Found during the independent review of #291. Pre-existing — not introduced by that PR — but worth fixing on its own, because the daemon's outbound channels are now keyed by descriptor too, which makes a wrong-descriptor write more consequential than it was.
The interleaving
ProjectRegistry.removeConnection iterates a copy of connectionProjectPaths[id] with an await per store. While it is suspended:
- Another connection's
.openProject of a new path runs joinSidebars.
- That inserts the departing sidebar into the new store —
connectionProjectPaths[X].insert(B) plus storeB.addConnection(X, fdX).
- The removal loop never sees
B, because it is walking a snapshot taken before.
close(fdX) runs anyway.
storeB is then left holding X → fdX indefinitely. Every later broadcast for project B goes to whichever connection has since been handed that descriptor number — including a one-shot CLI, which would print the first graphChanged it sees as though it were its own project's.
It needs the app disconnecting at the same instant another client opens a new folder, so it is rare. No deterministic failing test yet — this is reasoning from ProjectRegistry.swift:128-137 and :390-397.
Suggested fix
Two parts, from the review:
- Key the connection registry by connection
UUID rather than by file descriptor. Every caller already has the UUID, and it is the identity that is actually stable — a descriptor number is reused the moment it closes. This applies to OutboundChannels as well, whose global map is descriptor-keyed today.
- Re-read the path set after each
await in removeConnection, rather than iterating a snapshot taken before suspending.
Why not in #291
#291 is already large and touches the same files; folding a registry-wide re-keying into it would make it unreviewable. Its own descriptor-recycling hazards are closed (open refuses to inherit a dead channel, send refuses an unregistered descriptor, close refuses a descriptor it has no channel for), so this is the remaining structural half rather than an open hole.
🤖 Generated with Claude Code
https://claude.ai/code/session_01BP43ags4cn8fq2ZZdv85J9
Found during the independent review of #291. Pre-existing — not introduced by that PR — but worth fixing on its own, because the daemon's outbound channels are now keyed by descriptor too, which makes a wrong-descriptor write more consequential than it was.
The interleaving
ProjectRegistry.removeConnectioniterates a copy ofconnectionProjectPaths[id]with anawaitper store. While it is suspended:.openProjectof a new path runsjoinSidebars.connectionProjectPaths[X].insert(B)plusstoreB.addConnection(X, fdX).B, because it is walking a snapshot taken before.close(fdX)runs anyway.storeBis then left holdingX → fdXindefinitely. Every later broadcast for project B goes to whichever connection has since been handed that descriptor number — including a one-shot CLI, which would print the firstgraphChangedit sees as though it were its own project's.It needs the app disconnecting at the same instant another client opens a new folder, so it is rare. No deterministic failing test yet — this is reasoning from
ProjectRegistry.swift:128-137and:390-397.Suggested fix
Two parts, from the review:
UUIDrather than by file descriptor. Every caller already has the UUID, and it is the identity that is actually stable — a descriptor number is reused the moment it closes. This applies toOutboundChannelsas well, whose global map is descriptor-keyed today.awaitinremoveConnection, rather than iterating a snapshot taken before suspending.Why not in #291
#291 is already large and touches the same files; folding a registry-wide re-keying into it would make it unreviewable. Its own descriptor-recycling hazards are closed (
openrefuses to inherit a dead channel,sendrefuses an unregistered descriptor,closerefuses a descriptor it has no channel for), so this is the remaining structural half rather than an open hole.🤖 Generated with Claude Code
https://claude.ai/code/session_01BP43ags4cn8fq2ZZdv85J9