Skip to content

zlib: don't throw when a write is flushed after cleanup - #1506

Open
ArockiaRajamanickam wants to merge 1 commit into
mscdex:masterfrom
ArockiaRajamanickam:fix-1178-zlib-after-cleanup
Open

zlib: don't throw when a write is flushed after cleanup#1506
ArockiaRajamanickam wants to merge 1 commit into
mscdex:masterfrom
ArockiaRajamanickam:fix-1178-zlib-after-cleanup

Conversation

@ArockiaRajamanickam

Copy link
Copy Markdown

Fixes #1178.

ZlibPacketWriter#cleanup() closed the zlib handle but left this._zlib pointing at it. Any outbound packet finalized after that reached this._zlib.writeSync(...) on a closed handle, and zlib throws Error: Invalid Zlib instance from there. It escapes through the socket 'close' handler, so there is nothing in userland that can catch it and the process dies.

The fix clears the reference in cleanup() and returns early from finalize() when it is gone.

Why it returns quietly rather than emitting an error

This was the part worth thinking about, and I went the opposite way from what the issue thread suggested.

Emitting an error here would fire on ordinary, successful teardown. I instrumented finalize() and saw between 1 and 34 late writes per disconnect, even on healthy connections with compression off. Turning every clean disconnect into an 'error' event would be a worse regression than the crash for well-behaved users, and it breaks common.js's own setup() helper, which treats an unexpected error event as a failure.

The uncompressed path already handles this exact situation the same way: crypto.js's cipher classes all return quietly when called after free(), and PacketWriter#finalize() has nothing to throw. So returning quietly matches the convention already in the codebase rather than inventing a new one for the compressed path.

Nothing that could have reached the wire is discarded. By the time this triggers the socket is gone, the cipher is freed, and Protocol#_onWrite has been replaced with a thrower — the packet was unsendable either way. The only question was whether it also killed the process.

If you would rather it surfaced as an error, I think that belongs in Protocol#_destruct() for every packet writer, not in zlib.js alone. Happy to do it that way instead.

Test

Added to test/test-misc-client-server.js: a client and server that negotiate zlib, then call client._protocol.channelData(...) from the client's 'close' handler, which is the moment after cleanup() when a queued write can still be flushed.

Reverting only lib/protocol/zlib.js and keeping the test crashes with Error: Invalid Zlib instance at the same three frames as the issue's stack trace; with the fix it passes. It is deterministic — the real-world SFTP path only crashed about half the time, and you asked for a test as minimal as possible, so I drove the same code path directly rather than racing it.

Two things to push back on if you disagree:

  • The test touches an internal. test-misc-client-server.js already does this in several places (conn._protocol.channelSuccess, client._protocol._offer, client._chanMgr._channels) and test-sftp.js calls sftp._protocol.exitStatus(...), so it is consistent with the suite, but it is still white-box. It asserts info.cs.compress === 'zlib' on handshake so it cannot silently stop exercising the compressed path.
  • It depends on cleanup() running before 'close' is emitted (client.js:812-819). True today and what makes it deterministic, but if that order ever changed the test would quietly stop covering the bug.

ZlibPacketReader#read() has the same shape of hazard. I could not construct a path that reaches it, so I left it alone rather than change code I could not exercise — flagging it rather than quietly fixing it.

Verification notes

Tested on Node v25.9.0, macOS only; I did not run the full CI matrix. test-integration-openssh.js already fails on a clean checkout in my environment because the host OpenSSH rejects an old RSA key fixture — that is unrelated and unchanged by this branch, so please don't read it as a regression.

Also worth knowing: if #1491 (SFTP cleanupRequests looping) lands first, it would make this crash more frequent, since it increases the number of late writes during teardown.

I used an AI assistant while working on this. The instrumentation counts, the negative control and the decision not to emit an error are mine.

Protocol#_destruct() closes the zlib instances used by ZlibPacketWriter,
but outbound data can still be flushed afterwards -- for example when
SFTP's cleanupRequests() runs during connection teardown and a request
callback synchronously issues another write. In that case
ZlibPacketWriter#finalize() called into a closed Zlib instance, which
threw 'Invalid Zlib instance' from an asynchronous callback where
nothing could catch it, taking down the process.

Ciphers already tolerate this: free() marks them dead and encrypt()
returns early instead of throwing. Do the same for the compressing
packet writer, so a late write is dropped rather than thrown. This
matches the behaviour already seen with compression disabled, where the
same late writes are silently discarded by the freed cipher.

Fixes: mscdex#1178

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.

Invalid Zlib Instance uncatchable error

1 participant