Skip to content

Commit 6695b5f

Browse files
committed
fix(workspace-client): unwatchGit/unwatchFs can strand a fresh, unretained connection
Found by a review of #6856's cleanup-effect ordering, but pre-existing — affects fs:watch too, and predates both #6848 and #6856. getEventBus(hostUrl) unconditionally (re)creates a ConnectionState if none exists in the shared `connections` map. unwatchGit/unwatchFs/unwatchFsFile never called maybeCleanupConnection, unlike on()'s and retain()'s cleanups — so a caller that only ever intends to *release* interest (never establish it) could, if the connection's last retainer/listener already tore it down moments earlier, silently mint a brand-new WebSocket connection just to send an unwatch command, then leave it dangling forever: nothing else would ever call maybeCleanupConnection for it again. This is a real, reachable ordering hazard in a multi-effect component like DashboardSidebarWorkspaceStatusProvider: React runs effect cleanups in declaration order (not reversed), so a "listener registration" effect's cleanup releasing a connection's last retain/listener, followed by a separate "release git-watch interest" effect's cleanup (or body, on a mid-session workspace removal) calling unwatchGit for the same host, hits this exactly. Fix: unwatchGit/unwatchFs/unwatchFsFile now call maybeCleanupConnection after releasing their own interest, mirroring on()'s and retain()'s cleanups. Verified with a real WS server (no mocks): before the fix, a release-only call after the connection's teardown opens a second real upgrade that never closes; after the fix, maybeCleanupConnection fires synchronously and fast enough that the stray connection never even dials out — confirmed via a standalone diagnostic script before writing the regression test, so the test's "no upgrade" assertion isn't guesswork. Claude-Session: https://claude.ai/code/session_01NmLFihnhebmL9bbbojGYCR
1 parent dff3d45 commit 6695b5f

2 files changed

Lines changed: 49 additions & 0 deletions

File tree

packages/workspace-client/src/lib/eventBus.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,4 +148,39 @@ describe("eventBus", () => {
148148
await Bun.sleep(1_500);
149149
expect(host.clientCount()).toBe(0);
150150
});
151+
152+
it("releasing interest via a fresh handle, after the connection's last listener already closed it, does not strand a new connection", async () => {
153+
const host = makeHostServer();
154+
const bus = getEventBus(host.hostUrl, () => "tok");
155+
const off = bus.on("git:changed", "*", () => {});
156+
bus.watchGit("ws-1");
157+
cleanups.push(() => host.server.stop(true));
158+
await waitFor(() => host.clientCount() === 1);
159+
expect(host.upgrades.length).toBe(1);
160+
161+
// Simulates a sibling effect's cleanup running first and releasing
162+
// this connection's only listener/retainer — a real, order-dependent
163+
// scenario in a multi-effect component (see DashboardSidebar-
164+
// WorkspaceStatusProvider's final-unmount and mid-session-removal
165+
// cleanup ordering).
166+
off();
167+
await waitFor(() => host.clientCount() === 0);
168+
169+
// A *fresh* getEventBus() call, exactly matching the real call site
170+
// (getHostEventBus(hostUrl).unwatchGit(...) — never the same cached
171+
// handle). getEventBus() unconditionally (re)creates a ConnectionState
172+
// if none exists; before this fix, nothing ever closed the socket it
173+
// opens back down (unwatchGit/unwatchFs never called
174+
// maybeCleanupConnection), so it would dial in and reconnect forever
175+
// (a second real upgrade hits the server, `host.upgrades.length`
176+
// reaches 2, and `clientCount()` gets stuck at 1).
177+
getEventBus(host.hostUrl, () => "tok").unwatchGit("ws-1");
178+
179+
// With the fix, maybeCleanupConnection closes the fresh connection
180+
// synchronously — fast enough that it never even dials out. No
181+
// second upgrade ever reaches the server, and no client lingers.
182+
await Bun.sleep(1_500);
183+
expect(host.upgrades.length).toBe(1);
184+
expect(host.clientCount()).toBe(0);
185+
});
151186
});

packages/workspace-client/src/lib/eventBus.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,14 @@ export function getEventBus(
480480
if (count <= 1) {
481481
state.fsWatchedWorkspaces.delete(workspaceId);
482482
sendCommand(state, { type: "fs:unwatch", workspaceId });
483+
// getEventBus() above always creates the connection if it didn't
484+
// already exist — a caller that only ever intends to release
485+
// interest (a cleanup effect running after this connection's
486+
// last retainer already tore it down) would otherwise mint a
487+
// fresh, unretained, unlistened-to connection here and leave it
488+
// dangling forever, since nothing else will ever call this again
489+
// for it. Mirrors on()'s and retain()'s cleanup.
490+
maybeCleanupConnection(hostUrl);
483491
} else {
484492
state.fsWatchedWorkspaces.set(workspaceId, count - 1);
485493
}
@@ -498,6 +506,9 @@ export function getEventBus(
498506
if (count <= 1) {
499507
state.gitWatchedWorkspaces.delete(workspaceId);
500508
sendCommand(state, { type: "git:unwatch", workspaceId });
509+
// See unwatchFs's comment: a release-only call can otherwise mint
510+
// and permanently strand a fresh, never-retained connection.
511+
maybeCleanupConnection(hostUrl);
501512
} else {
502513
state.gitWatchedWorkspaces.set(workspaceId, count - 1);
503514
}
@@ -526,6 +537,9 @@ export function getEventBus(
526537
workspaceId,
527538
absolutePath,
528539
});
540+
// See unwatchFs's comment: a release-only call can otherwise mint
541+
// and permanently strand a fresh, never-retained connection.
542+
maybeCleanupConnection(hostUrl);
529543
} else {
530544
state.fsWatchedFiles.set(key, count - 1);
531545
}

0 commit comments

Comments
 (0)