Channel: invoke _write callback with an error on non-open channels - #1509
Open
bilby91 wants to merge 1 commit into
Open
Channel: invoke _write callback with an error on non-open channels#1509bilby91 wants to merge 1 commit into
bilby91 wants to merge 1 commit into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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()andServerStderr._write()return without invoking the write callback whenoutgoing.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 — theWINDOW_ADJUSTthat arrives afterclose()re-enters_write()through the flush path (CHANNEL_WINDOW_ADJUSThandler →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 bywrite(), 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
_writeimplementations. Pending writes now complete observably and the stream can tear down.This is deliberately the minimal fix. The fuller alternative — deferring
SSH_MSG_CHANNEL_CLOSEuntil the writable side has flushed, which would preserve the data instead of erroring it — changesclose()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.jsusing the existingsetup/mustCallharness: a forwarded channel writes past the window against a paused receiver, callsend()+close(), and the receiver resumes only afterwards so the adjust arrives post-close. Every queued write callback is wrapped inmustCall, so the silent drop fails the test on master (32 uninvoked callbacks + timeout) and passes with this change.node test/test-misc-client-server.js: fails on master, passes on this branchnode test/test.js(full suite): passes on this branch