Skip to content

Fix/446 diagnostic text utf8 - #447

Merged
TimelordUK merged 3 commits into
masterfrom
fix/446-diagnostic-text-utf8
Sep 9, 2026
Merged

Fix/446 diagnostic text utf8#447
TimelordUK merged 3 commits into
masterfrom
fix/446-diagnostic-text-utf8

Conversation

@TimelordUK

Copy link
Copy Markdown
Owner

The three claims — all confirmed

  1. Narrowing. cpp/include/common/odbc_common.h:150 did exactly what he says. Live output before the fix:
    --lista producent<U+FFFD>w u|ytkownika, wydajno[ciowych, podwBadnych
    ł→B, ż→|, ś→[, and ó's lone 0xF3 → U+FFFD. ąćę came through as \x05 \x07 \x19 — invisible, as he warned. His premise that UTF-8 is the right target checks out: js_object_mapper.cpp:387 hands the message to Napi::String::New, i.e. napi_create_string_utf8.

  2. NUL truncation. print N'PRZED' + nchar(0x0100) + N'PO' arrived as PRZED. Worth noting the mechanism in his own repro isn't an embedded NUL — it's U+0100's low byte being 0x00. Both truncate, and the fix handles both.

  3. The 10239 ceiling. Exact. My direct ODBC probe, on the real driver:
    replicate=16000 (PRINTed 16005 chars)
    buffer 10240 (old) rc=1(WITH_INFO) msg_len=16056 written=10239
    buffer 32767 (API max) rc=0 msg_len=16056 written=16056
    The driver reports 16056 available and writes 10239; the old code ignored the return.

One correction to the report

His expected 16005 isn't attainable. Driver 17/18 caps a single diagnostic record at 16056 characters — the 54-char [Microsoft][ODBC Driver 17...] prefix plus 16002. msg_len is 16056 whether the server PRINTs 16005, 20005 or 40005 characters, at any buffer size. So after his fix you get 16056, i.e. everything available; the residual 3 characters are the driver's, not recoverable.

His 32767 clamp also turns out to be necessary, not just tidy: BufferLength=40000 overflows the SQLSMALLINT and the driver returns SQL_ERROR with nothing written.

Fix review

Applies cleanly to master with git apply, compiles, clang-format clean, 733 passing / 0 failing on the full JS suite. It also closes a latent overrun — msg.capacity() was being passed as BufferLength. Three minor notes:

  • The new doc comment says trim derives its length from capacity(), but the same patch changes trim to size() — stale on arrival.
  • The embedded-NUL preservation can't reach JS: read_errors builds OdbcError from c_msg.c_str() (odbc_handles.h:150), so a genuine U+0000 is still cut there. Doesn't affect the reported case.
  • At msg_len == 32767 exactly you still lose one character to the terminator. Unavoidable through this API.

Regression tests added

  • test/cpp/unit/diagnostic_text_test.cpp — 13 gtest cases, no server or driver needed. 7 fail before the patch, 13 pass after.
  • test/diagnostic-text.test.js — 5 mocha cases end-to-end. I reverted the patch, rebuilt, and confirmed all 5 fail (10185 = 10239 minus the stripped prefix), then restored and confirmed all 5 pass. Pinned at 12000 characters to stay clear of the driver ceiling.

TimelordUK and others added 3 commits September 9, 2026 20:10
PRINT and RAISERROR text reached JavaScript through odbcstr::swcvec2str, which
reinterpreted the SQLWCHAR buffer as bytes and kept the low byte of each UTF-16
code unit. Every character above U+00FF was therefore damaged: U+017C became
'|', U+0142 became 'B', and where the low byte was not printable ASCII the
character was dropped or, once napi_create_string_utf8 decoded the bytes,
replaced with U+FFFD. A code unit whose low byte was 0x00 - U+0100, or a genuine
U+0000 - terminated the message, because the string was built from a
NUL-terminated buffer.

swcvec2str now converts UTF-16 to UTF-8 properly, recombining surrogate pairs
where SQLWCHAR is two bytes and leaving code points alone where it is four, so
unixODBC and iODBC are both handled. A lone surrogate becomes U+FFFD rather than
corrupting the rest of the line. Trailing NULs are trimmed, which keeps the three
callers that pass a buffer size rather than a string length correct, while an
embedded NUL no longer takes the tail of a message with it. The length is clamped
to size(), and trim() derives it from size() rather than capacity(), so neither
can read past the elements that exist.

read_errors sized its buffer at 10 * 1024 and ignored msg_len, which reports the
length available rather than the length written, so any longer record arrived cut
to 10239 characters with no error at all. It now re-fetches a record that did not
fit. `print cast(substring(@SQL, 1, 16000) as ntext)` is the documented way
around PRINT's own 4000/8000 limit, so records past 10240 are ordinary, and a
generated statement 5761 characters short still looks like a statement. The
regrown buffer is capped at 32767 because BufferLength is a SQLSMALLINT: 40000
overflows it and the driver answers SQL_ERROR having written nothing.

msg.capacity() was also being passed as BufferLength, which promised the driver
more room than the vector was guaranteed to have; it now passes msg.size().

Reported and diagnosed by @FilipB97, whose patch this is.

Fixes #446

Co-authored-by: FilipB97 <FilipB97@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS9t73svfSHXWoBUehTKA2
Two layers, because the defect had both a pure-conversion half and a
buffer-management half.

test/diagnostic-text.test.js drives a real server: non-ASCII text in a PRINT,
characters whose low byte is a control code, U+0100 not ending the message, a
PRINT longer than the old 10240-unit buffer, and non-ASCII in a RAISERROR. All
five fail on the previous commit's parent and pass on it.

test/cpp/unit/diagnostic_text_test.cpp needs neither a server nor a driver: the
conversion is pure, and the truncation case drives read_errors through a mocked
SQLGetDiagRecW that reports the length available and writes only what fits, the
way the real driver does. Seven of its thirteen cases fail before the fix.

The long-message test is pinned at 12000 characters rather than the 16000 from
the report. Measured against ODBC Driver 17 and 18, SQLGetDiagRecW will not
return more than 16056 characters for one record - the 54-character
"[Microsoft][ODBC Driver 17 for SQL Server][SQL Server]" prefix plus 16002 - and
reports that same 16056 whether the server PRINTed 16005, 20005 or 40005
characters, at any buffer size. That ceiling is the driver's, so the test stays
below it and asserts an exact round trip instead.

Both source files are free of non-ASCII characters, spelling the text under test
with \u escapes and the expectations with the UTF-8 bytes they must produce, so
no assertion depends on which charset a compiler or editor guesses for the file.

The C++ test is its own CMake target over Logger.cpp and mock_odbc_api.h rather
than a case inside cpp_tests, because cpp_tests cannot currently build: its
Release config combines /GL with WINDOWS_EXPORT_ALL_SYMBOLS, which leaves
cmake -E __create_def unable to read the LTCG objects, and
mocks/include/mock_odbc_statement.h is out of date against the present
IOdbcStatement. Neither is touched here. mock_odbc_api.h had drifted too, and
gains the four IOdbcApi methods it was missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS9t73svfSHXWoBUehTKA2
The test workflow has never compiled the C++ it is meant to be testing. `npm
install` runs this package's own install script, `prebuild-install || node-gyp
rebuild`, and prebuild-install succeeds on every platform in the matrix: the
release assets are napi-v8 builds, which match any Node version, so all eight jobs
quietly downloaded the last *published* binary and ran the suite against that.

The JavaScript came from the branch and the addon did not. A C++ fix therefore
looks broken in CI and a C++ regression looks fine - the first of those is how
this surfaced, with the issue 446 tests failing on exactly the symptoms the commit
before this one removes, because the binary under test predated it.

`--build-from-source` makes prebuild-install decline with exit 1 so the `||`
fallback compiles this checkout. It is scoped to this package by name, since the
bare flag is read by every dependency that uses prebuild-install.

Verified by running the workflow's own command locally: prebuild-install declines,
node-gyp compiles, and the five issue 446 tests that fail against the published
binary pass against the compiled one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FS9t73svfSHXWoBUehTKA2
@TimelordUK
TimelordUK merged commit fc08ff4 into master Sep 9, 2026
10 checks passed
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