-
Notifications
You must be signed in to change notification settings - Fork 5
ProjectRegistry.removeConnection is reentrant, and the connection registry is keyed by descriptor #292
Description
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
.openProjectof a new path runsjoinSidebars. - That inserts the departing sidebar into the new store —
connectionProjectPaths[X].insert(B)plusstoreB.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
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. - Re-read the path set after each
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 (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