fix(daemon): release the pending IPC slot when write throws - #1045
Conversation
DaemonIpcClient.request registers a pending slot, then writes to the socket. A socket that is already closed makes write() throw synchronously, and the slot stayed in the pending map for the lifetime of the client. Catch the synchronous throw, delete the slot, and reject explicitly. The caller already saw a rejection before this change, because a throw inside a Promise executor rejects that promise. The leak was the map entry alone, so the observable fix is the released slot rather than the rejection. Closes Nano-Collective#1040
will-lamerton
left a comment
There was a problem hiding this comment.
Thanks for this, and especially for writing the regression test first and correcting the "hang" claim in the issue. I verified it: the test fails on main with exactly the - 1 / + 0 you reported and passes on the branch. The fix itself is correct and idiomatic.
One thing needs changing before merge.
The changeset states a mechanism that isn't real
Both the issue and the changeset say write() throws synchronously when the socket is already closed. It doesn't. On Node 22.15:
- write after
destroy(): returnsfalse, no throw, no error event - write after
end(): returnsfalse, no throw, asyncERR_STREAM_WRITE_AFTER_END
net.Socket.prototype.write only throws synchronously when the chunk isn't a string or ArrayBufferView. So the repro steps in #1040 never hit this path. The closed-socket case was already safe via handleClose(), which rejects and clears every pending slot on the socket's close event.
The guard is still worth having, but the realistic sync-throw source in that block is JSON.stringify on circular params, not write. Since the changeset ships verbatim into the published changelog, I'd rather it didn't assert the wrong cause. Suggested wording:
Fixed a leaked pending slot in the daemon IPC client. If serializing or writing a request threw synchronously, the request's entry stayed in the pending map for the lifetime of the client, one per failed request. The slot is now released and the request rejected explicitly. Closes #1040.
Suggestions (non-blocking)
source/daemon/ipc.ts:248 - the ?. is now dead and hides the same bug class. request() already throws at line 244 if !this.socket, and the executor runs synchronously, so it can't be null here. But if it ever were, ?. would skip the write and leave the slot pending with no rejection, which is exactly the leak you're fixing. Capturing it first makes the guard total:
const socket = this.socket;
const id = this.nextId++;
return new Promise((resolve, reject) => {
this.pending.set(id, {resolve, reject});
try {
socket.write(`${JSON.stringify({id, method, params} satisfies IpcRequest)}\n`);
} catch (error) { ... }
});Test stub restoration. Overwriting internals.socket.write survives the finally only because Writable.end() with no chunk doesn't route through the public write. Restoring the original in finally would decouple the test from that internal.
Optional second case. A test that destroys the socket, fires a request, and asserts the promise rejects and pending drains via handleClose would cover the path the issue actually describes. Nothing in the suite pins that today.
Adjacent, please don't fix here
Two things I noticed while reading, both pre-existing and better as separate issues if you're interested:
- No request timeout. If the daemon accepts the connection but never responds and never closes the socket, the slot leaks and the promise never settles. That's the real route to the "hangs" symptom in the issue title.
connect()(source/daemon/ipc.ts:204) never removes itss.once('error', reject)on success. Post-connect, the first socket error is swallowed by a no-op reject and consumes the listener, so a seconderrorhas no listener and takes the process down.
The changeset claimed write() throws synchronously on a closed socket. It does not. Measured on Node 24.18: write after destroy() returns false, write after end() returns false and emits ERR_STREAM_WRITE_AFTER_END asynchronously, and net.Socket.write only throws synchronously for a chunk that is not a string or ArrayBufferView. The closed-socket path was already safe through handleClose(), which rejects and clears every pending slot. The realistic synchronous throw in that block is JSON.stringify on circular params, so the changeset now says "serializing or writing" instead of naming a mechanism that does not exist. Capture the socket before the promise instead of writing through an optional chain. request() already throws when there is no socket, so the chain was dead, and had it ever been null it would have skipped the write and left the slot pending with no rejection, which is the leak this fixes. Restore the stubbed write in the test rather than relying on Writable.end() not routing through the public write. Add a test for the path the issue actually describes: destroy the socket with a request in flight, and assert it rejects and the pending map drains. Reported by @will-lamerton in review of Nano-Collective#1045.
|
You are right, and I reproduced it rather than taking it on faith. Pushed in 3494f8e. The mechanism does not existProbed the four cases directly on Node 24.18, so it is not version-specific to your 22.15: So the repro steps in #1040 never reach the That makes this the second wrong claim in the issue after the "hang", and I should have checked the mechanism as carefully as I checked the leak itself. The leak is real, the stated cause was not. Suggestions, all three takenThe dead Stub restoration. Restored in a The close-path test. Added: destroy the socket with a request in flight, assert it rejects with VerificationSemgrep still not installed here, so that step is unverified on my side. The two adjacent onesLeft both alone as you asked. Happy to file them with what I found if useful:
Say the word, or take them yourself since you found them. |
|
Thanks for this PR @rascal-sl - feel free to add yourself as a contributor to our website via a PR which I will approve :) https://nanocollective.org/contributors |
Description
DaemonIpcClient.requestregisters a pending slot and then writes to the socket:When the socket is already closed,
write()throws synchronously and the slot stays inpendingfor the lifetime of the client. Every failed request adds one more entry that nothing ever removes.Now the synchronous throw is caught, the slot deleted, and the promise rejected explicitly.
One correction to the issue
The issue says the promise is never resolved or rejected and that the request hangs. That part does not reproduce. A throw inside a Promise executor rejects that promise, so the caller already got a rejection before this change.
I wrote the regression test first to check, and it separates the two claims cleanly. Against unfixed
main:The rejection assertion in that same test passed. Only
pending.sizewas wrong,1where0was expected. So the leak is real and worth fixing, the hang is not. I kept the explicitrejectanyway rather than relying on executor semantics, since depending on an implicit throw for control flow is not obvious to the next reader.Type of Change
Tests
Added one case to
source/daemon/ipc.spec.ts. It stubswriteon the connected socket to throw, then asserts the call rejects and that the pending map is empty. It fails onmainand passes here.Reading the private
pendingmap through a cast is deliberate: the leak has no other observable effect from outside the class.Commands run
Not run: Semgrep, which is not installed here.
pnpm run buildis needed beforetest:allon a fresh clone, otherwise thecli-integration.spec.tscases fail onMODULE_NOT_FOUNDbecause they spawndist/cli.js.Platform: macOS, Node 24, pnpm 11.
Note
This branches from
mainand is independent of #1043, which fixes#1042insource/daemon/cli.ts. The two can merge in either order.