fix(ext/node): keep writev buffer boundaries in TLS record framing - #36719
Open
tomas-zijdemans wants to merge 4 commits into
Open
fix(ext/node): keep writev buffer boundaries in TLS record framing#36719tomas-zijdemans wants to merge 4 commits into
tomas-zijdemans wants to merge 4 commits into
Conversation
TLSWrap.writev flattened all buffers into one slice before feeding rustls, which fragments records per write() call. Node encrypts each writev buffer with its own SSL_write, so a record never spans two buffers. Protocols that tunnel framed data over TLS depend on that: SQL Server (TDS) closes the connection when a TLS record crosses a TDS packet boundary, surfacing in tedious/mssql/@prisma/adapter-mssql as "socket hang up" for any query spanning 3+ TDS packets. Track chunk end offsets in pending_cleartext_boundaries and never pass bytes from two chunks to a single rustls write() call.
Pass the writev buffers as one buffer plus chunk end offsets instead of a Vec<Vec<u8>>, so each buffer is copied once as before rather than twice. Walk the boundary list with a cursor (chunk_end) instead of rescanning it per chunk. Cover TLS 1.3 in the regression test as well: the framing is version independent, and under 1.3 the client's Finished is also an outer application_data record, so record capture now starts after a one-byte round trip instead of at secureConnect. Add a Rust unit test for the boundary walk, including resuming mid-chunk after a MAX_CLEAR_IN cut.
bartlomieju
reviewed
Aug 31, 2026
| op_state: &mut OpState, | ||
| ) -> i32 { | ||
| let mut data = Vec::new(); | ||
| let mut boundaries = Vec::new(); |
Member
There was a problem hiding this comment.
Could you preallocate this vec?
Contributor
Author
There was a problem hiding this comment.
Good catch. Fixed now
Matches the dominant convention in the tree (`as usize` over `as _`).
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.
Problem
npm:tedious(and thereforenpm:mssqland@prisma/adapter-mssql) connections to SQL Server / Azure SQL fail withsocket hang up/Connection lost - socket hang upwhenever a query's TDS message spans 3 or more TDS packets (roughly > 8 KB with the default 4096-byte packet size). Small queries work; the same code works on Node. This is distinct from the implicit session-resumption issue fixed by #36592 and from the JSStreamSocket deadlock fixed for #33907 — with both of those fixes in place (Deno 2.9.6), large writes still kill the connection.Mechanism, verified against a real SQL Server 2022 with a record-level wire capture:
Writableand are submitted together through_writev.TLSWrap.writevflattened all chunks into one slice before feeding rustls, and rustls fragments records perwriter().write()call — so the resulting application-data record spanned two TDS packets. For a 3-packet message the capture shows Deno sending records of 4096 + 4150 plaintext bytes where Node sends 4096 + 4096 + 54.SSL_write(TLSWrap::DoWriteloops over the buffers), so records always align with TDS packet boundaries. SQL Server appears to require this alignment: in our captures it closes the connection (TCP FIN, no TLS alert) as soon as a record crosses a packet boundary. Capping the record size alone (rustlsmax_fragment_size = 4096) did not help — alignment is what matters, not size. tedious'ssetMaxSendFragment(packetSize)call points at the same server behavior.The total plaintext Deno sent was byte-identical to Node's — only the record framing differed.
Fix
Track the end offset of each write chunk in
pending_cleartext_boundaries, havewritevrecord those offsets alongside the concatenated buffer instead of discarding them, and makeclear_innever pass bytes from two chunks to a single rustlswrite()call. Since rustls fragments perwrite()call, each chunk now gets its own TLS record(s), matching Node's per-bufferSSL_write. Single-buffer writes and theMAX_CLEAR_INrate-limiting behave exactly as before; a chunk that straddles aMAX_CLEAR_INcut is still only split within itself, never merged with the next one.Tradeoff: a
writevbatch of many small chunks now produces a record per chunk (~22 bytes overhead each) rather than one coalesced record. That is Node's behavior, and it is the property protocols like TDS depend on.Testing
tests/unit_node/tls_test.ts, run for both TLS 1.2 and 1.3, builds the same topology tedious uses (TLS client over a back-to-back Duplex pair), corks three writes (4096/4096/54) so they flow through one_writevbatch, and asserts one application-data record per chunk by parsing the record headers off the encrypted side. Both versions fail without the fix (the three chunks arrive as one merged record) and pass with it.MAX_CLEAR_INcut.encrypt: true): before the change every INSERT whose TDS message needs > 2 packets fails withsocket hang up(threshold bisected exactly to the 2-to-3 packet boundary); with the change, inserts up to 1 MB pass and the client's TLS record sizes match Node's byte-for-byte.cargo test unit_node::tls_testand the existingtls_wrapRust unit tests pass.Likely also relevant to older reports of
mssql/Prisma failures that persisted after #33914, e.g. the "more than N rows" symptom in #32271.I used Claude Code to help investigate and write this change.