Skip to content

fix: connection lifecycle fixes for socket death and close - #9

Open
Dodothereal wants to merge 4 commits into
alexanderolvera:mainfrom
Dodothereal:fix/6-connection-lifecycle-connected-lies-afte
Open

Dodothereal wants to merge 4 commits into
alexanderolvera:mainfrom
Dodothereal:fix/6-connection-lifecycle-connected-lies-afte

Conversation

@Dodothereal

@Dodothereal Dodothereal commented Jul 23, 2026 •

Copy link
Copy Markdown

Socket failures can leave connected stale; closing the client can strand queued calls; and a dead socket's late close event can tear down an immediate reconnect.

Fixes #6.

Share teardown between failure and intentional close, reject the active and queued calls, and guard every socket handler by socket identity. Also settle an outstanding connect() when the socket closes before the handshake reply, so both DfConnection and DwarfClient callers receive the causal error.

The offline lifecycle suite covers close/reconnect ordering, stale and repeated events, pre-handshake aborts, and public-client behavior. Its waits are bounded and a stalled suite exits unsuccessfully.

Validation on the final head: typecheck, lint, build, five existing protocol checks, and 42 lifecycle checks pass. Separate verification reproduced the prior failures, passed independent promise/event checks and real loopback TCP tests, and confirmed that a never-settling mutation fails the harness. Node 24 was tested; live DFHack interoperability remains unverified. Hosted CI requires maintainer approval: https://github.com/alexanderolvera/dfhack-remote-node/actions/runs/40252224414.

AI assistance: the original patch was prepared with Claude Code. This revision used DeepSeek V4.1 Flash, separate independent verification, and final Codex review. No human review is claimed.

- In fail(): destroy socket with error, set sock=null, shookHands=false, ready=null, and clear buffers.
- In close(): clear buffers after closing socket.
- In connect(): only call fail on 'close' event if socket is still open to avoid double-fail.

Fixes alexanderolvera#6

Assisted-by: Claude Code
Copilot AI review requested due to automatic review settings July 23, 2026 09:58

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@alexanderolvera alexanderolvera left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for this — and for picking up a first issue here. The core of it is right: resetting shookHands/ready and destroying the socket in fail() is exactly what issue #6 asked for, and I confirmed it fixes the headline bug. Against main, an instance whose socket dies still reports connected === true; with your branch it correctly reports false and the instance can reconnect.

I built an offline lifecycle harness to check the branch and hit two regressions I'd like resolved before merging. Both are reproducible, not theoretical.

1. close() now strands in-flight calls forever

Rejecting pending calls on close used to happen indirectly: close() set this.sock = null, the socket emitted 'close', and the handler called fail(), which rejected them. Now that the handler skips sockets already cleared by close(), nothing rejects them — so anything awaiting a reply hangs forever instead of getting an error.

### against PR #9 as filed ###
FAIL  close() rejects the in-flight call  (HUNG)
FAIL  close() rejects the queued call  (HUNG)

2. The socket guard checks a socket, not this socket

if (this.sock) is true whenever any socket is installed — including a newer one from a reconnect. Reconnecting at the first moment a caller can know the connection died (the rejection of the in-flight call) leaves the dead socket's trailing 'close' to tear down the socket that replaced it, so recovery never survives:

FAIL  reconnect succeeds immediately after a failure  (connected=false)
FAIL  the dead socket does not take the new one down  (connected=false)

This one also throws an unhandled Error: connection closed out of the 'close' handler at src/connection.ts:89 and crashes the process.

Suggested shape

Give the handlers an identity check, and let fail() and close() share one teardown so both always settle pending calls — the only difference being whether the socket is destroyed or ended cleanly. Clearing state before touching the socket matters: it makes the events that destroy()/end() themselves trigger fall through the guards instead of re-entering.

const sock = net.createConnection({ host: this.host, port: this.port });
this.sock = sock;
const current = () => this.sock === sock;

sock.on('data', (chunk: Buffer) => {
  if (!current()) return;
  /* ...unchanged... */
});
sock.on('error', (err) => {
  if (current()) this.fail(err);
  reject(err);
});
sock.on('close', () => {
  if (current()) this.fail(new Error('connection closed'));
});
private teardown(err: Error, destroy: boolean): void {
  const sock = this.sock;
  this.sock = null;
  this.shookHands = false;
  this.ready = null;
  this.buf = Buffer.alloc(0);
  this.textFrames = [];

  if (sock) {
    if (destroy) sock.destroy(err);
    else sock.end();
  }

  const pending = this.active ? [this.active, ...this.queue] : [...this.queue];
  this.active = null;
  this.queue = [];
  for (const call of pending) call.reject(err);
}

private fail(err: Error): void {
  this.teardown(err, true);
}

close(): void {
  this.teardown(new Error('connection closed'), false);
}

Regression test

I've written scripts/lifecycle-test.js — a mock server that handshakes and then misbehaves on purpose, covering all three parts of issue #6 plus the two regressions above. It's worth having in the repo either way, since it fails on main too (item 3, the uncleared parse buffer, crashes outright there). Say the word and I'll push it to this branch or land it separately so you can rebase onto it — whichever you prefer. With the changes above, it goes 8/8 green alongside the existing protocol test.

No rush on this, and thanks again — the diagnosis in your commit message was on the money.

close() now shares the failure teardown, rejecting in-flight and queued calls while ending the socket cleanly. Socket handlers only affect the connection that created them, so a dead socket's delayed close or error cannot tear down a replacement.

Add an offline fake-socket lifecycle suite covering close, reconnect ordering, repeated events, and intentional reuse.
connect() stored its handshake promise and then dropped the reference in
teardown(), so a socket that closed (or was explicitly closed) before the
handshake reply left the caller awaiting forever. teardown() now owns a
one-shot settlement handle for the outstanding attempt: it rejects the
pending connect() with the causal error before taking the socket down, and a
successful handshake clears it, so a stale error from a dead socket cannot
settle a replacement's promise or re-settle an already rejected one.

The lifecycle harness could also print FAIL lines and exit 0: it awaited raw
promises that never settle, with nothing left on the event loop. It now sets
a non-zero exit code before the first await, gives every awaited promise a
timeout, adds a whole-suite watchdog, and records unhandled rejections. New
cases cover remote close and explicit close before the handshake, stale
close/error/duplicate events around an immediate reconnect, and the
DwarfClient.connect() path.
Copilot AI balanced review requested due to automatic review settings October 26, 2026 01:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@Dodothereal

Dodothereal commented Oct 26, 2026 •

Copy link
Copy Markdown
Author

Thanks for the detailed reproductions. Both reported regressions are fixed: close now rejects the active and queued calls, and each handler checks its own socket identity, so delayed events from a dead socket leave the replacement alone.

The review also caught a connect promise that stayed pending if the socket closed before the handshake. Teardown now settles that attempt with its original error. I added offline lifecycle coverage and made the harness fail if an awaited promise stalls. Typecheck, lint, build, the five protocol checks and 42 lifecycle checks pass, with separate loopback TCP and regression verification. I have not tested against a live DFHack server.

Hosted CI is waiting for maintainer approval: https://github.com/alexanderolvera/dfhack-remote-node/actions/runs/40252224414.

This branch has not been deployed

No deployments
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.

connection lifecycle: connected lies after socket death; desync and close() leave stale state

3 participants