Skip to content

fix(tds): read past the cancelled response for the Attention acknowlement - #20

Closed
CommonwealthPasta wants to merge 169 commits into
MattJackson:mainfrom
CommonwealthPasta:main
Closed

CommonwealthPasta wants to merge 169 commits into
MattJackson:mainfrom
CommonwealthPasta:main

Conversation

@CommonwealthPasta

Copy link
Copy Markdown

Title: fix(tds): read past the cancelled response for the Attention acknowledgement

Disclosure

Full transparency, this changeset was authored by Claude Opus 5.5. I instructed it to prove the bug existed before bothering to patch it. I am a professional software developer by trade and consume this library through the TDSQL library. I would loved to have authored this fix myself, but I have too many other projects to work on. I apologize! Feel free to reject as necessary.

Summary

After sending an Attention, cancel_query read only to the end of the current TDS message. SQL Server can send the acknowledgement (a DONE token with DONE_ATTN) as a message of its own, after the cancelled response (MS-TDS 3.2.5.9, 4.19.2). cancel_query then failed and left the acknowledgement unread, so the connection desynced:

cancel_query: Err(Protocol("Never got a DONE token acknowledging the Attention signal."))
SELECT 42 -> []
SELECT 43 -> [Some(42)]
SELECT 44 -> [Some(43)]

cancel_request now drains message by message until the acknowledgement arrives, discarding the cancelled response, including any server error in it.

Related issues

None.

Type of change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that changes existing public API)
  • Documentation / tooling / CI only
  • Refactor (no functional change)

Checklist

  • This PR targets the dev branch (feature work lands on dev, is promoted to qa, then main).
  • cargo fmt --all has been run and the code is formatted.
  • cargo clippy --all-targets --all-features -- -D warnings passes with no warnings. (--all-features doesn't compile on main because the TLS backends are mutually exclusive. --all-targets --features=all is clean.)
  • cargo test passes. Integration tests against a live SQL Server (via TIBERIUS_TEST_CONNECTION_STRING / docker compose) were run if the change affects runtime behaviour.
  • Supply-chain check passes: cargo deny check is green (advisories, licenses, bans, sources).
  • CHANGELOG.md has been updated with a user-facing entry (unless this change is not user-visible).
  • Public API changes are documented (rustdoc), and new features are gated behind the appropriate Cargo feature flag where applicable.
  • Commits are signed off / attributed to me, and I agree to license my contribution under the project's terms (MIT OR Apache-2.0).

Testing

  • New tests/cancel.rs, run on tokio and smol:
    • cancel_mid_stream cancels a batch mid-SELECT, then checks that the rest of the batch never ran and that the next query reads its own result.
    • cancel_after_a_server_error checks the next query's result after cancelling a response that contains a server error.
    • Both fail without the fix (10/10 runs). The second also fails with only the Err(crate::Error::Server(_)) arm removed.
  • CI's integration matrix, run on Linux with Rust 1.99.1 and RUSTFLAGS=-Dwarnings:
    • Servers: SQL Server 2017, 2019, 2022, 2025 and azure-sql-edge, each built from docker/.
    • Feature sets: cargo test with each of --features=all, --no-default-features, and --no-default-features plus chrono, time, rustls or vendored-openssl.
    • All 30 runs pass. Without the fix, both cancel tests fail on every server.
  • Also passes on Windows with default features against SQL Server 2022.
  • cargo fmt --all --check, cargo clippy --all-targets --features=all -- -D warnings and cargo deny check advisories bans sources licenses are all clean.

Additional notes

  • A server that never acknowledges now makes cancel_query wait indefinitely. Before, it errored at the end of the first message. The server is required to acknowledge (MS-TDS 3.3.5.9).
  • The touched doc comment's Attention reference is corrected from section 2.2.1.6 (Remote Procedure Call) to 2.2.1.7. The same mistake remains in connection.rs, client.rs and header.rs, left for a separate change.

They expect AuthMethod::Integrated and AuthMethod::Windows, which exist
on Windows only with `winauth`, so the unit tests failed to compile
without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`BytesMut::extend` copied the payload into the read buffer a byte at a
time; `extend_from_slice` copies it in one go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`split_to` made a second handle to the buffer, bumping its refcount or
promoting it to shared, only to copy from it and drop it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`BytesMut::extend` copied the payload into the frame a byte at a time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a packet already in the frame buffer, such as the Attention a
dropped `cancel_query` leaves queued, the new packet's length was
written into the first packet's header instead of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`BytesMut::reserve` takes the bytes wanted beyond those already held, so
a partly received packet reserved room for nearly twice its length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each call copied the header into a new `BytesMut` and decoded it, even
while the packet was still incomplete, then decoded it again. The length
is now read in place, checked first, and the header decoded once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`read_exact` is safe here: `Connection::poll_read` spans packet
boundaries itself. Buffers still grow in 64 KiB steps, so an untrusted
length cannot force one huge allocation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its own byte-wise `read_bytes` gives way to the PLP one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the code page leaves the bytes as they are (ASCII, or a UTF-8
collation), they become the `String` without a copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The UTF-8 length is counted first, and ASCII-only values are copied a
byte per code unit without decoding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
They were read a byte (ntext: a code unit) at a time; they are now read
whole and decoded with the varchar and nvarchar decoders.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`as_chunks::<2>` yields `[u8; 2]` arrays, so no slice indexing is left
to bounds-check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A token's bytes stay buffered until it ends, up to 1 MiB, so a cancel
after a stream or `cancel_query` dropped mid-token no longer parses from
its middle. Past 1 MiB the cancel fails and poisons the connection.

Audit: P2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Skipping it lost its ENVCHANGEs, so after a `BEGIN TRAN` or `COMMIT`
in it the server rejected the next request (3989) or every later one
(3971). A response out of step with its tokens is still skipped.

Audit: P2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
After an encode error, such as a TVP value too long for its column, the
next request waited for a response to the request never sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An empty result set after the first vanished and shifted later ones
down an index. Every COLMETADATA now opens a result set.

Audit: P4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only a DONE with DONE_COUNT and a non-SELECT CurCmd is counted, as in
TdsParser.TryProcessDone; SELECTs and variable assignments were too.

Audit: P5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2.2.6.8 is the SSPI message, and the packet type is 14 (0x0E), not
0x14. The compatibility table also lists `save`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It sent `IsolationLevel::Unspecified`, which keeps the session's level
(MS-TDS 2.2.6.9), so a transaction after a SERIALIZABLE one was
SERIALIZABLE too. SqlClient's BeginTransaction asks for READ COMMITTED.

Audit: P6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MS-TDS 2.2.7.18 makes it a LONG; `RETURN -1` came back as 4294967295.
`CommandItem::ReturnStatus`, `as_return_status` and `return_code` are
now `i32`.

Audit: P7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Only its first ERROR survived. `Error::Server` still carries it; the
new `TokenError::other_errors` and `TokenError::info_messages` return
the response's other ERROR and INFO tokens, up to 1000 of each.

Audit: P8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The context kept its default, 7.4, so DONE, ERROR and INFO tokens were
never read in their 7.1 layouts. The docs no longer claim 7.1 support:
every request carries ALL_HEADERS, which 7.2 introduced.

Audit: P9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Ignoring them would change what the connection does: for example
MultipleActiveResultSets=true, Failover Partner or an Entra ID
Authentication; from_ado_string's docs list them all.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SPN is derived from the server name and port, so a given one was
ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It replaces the connection-string crate: a bare \ and non-ASCII values
parse and }} in braces is one }. Values are built in zeroizing buffers
sized up front, so no freed block holds part of a password.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Properties are stored by mssql-jdbc's name, as mssql-jdbc stores them:
serverName=a;server=b connects to b, and server= overrides the URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
serverName= connected to host "" and serverName=h\ asked the SQL
Browser for instance "".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The connection GUID, the activity GUID and its sequence, not 20 bytes;
a TRACEID of another length is skipped, as SqlClient skips it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 0x80 bit failed the prelogin as an invalid ENCRYPTION value; with
it, no encryption ends the connection, since the certificate needs TLS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MSSQLSvc/<host>:<instance>, as SqlClient uses when it has no port; the
SPN carried the SQL Browser's own port 1434.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Strict runs TLS straight over TCP, so the TLS 1.2 cap, there for TDS
7.x's PRELOGIN-framed handshake, no longer applies to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rows of up to 256 columns no longer allocate a Vec for the bitmap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
put_b_varchar and put_us_varchar check the UTF-16 length against the
u8 or u16 prefix; RPC names and TVP type names use them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 overflowed the u8 length: a debug-build panic, 0 in release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The database, schema and type names (u8) and the assembly-qualified
name (u16) in bulk and TVP metadata wrapped instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The database name and owner (u8) and collection name (u16) in TVP
metadata wrapped instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
256 wrapped the B_VARCHAR length to 0 ahead of all 256 units.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
65,534 bytes overflowed the option offsets (a debug-build panic),
70,000 wrapped INSTOPT's length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An AAD token of 2^31 UTF-16 units wrapped Length, FeatureDataLen and
the token's own length.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
XmlData and nvarchar(max) now share put_plp_utf16, which checks the
length before writing; XmlData's u32 byte count wrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its length was cast with `as u32`, which wrapped from 4 GiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its length was cast with `as u32`, which wrapped from 4 GiB; the check
now comes before the text pointer is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its u32 byte count overflowed from 2^31 UTF-16 units; the length is
now checked before anything is written.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its length was cast with `as u32`, which wrapped from 4 GiB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
As SqlBulkCopy does; it was an error. The value goes in one PLP chunk,
as the column is declared nvarchar(max).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
datetimeoffset stores whole minutes, and +01:00:30 was cut to +01:00,
its local time 30 s off; a chrono offset with seconds is already an
error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Encoding one as a parameter now fails with a Conversion error instead
of sending a value the server rejects (8023).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rounding carried it into the next day: 24:00:00.5 was stored as
00:00:00.5 the day after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A chrono leap second counted on into the next second (12:34:60.5 was
sent as 12:35:00.5) or past midnight; it now converts to a marker the
encoder rejects, as ToSql cannot fail.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Integrated authentication uses a non-blank Server SPN / ServerSPN
(ADO.NET) or serverSpn (JDBC) as given, as SqlClient does; with
integrated security it was an error.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Fixes clippy::useless_vec.

Co-Authored-By: Claude Opus 5.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.

1 participant