You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This report was investigated and written with AI assistance (Claude), but the bug is real and human-verified: it froze large HTTP responses in our production SSH tunnel (proxy → reverse-forwarded channel → local service) at exactly total − buffered-tail bytes, and the reproduction below was validated by hand against pristine master (318d447) and v1.17.0. Following the disclosure precedent of #1483 — which appears to be a sibling of this bug (both are teardown-state guards misbehaving between close() and the remote's close).
Summary
Channel._write() silently discards both the chunk and its stream callback when outgoing.state !== 'open':
_write(data,encoding,cb){
...
if(outgoing.state!=='open')return;// cb is never invoked
If an application calls channel.close() (or channel.destroy(), which is end() + close()) while writes are parked waiting for CHANNEL_WINDOW_ADJUST — the normal state for any port-forwarding proxy whose source socket closes while the tunnel is still draining — then when the adjust arrives, the flush path (CHANNEL_WINDOW_ADJUST handler → channel._write(channel._chunk, null, channel._chunkcb)) hits that guard and returns. Consequences:
Silent data loss: everything buffered beyond the window is discarded — and the receiver still sees a clean EOF, so the truncation is invisible to both sides.
Permanently wedged Writable: the in-flight _write callback is never invoked, so 'finish' never fires and all further buffered writes sit forever. In our production case (HTTP with Content-Length), the consumer hung indefinitely waiting for bytes that had been dropped.
The write-side park state that makes this reachable is easy to hit: the default window is 2 MiB, so any transfer larger than that with a receiver slower than the sender has parked writes for most of its lifetime.
Reproduction (self-contained, exits 1 on the bug)
Output on master (318d447) and v1.17.0, Node 25.9.0 (also observed on 26.7.0):
bytes queued into channel: 4194304
receiver got: 2097152 <- exactly the 2 MiB initial window
receiver saw EOF: true <- clean-looking end: silent truncation
sender 'finish': false
write cbs queued/called: 64/32 (0 with error)
BUG: 2097152 buffered bytes silently dropped and 32 write callback(s)
never invoked (Channel._write returns without calling cb when
outgoing.state !== 'open').
repro-close-drops-writes.js
"use strict";/** * Reproduction: Channel.close() while writes are parked on window credit * silently drops the buffered data AND its stream callback, wedging the * Writable forever. * * Scenario (common in port-forwarding proxies): a source stream is piped * into a forwarded channel; the source finishes and closes, and the app * calls channel.end() + channel.close() (i.e. channel.destroy()) while the * channel still holds data the remote hasn't granted window credit for. * * Expected: the buffered data flushes when WINDOW_ADJUST arrives (or the * pending write callback is invoked with an error), 'finish'/'end' fire, * and the receiver sees all bytes or an error. * * Actual: Channel._write() begins with `if (outgoing.state !== 'open') * return;` — when the adjust arrives after close() set state='closing', * the flush re-invokes _write(), which returns without sending anything * and WITHOUT calling the callback. The Writable never finishes, 'finish' * never fires, and the receiver hangs mid-stream missing the tail. * * Run: node repro-close-drops-writes.js (exits 1 on the bug, 0 if fixed) */const{ Server, Client }=require("./lib/index.js");const{ generateKeyPairSync }=require("crypto");constPAYLOAD=5*1024*1024;// > 2 MiB initial window: forces parkingconstCHUNK=64*1024;consthostKey=generateKeyPairSync("rsa",{modulusLength: 2048}).privateKey.export({type: "pkcs1",format: "pem"});letreceived=0;letreceiverEnded=false;letsenderFinished=false;letqueuedBytes=0;letcbsQueued=0;letcbsCalled=0;letcbErrors=0;constsenderErrors=[];constserver=newServer({hostKeys: [hostKey]},(conn)=>{conn.on("authentication",(ctx)=>ctx.accept());conn.on("request",(accept,_reject,name)=>{if(name!=="tcpip-forward")return;accept();// Give the client a beat to record the forwarding binding (it accepts// forwarded-tcpip opens only for registered bindings), then open the// forwarded connection; the client side will be the sender under test.setTimeout(()=>openForwarded(conn),100);});functionopenForwarded(conn2){conn2.forwardOut("localhost",9999,"127.0.0.1",1234,(err,channel)=>{if(err)throwerr;// Slow receiver: consume in small paced reads so the sender exhausts// its 2 MiB window and parks before the payload is fully sent.channel.pause();constdrain=setInterval(()=>{letchunk;while((chunk=channel.read(16*1024))!==null){received+=chunk.length;break;// one small read per tick}},1);channel.on("end",()=>{receiverEnded=true;clearInterval(drain);});channel.on("close",()=>clearInterval(drain));});}});server.listen(0,"127.0.0.1",()=>{constclient=newClient();client.on("ready",()=>{client.forwardIn("localhost",9999,(err)=>{if(err)throwerr;});});client.on("tcp connection",(_info,accept)=>{constchannel=accept();// sender side under test// Write the payload. Everything beyond the 2 MiB window parks inside// the channel awaiting WINDOW_ADJUST.letsawFalse=false;constwriteMore=()=>{while(queuedBytes<PAYLOAD){constsize=Math.min(CHUNK,PAYLOAD-queuedBytes);queuedBytes+=size;constok=channel.write(Buffer.alloc(size,0x61),(err)=>{cbsCalled++;if(err)cbErrors++;});cbsQueued++;if(!ok&&!sawFalse){sawFalse=true;// The window is exhausted and writes are parked. This is the// moment a proxy's source socket typically closes: the app ends// and closes the channel, expecting the buffered tail to flush.setImmediate(()=>{channel.end();// graceful: should flush buffered data, then EOFchannel.close();// what apps (and Channel.destroy()) do next});return;// stop writing more; the tail is already buffered}if(!ok)returnchannel.once("drain",writeMore);}channel.end();};channel.on("finish",()=>{senderFinished=true;});channel.on("error",(err)=>{senderErrors.push(err.message);});writeMore();});constport=server.address().port;client.connect({host: "127.0.0.1",
port,username: "test",password: "test",});});setTimeout(()=>{console.log(`bytes queued into channel: ${queuedBytes}`);console.log(`receiver got: ${received}`);console.log(`receiver saw EOF: ${receiverEnded}`);console.log(`sender 'finish': ${senderFinished}`);console.log(`write cbs queued/called: ${cbsQueued}/${cbsCalled} (${cbErrors} with error)`);console.log(`sender 'error' events: ${senderErrors.length}${senderErrors[0]??""}`);constdataLost=received<queuedBytes;constcbsLost=cbsCalled<cbsQueued;if(dataLost&&cbsLost&&senderErrors.length===0){console.log("\nBUG: "+(queuedBytes-received)+" buffered bytes silently dropped and "+(cbsQueued-cbsCalled)+" write callback(s) never invoked "+"(Channel._write returns without calling cb when outgoing.state !== 'open').",);process.exit(1);}if(dataLost){console.log("\nPARTIAL: data still dropped, but the failure is observable (cb/error fired).");process.exit(2);}console.log("\nOK: buffered data flushed — bug not present.");process.exit(0);},8000);
Suggested fix (two tiers, both verified against the repro)
Backstop (small): invoke the callback with an error instead of silently returning, in both Channel._write and ServerStderr._write:
if(outgoing.state!=='open'){cb(newError('Channel is not open'));return;}
Verified: the Writable un-wedges and the failure becomes observable (64/64 callbacks invoked, 32 with the error). Data buffered at close time is still lost, but callers can now see it.
Complete: make close() defer SSH_MSG_CHANNEL_CLOSE until the writable side has flushed (buffered data drains as window credit arrives, then EOF, then CLOSE). That preserves the data and matches what callers of end() + close()/destroy() reasonably expect from a stream. It also composes with Fix: windowAdjust sends after CHANNEL_CLOSE #1483's concern: today, sending CLOSE while the local writable still holds data is precisely the state that later tempts post-CLOSE messages.
Happy to turn either tier into a PR with the repro as a regression test if there's interest.
Context
Found while diagnosing multi-megabyte HTTP responses freezing at deterministic offsets in an SSH reverse-tunnel proxy (ssh2 Server on one end, Client + forwardIn on the other). The application-level workaround — never call close() on a channel whose writable side may still hold data; only force-close on abnormal teardown — works, but every ssh2-based forwarding proxy is likely to have written the natural socket.on('close', () => channel.close()) and be silently truncating large transfers under backpressure.
Disclosure
This report was investigated and written with AI assistance (Claude), but the bug is real and human-verified: it froze large HTTP responses in our production SSH tunnel (proxy → reverse-forwarded channel → local service) at exactly
total − buffered-tailbytes, and the reproduction below was validated by hand against pristinemaster(318d447) and v1.17.0. Following the disclosure precedent of #1483 — which appears to be a sibling of this bug (both are teardown-state guards misbehaving betweenclose()and the remote's close).Summary
Channel._write()silently discards both the chunk and its stream callback whenoutgoing.state !== 'open':If an application calls
channel.close()(orchannel.destroy(), which isend()+close()) while writes are parked waiting forCHANNEL_WINDOW_ADJUST— the normal state for any port-forwarding proxy whose source socket closes while the tunnel is still draining — then when the adjust arrives, the flush path (CHANNEL_WINDOW_ADJUSThandler →channel._write(channel._chunk, null, channel._chunkcb)) hits that guard and returns. Consequences:_writecallback is never invoked, so'finish'never fires and all further buffered writes sit forever. In our production case (HTTP withContent-Length), the consumer hung indefinitely waiting for bytes that had been dropped.The write-side park state that makes this reachable is easy to hit: the default window is 2 MiB, so any transfer larger than that with a receiver slower than the sender has parked writes for most of its lifetime.
Reproduction (self-contained, exits 1 on the bug)
Output on
master(318d447) and v1.17.0, Node 25.9.0 (also observed on 26.7.0):repro-close-drops-writes.js
Suggested fix (two tiers, both verified against the repro)
Backstop (small): invoke the callback with an error instead of silently returning, in both
Channel._writeandServerStderr._write:Verified: the Writable un-wedges and the failure becomes observable (64/64 callbacks invoked, 32 with the error). Data buffered at close time is still lost, but callers can now see it.
Complete: make
close()deferSSH_MSG_CHANNEL_CLOSEuntil the writable side has flushed (buffered data drains as window credit arrives, then EOF, then CLOSE). That preserves the data and matches what callers ofend()+close()/destroy()reasonably expect from a stream. It also composes with Fix: windowAdjust sends after CHANNEL_CLOSE #1483's concern: today, sending CLOSE while the local writable still holds data is precisely the state that later tempts post-CLOSE messages.Happy to turn either tier into a PR with the repro as a regression test if there's interest.
Context
Found while diagnosing multi-megabyte HTTP responses freezing at deterministic offsets in an SSH reverse-tunnel proxy (ssh2
Serveron one end,Client+forwardInon the other). The application-level workaround — never callclose()on a channel whose writable side may still hold data; only force-close on abnormal teardown — works, but every ssh2-based forwarding proxy is likely to have written the naturalsocket.on('close', () => channel.close())and be silently truncating large transfers under backpressure.