Skip to content

Channel: invoke _write callback with an error on non-open channels - #1509

Open
bilby91 wants to merge 1 commit into
mscdex:masterfrom
bilby91:fix/channel-write-callback-drop
Open

Channel: invoke _write callback with an error on non-open channels#1509
bilby91 wants to merge 1 commit into
mscdex:masterfrom
bilby91:fix/channel-write-callback-drop

Conversation

@bilby91

@bilby91 bilby91 commented Aug 13, 2026

Copy link
Copy Markdown

Fixes #1508.

Disclosure, following the precedent of #1483: this fix was developed with AI assistance (Claude), but the bug is real and human-verified — it froze large HTTP responses at deterministic offsets in our production SSH tunnel, and every validation step below was run against this branch and against pristine master.

Problem

Channel._write() and ServerStderr._write() return without invoking the write callback when outgoing.state !== 'open'. When a channel is closed while writes are parked awaiting window credit — the normal state for any port-forwarding proxy whose source socket finishes while the tunnel is still draining — the WINDOW_ADJUST that arrives after close() re-enters _write() through the flush path (CHANNEL_WINDOW_ADJUST handler → channel._write(channel._chunk, null, channel._chunkcb)), hits the guard, and silently discards both the chunk and its callback.

The Writable is then permanently wedged: 'finish' can never fire and every subsequently buffered write waits forever — while the receiver sees a clean EOF. Measured with the repro in #1508: of 4,194,304 bytes accepted by write(), exactly 2,097,152 (the initial window) were delivered, and 32 of 64 write callbacks were never invoked, with no error anywhere.

Change

Invoke the callback with an error instead of silently returning, in both _write implementations. Pending writes now complete observably and the stream can tear down.

This is deliberately the minimal fix. The fuller alternative — deferring SSH_MSG_CHANNEL_CLOSE until the writable side has flushed, which would preserve the data instead of erroring it — changes close() semantics for callers that use it as an abort, so I've left that as a design decision for the maintainer (details in #1508); this change is compatible with doing that later.

Test

Adds a regression to test-misc-client-server.js using the existing setup/mustCall harness: a forwarded channel writes past the window against a paused receiver, calls end() + close(), and the receiver resumes only afterwards so the adjust arrives post-close. Every queued write callback is wrapped in mustCall, so the silent drop fails the test on master (32 uninvoked callbacks + timeout) and passes with this change.

Channel._write() and ServerStderr._write() returned without invoking the
write callback when outgoing.state !== 'open'. When a channel is closed
while writes are parked awaiting window credit (a normal state for
port-forwarding proxies whose source finishes while the tunnel drains),
the WINDOW_ADJUST that arrives after close() re-enters _write() through
the flush path, hits the guard, and silently discards both the chunk and
its callback. The Writable then never finishes: 'finish' cannot fire and
all subsequently buffered writes wait forever, while the receiver sees a
clean EOF — silent truncation with no error on either side.

Invoke the callback with an error instead, so pending writes complete
observably and the stream can tear down. Deferring CHANNEL_CLOSE until
the writable has flushed (preserving the data instead of erroring it) is
a possible follow-up but changes close() semantics for callers that use
it as an abort, so it is left out of this change.

Fixes: mscdex#1508

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.

Channel.close() while writes await window credit silently drops buffered data and never invokes write callbacks

1 participant