From 928757542e09a7632a37940b93049e3ac39b1fd0 Mon Sep 17 00:00:00 2001 From: YueGuobin Date: Fri, 2 Oct 2026 20:56:58 +0800 Subject: [PATCH] fix(controller): bind and release link members on their live ports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Link.delete cleared the port link through the port object stored at add_node time; a node update since then rebuilt the port list, so that object was stale and the live port kept its link — the next link on the port was refused with "Port is already used". Masked until now by the older rebuild bug (c2d68a65d): links never survived a rebuild, so the guard never fired and the stale clear never mattered. --- gns3server/controller/link.py | 28 ++++++++++++++++++++++------ gns3server/controller/project.py | 8 ++------ tests/controller/test_link.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 12 deletions(-) diff --git a/gns3server/controller/link.py b/gns3server/controller/link.py index 013b7e509..909792ac8 100644 --- a/gns3server/controller/link.py +++ b/gns3server/controller/link.py @@ -438,9 +438,7 @@ class Link: if len(self._nodes) == 2 and not batch: await self.create() - for n in self._nodes: - n["node"].add_link(self) - n["port"].link = self + self._bind_members() self._created = True self._project.emit_notification("link.created", self.asdict()) @@ -471,14 +469,32 @@ class Link: """ raise NotImplementedError + def _bind_members(self): + """ + Wire the back-references on every member: the node's link set and the + port's link. The port is resolved live, through the node's current + port list — the object stored at add_node time goes stale whenever a + node update rebuilds that list (``parse_node_response``), and a + back-reference set on the stale object is invisible to the "Port is + already used" guard in add_node, which reads the live one. + """ + + for n in self._nodes: + n["node"].add_link(self) + port = n["node"].get_port(n["adapter_number"], n["port_number"]) or n["port"] + port.link = self + async def delete(self): """ Delete the link """ for n in self._nodes: - # It could be different of self if we rollback an already existing link - if n["port"].link == self: - n["port"].link = None + # The live port, for the same staleness reason as _bind_members; + # the link could be different from self if we rollback an + # already existing link + port = n["node"].get_port(n["adapter_number"], n["port_number"]) or n["port"] + if port.link == self: + port.link = None n["node"].remove_link(self) async def reset(self): diff --git a/gns3server/controller/project.py b/gns3server/controller/project.py index c5aedf731..e2def78c9 100644 --- a/gns3server/controller/project.py +++ b/gns3server/controller/project.py @@ -987,9 +987,7 @@ class Project: # an image is missing. Keep the link on the controller (so the # topology is preserved) but defer the NIO creation until the # missing image is resolved. - for n in link._nodes: - n["node"].add_link(link) - n["port"].link = link + link._bind_members() link._deferred = True log.info( "Project '%s' [%s]: deferring link %s until missing image(s) are resolved", @@ -1990,9 +1988,7 @@ class Project: # Finalise every link: wire node/port back-references, mark created, # notify clients, and apply project-level marker definitions. for link, _entries in valid: - for n in link._nodes: - n["node"].add_link(link) - n["port"].link = link + link._bind_members() link._created = True self.emit_notification("link.created", link.asdict()) log.info("Project '%s' [%s]: created %d links", self._name, self._id, len(valid)) diff --git a/tests/controller/test_link.py b/tests/controller/test_link.py index 23e2d8f35..8f932ae28 100644 --- a/tests/controller/test_link.py +++ b/tests/controller/test_link.py @@ -141,6 +141,37 @@ async def test_add_node_already_connected_after_node_update(project, compute): await link2.add_node(node1, 0, 0) +@pytest.mark.asyncio +async def test_link_delete_after_node_update_releases_port(project, compute): + """ + Link.delete drops the port's link reference through the port object it + stored at add_node time; a node update since then rebuilt the port list, + so that stored object is stale. The delete must release the *live* port, + or the next link on it is refused with "Port is already used". + """ + + project.dump = AsyncioMagicMock() + + node1 = Node(project, compute, "node1", node_type="qemu") + node2 = Node(project, compute, "node2", node_type="qemu") + + link = Link(project) + link.create = AsyncioMagicMock() + link.node_updated = AsyncioMagicMock() + link._project.emit_notification = MagicMock() + await link.add_node(node1, 0, 0) + await link.add_node(node2, 0, 0) + + # a node update (e.g. the status change of a start) rebuilds the ports + await node1.parse_node_response({"status": "started"}) + + await link.delete() + + link2 = Link(project) + link2.create = AsyncioMagicMock() + await link2.add_node(node1, 0, 0) # must not raise "Port is already used" + + @pytest.mark.asyncio async def test_add_node_cloud(project, compute):