refactor: add MessageIO.writeMessage/readMessage and use them for the PRELOGIN exchange#1756
refactor: add MessageIO.writeMessage/readMessage and use them for the PRELOGIN exchange#1756arthurschreiber wants to merge 4 commits into
MessageIO.writeMessage/readMessage and use them for the PRELOGIN exchange#1756Conversation
… the `PRELOGIN` exchange This adds two static methods to `MessageIO` that read and write TDS messages directly on a stream, without going through the `Message`, `IncomingMessageStream` and `OutgoingMessageStream` machinery: * `MessageIO.writeMessage` writes a payload (a sync or async iterable of `Buffer`s) to a stream, wrapped in TDS packets, respecting backpressure. If iterating the payload throws, the message is terminated with the `IGNORE` flag set and the error is re-thrown. * `MessageIO.readMessage` is an async generator that yields the packet contents of the next message on the stream. Bytes that arrive after the message's last packet are pushed back onto the stream for the next read. The `PRELOGIN` exchange during connection establishment is the first consumer: it now runs directly on the raw socket, and the `MessageIO` instance is only constructed once the prelogin response was fully read. Setting up per-message stream objects is a significant part of the message handling cost, especially before v8 optimizations kick in. Comparing the new functions against the existing stream classes (messages/second, Node.js v24.18.0, median of 3 runs): | n | IncomingMessageStream | readMessage | | |---|---|---|---| | 100 | 9,889 | 22,441 | +127% | | 1,000 | 26,244 | 58,069 | +121% | | 10,000 | 76,942 | 150,728 | +96% | | 100,000 | 173,808 | 293,468 | +69% | | n | OutgoingMessageStream | writeMessage | | |---|---|---|---| | 100 | 9,353 | 16,518 | +77% | | 1,000 | 29,759 | 48,655 | +64% | | 10,000 | 91,387 | 127,079 | +39% | | 100,000 | 156,812 | 191,138 | +22% | Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewNice piece of work — the two new primitives are well-documented, the write/read framing logic is careful about partial packets and backpressure, and the benchmark numbers make a strong case for the direction. A few things worth a look before this pattern is rolled out to the rest of the outgoing/incoming paths. Correctness
Test coverage
Minor / style
Performance & security
Overall this is a solid, well-tested foundation for the migration. The two correctness notes above are edge cases rather than blockers for this PR's actual scope (the low-traffic PRELOGIN exchange), but probably worth resolving — or at least deciding on purpose — before |
…age` Both methods now accept an optional abort signal via an options object. For `readMessage` this is a correctness requirement, not a convenience: calling `.return()` on the generator while it is suspended waiting for stream data is queued behind the pending `.next()` call, so a caller racing reads against an abort promise and cleaning up via `.return()` deadlocks when the abort fires while the stream is open but quiet - exactly the connect-timeout scenario. Aborting the signal settles the pending read from the inside instead. For `writeMessage`, an abort interrupts waiting for the stream to drain or for more payload data. This can leave a partially written message on the stream, so it is only meant for teardown paths; cooperative cancellation that keeps the TDS stream aligned still goes through the payload-error path that terminates the message with the `IGNORE` flag. The `PRELOGIN` exchange now passes the connect signal down, and `readPreloginResponse` collapses to a plain `for await` loop. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewNice piece of work — the Correctness
Style / consistency
Test coverage
Performance
Security
Overall: solid, well-tested infrastructure work with clear docs and a sound migration story. The two nits above (the |
Instead of static methods on `MessageIO`, `writeMessage` and
`readMessage` are now plain module-level functions, matching the
precedent set by `connector.ts`. The `debug` parameter moves into the
options object and becomes optional, shrinking the positional parameter
lists:
writeMessage(stream, packetSize, type, payload, { debug, resetConnection, signal })
readMessage(stream, { debug, signal })
Since `module.exports = MessageIO` replaces the named exports on the
CommonJS side, they are explicitly re-attached for `require` users.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review:
|
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #1756 +/- ##
==========================================
+ Coverage 80.84% 81.38% +0.53%
==========================================
Files 90 90
Lines 4887 5065 +178
Branches 924 958 +34
==========================================
+ Hits 3951 4122 +171
- Misses 640 644 +4
- Partials 296 299 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
While the `signal` option is for connection teardown (stop writing, throw, stream may be left misaligned), the new `cancelSignal` option expresses graceful request cancellation as defined by the protocol: stop consuming the payload, discard buffered data, and terminate the message with the `IGNORE` flag set. This is a normal protocol outcome, so the returned promise resolves normally, the TDS stream stays aligned, and the server sends a short response to the ignored message. Cancellation also skips any pending drain waits: nothing further will be written for this message, so there is no reason to keep the caller waiting on a stalled peer. This replaces the need for callers to express cancellation by throwing a sentinel error from the payload iterable and swallowing it again. When both signals are aborted, teardown wins. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewI read through OverviewThis is a well-scoped, low-risk first step: it introduces Potential bug:
|
This is the first step of the plan from #1656: replacing the
Message/IncomingMessageStream/OutgoingMessageStreammachinery with two straightforward functions. It adds the new primitives plus a first low-risk consumer, without touching the existing stream classes yet:writeMessage(stream, packetSize, type, payload, { debug, resetConnection, cancelSignal, signal })writes a payload (a sync or async iterable ofBuffers) to a stream, wrapped in TDS packets, respecting backpressure viadrain. If iterating the payload throws, the message is terminated with a final packet that has theIGNOREflag set (so the server disregards the partial message) and the error is re-thrown.readMessage(stream, { debug, signal })is an async generator that yields the packet contents of the next message on the stream. Bytes arriving after the message's last packet (e.g. the start of the next message) are pushed back onto the stream withunshift, to be consumed by the next read — this is what will later keep the message stream aligned aroundATTENTIONacknowledgements.Compared to the draft in #1656, two bugs are fixed: the packet-framing loop now correctly waits for more data when only a partial packet is buffered (the draft could spin forever), and stream errors/closes that occur while the consumer is processing a yielded chunk are re-checked on re-entry instead of being lost (the draft could hang).
The first consumer is the
PRELOGINexchange during connection establishment:sendPreLoginandreadPreloginResponsenow run directly on the raw socket, and theMessageIOinstance is only constructed once the prelogin response has been fully read. This works unchanged forencrypt: 'strict', where the socket is already the TLS cleartext stream at this point.Both functions come with a unit-test suite covering backpressure, payload errors while draining, stream errors, premature close, trailing-byte unshift, and listener cleanup.
Benchmarks
Setting up per-message stream objects is a significant part of the message handling cost, especially before v8 optimizations kick in. Comparing the new functions against the existing stream classes (messages/second, Node.js v24.18.0, median of 3 runs; benchmarks included in this PR):
IncomingMessageStreamreadMessageOutgoingMessageStreamwriteMessage(The connection-establishment change itself is once-per-connection and not performance-relevant; the wins materialize once request/response traffic moves over in follow-up PRs.)
Follow-ups will move the remaining outgoing writes (and delete
OutgoingMessageStream), then the incoming reads (and deleteIncomingMessageStream/Message).🤖 Generated with Claude Code
API shape
Both are plain module-level functions (matching the precedent set by
connector.ts) rather than statics onMessageIO, and everything beyond the essential inputs —debug(now optional),resetConnection,signal— lives in a trailing options object. They are also re-attached to the CommonJS export, sincemodule.exports = MessageIOwould otherwise drop them forrequireusers.AbortSignalsupportFor
readMessagethis is a correctness requirement rather than a convenience: calling.return()on the generator while it is suspended waiting for stream data gets queued behind the pending.next()call, so racing reads against an abort promise and cleaning up via.return()deadlocks whenever the abort fires while the stream is open but quiet — exactly the connect-timeout scenario (server accepts TCP, never responds). Aborting the signal settles the pending read from the inside, letting the generator unwind and clean up its listeners.For
writeMessage, the two ways a message can be interrupted are expressed as two separate signals:cancelSignal— graceful cancellation, connection stays usable. Stops consuming the payload, discards buffered data, and terminates the message with theIGNOREflag set. This is a normal protocol outcome: the promise resolves, the TDS stream stays aligned, and the server sends a short response to the ignored message (callers distinguish viacancelSignal.aborted). Pending drain waits are skipped, since nothing further will be written for this message. This replaces the earlier plan of expressing cancellation by throwing a sentinel error from the payload iterable and swallowing it at the call site; in the follow-up asyncmakeRequest,Request.cancel()and the request timeout feed this signal.signal— teardown. An abort interrupts drain waits and payload waits and throws the abort reason. This can leave a partially written message on the stream, so it is documented as teardown-only. When both signals are aborted, teardown wins.readMessagedeliberately gets nocancelSignal: on the read side, cancellation requires continuing to read (drain the current response, then the attention acknowledgement) or the stream misaligns — cancel is a consumer-level behavior (discard tokens), not a framing-level one. Only teardown interrupts a read.The
PRELOGINexchange passes the connect signal down, which collapsesreadPreloginResponseinto a plainfor awaitloop. Test coverage includes: already-aborted signals, abort during a drain wait, abort during a payload wait (no spuriousIGNOREterminator), abort on a quiet stream, abort after a partial message, a regression test for the.return()deadlock, and abort-listener cleanup viagetEventListeners.