Skip to content

fix(daemon): release the pending IPC slot when write throws - #1045

Merged
will-lamerton merged 3 commits into
Nano-Collective:mainfrom
tisankanj:fix-ipc-pending-leak
Aug 30, 2026
Merged

will-lamerton merged 3 commits into
Nano-Collective:mainfrom
tisankanj:fix-ipc-pending-leak

Conversation

@tisankanj

Copy link
Copy Markdown
Contributor

Description

DaemonIpcClient.request registers a pending slot and then writes to the socket:

this.pending.set(id, {resolve, reject});
this.socket?.write(...);

When the socket is already closed, write() throws synchronously and the slot stays in pending for 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:

✘ [fail]: request rejects and releases the pending slot when write throws

  Difference (- actual, + expected):
  - 1
  + 0

The rejection assertion in that same test passed. Only pending.size was wrong, 1 where 0 was expected. So the leak is real and worth fixing, the hang is not. I kept the explicit reject anyway 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

  • Bug fix

Tests

Added one case to source/daemon/ipc.spec.ts. It stubs write on the connected socket to throw, then asserts the call rejects and that the pending map is empty. It fails on main and passes here.

Reading the private pending map through a cast is deliberate: the leak has no other observable effect from outside the class.

Commands run

$ npx ava source/daemon/ipc.spec.ts
  8 tests passed

$ pnpm run test:all
✅ AVA tests passed
✅ Knip check passed
✅ Audit passed
⚠️  Semgrep not installed - skipping security scan
✅ Everything passes!

Not run: Semgrep, which is not installed here. pnpm run build is needed before test:all on a fresh clone, otherwise the cli-integration.spec.ts cases fail on MODULE_NOT_FOUND because they spawn dist/cli.js.

Platform: macOS, Node 24, pnpm 11.

Note

This branches from main and is independent of #1043, which fixes #1042 in source/daemon/cli.ts. The two can merge in either order.


Tisankan Jeyakumar
Developed and verified

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 will-lamerton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(): returns false, no throw, no error event
  • write after end(): returns false, no throw, async ERR_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 its s.once('error', reject) on success. Post-connect, the first socket error is swallowed by a no-op reject and consumes the listener, so a second error has 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.
@tisankanj

Copy link
Copy Markdown
Contributor Author

You are right, and I reproduced it rather than taking it on faith. Pushed in 3494f8e.

The mechanism does not exist

Probed the four cases directly on Node 24.18, so it is not version-specific to your 22.15:

write after destroy():        NO SYNC THROW, write() returned false
write after end():            NO SYNC THROW, write() returned false
                              [async error] ERR_STREAM_WRITE_AFTER_END
non-string chunk:             SYNC THROW TypeError ERR_INVALID_ARG_TYPE
JSON.stringify(circular):     SYNC THROW TypeError

So the repro steps in #1040 never reach the catch, and handleClose() already rejects and clears every slot on the socket's close event. I took your changeset wording verbatim, since "serializing or writing" is the accurate description and JSON.stringify on circular params is the realistic source.

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 taken

The dead ?.. Captured the socket first. Your reasoning is the same argument as the fix itself: had it been null, the write would be skipped and the slot left pending with no rejection.

Stub restoration. Restored in a finally, so the test no longer depends on Writable.end() bypassing the public write.

The close-path test. Added: destroy the socket with a request in flight, assert it rejects with IPC connection closed and the pending map drains. It passes, which pins the behaviour that made the issue's premise wrong in the first place. Nine tests in the file now.

Verification

$ npx ava source/daemon/ipc.spec.ts
  9 tests passed

# guard removed
✘ [fail]: request rejects and releases the pending slot when write throws
  Difference (- actual, + expected):
  - 1
  + 0

$ pnpm run test:all
✅ Everything passes!

Semgrep still not installed here, so that step is unverified on my side.

The two adjacent ones

Left both alone as you asked. Happy to file them with what I found if useful:

  • no request timeout, which is the actual route to the "hangs" symptom in the issue title
  • connect() never removing its once('error', reject) on success, so the first post-connect socket error is swallowed by a no-op reject and the second has no listener

Say the word, or take them yourself since you found them.

@will-lamerton

Copy link
Copy Markdown
Member

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
https://github.com/Nano-Collective/organisation

@will-lamerton
will-lamerton merged commit e39f6e6 into Nano-Collective:main Aug 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Sponsor
SponsoredKunjungi sekarang
Promo