Choose ENV runtime teardown by process state: join while alive, leak while exiting - #486
Choose ENV runtime teardown by process state: join while alive, leak while exiting#486David Engel (David-Engel) wants to merge 20 commits into
Conversation
SQLFreeHandle released the last Arc<Runtime>, and Runtime's teardown waits on its worker threads. mssql-python frees its pooled connection handles from its extension module's CRT onexit table, which the loader runs inside LdrShutdownProcess - after Windows has already terminated every other thread. Work done on the main thread produced the 'threads should not terminate unexpectedly' panic and exited; work done on any other thread hung forever after the workload had already succeeded. SharedRuntime leaks the runtime when RtlDllShutdownInProgress reports the loader is shutting the process down, and drops it normally otherwise. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The Windows shutdown branch that fixes the regression lacks automated coverage.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
Prevents Windows host shutdown hangs by conditionally leaking the per-environment Tokio runtime during loader teardown.
Changes:
- Adds
SharedRuntimewith shutdown-aware disposal. - Migrates ENV/DBC and fetch paths to the wrapper.
- Documents the failure mechanism and validation.
File summaries
| File | Description |
|---|---|
mssql-odbc/src/handles/runtime.rs |
Implements shutdown-aware runtime ownership. |
mssql-odbc/src/handles/mod.rs |
Exports the runtime wrapper internally. |
mssql-odbc/src/handles/env.rs |
Stores SharedRuntime on ENV handles. |
mssql-odbc/src/handles/dbc.rs |
Shares the wrapper with DBC handles. |
mssql-odbc/src/api/fetch_scroll.rs |
Updates runtime parameter types. |
mssql-odbc/tokio.md |
Documents runtime teardown behavior. |
mssql-odbc/docs/typed-columnar-fetch-plan.md |
Records the resolved Windows hang. |
Review details
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
main landed #459 (AB#47509) for the same teardown, using Runtime::shutdown_background. Measured against that build the hang is still present in both the worker-thread and main-thread cases, so it converted the visible panic into a silent hang on the one path that used to exit. Keep #459's SharedRuntime and layer the actual fix on it: skip signalling entirely once RtlDllShutdownInProgress reports the loader is tearing the process down, since shutdown_background still takes scheduler locks that a terminated worker may have died holding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Review noted the shutdown branch had no automated coverage: the only new test asserted the loader flag reads false, so reverting to shutdown_background or dropping the condition would both have left the suite green. Split the decision (release_policy) from the action (release) and pass the loader flag in, so both arms are reachable in-process. A leaked runtime is still polling and a detached one is not, which is the observable that tells them apart. Verified by mutation: reverting Leak to shutdown_background fails the_leak_policy_signals_nothing_and_leaves_the_runtime_running, and dropping the condition fails only_a_shutting_down_process_selects_the_leak_policy. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
📊 Code Coverage Report
Diff CoverageDiff: main...HEAD, staged and unstaged changes
Summary
mssql-odbc/src/api/close_cursor.rsmssql-odbc/src/handles/env.rs🔗 Quick Links |
ttk (Theekshna)
left a comment
There was a problem hiding this comment.
This PR updates mssql-odbc's shared Tokio runtime teardown so ENV shutdown leaks the runtime during Windows process shutdown instead of signalling a dead scheduler, and it adds the corresponding regression tests and docs. I checked the merged diff, the prior Copilot thread and author follow-up, the coverage report, and the green CI; the code change itself looks sound, but one documentation update still presents the old and new Windows-validation states at the same time.
| Severity | Count |
|---|---|
| Blocking | 0 |
| Suggestion | 1 |
| Nit | 0 |
Blocking: none.
Suggestion:
mssql-odbc/docs/typed-columnar-fetch-plan.md:289— the new note says the fix now lets the 42-file Windows run complete with zero timeouts, but the paragraph immediately above still says there is no Windows-equivalent 42-file result yet and describes the old hang as current. Please rewrite or relocate that earlier paragraph so the plan presents one current state instead of both the pre-fix and post-fix story at once.
Nit: none.
The pre-fix paragraph still asserted, as present tense, that no Windows 42-file result exists and that the run hangs in test_003_connection, with a note directly below it saying the opposite. Fold both into a single paragraph that leads with the current result and keeps the old reading as history. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ttk (Theekshna)
left a comment
There was a problem hiding this comment.
Automated unattended run — comment-only, not checked by a human before posting.
Summary
Re-review triggered solely by head move (my prior review at 8ea9b11c flagged a doc contradiction, findings=1). At the current head (c7564c86) that finding is fixed: the typed-columnar-fetch-plan.md section now presents one current validation state (the post-fix 42-file pass) and moves the pre-fix hang into explicit history, exactly as requested.
Re-reviewed the mechanism fresh rather than trusting that alone:
- FFI correctness:
RtlDllShutdownInProgressis an undocumented but well-knownntdllexport (BOOLEAN RtlDllShutdownInProgress(VOID), no params) — the declared signature (unsafe extern "system" fn() -> u8, no args) matches, and theSAFETYcomment is accurate. CI's Windows and Windows ARM build legs confirm it links without WDK dependencies. - Mutation verification claim is real, not asserted: reverted
Leak→shutdown_background()locally and confirmedthe_leak_policy_signals_nothing_and_leaves_the_runtime_runningfails; revertedrelease_policyto always returnDetachand confirmedonly_a_shutting_down_process_selects_the_leak_policyfails. Both match the PR description. - Existing regression tests still guard the non-shutdown path:
dropping_env_handle_does_not_wait_for_blocking_workand the DBC-outlives-ENV variant still exerciseDetachend-to-end viaEnvHandle/DBC drop, unaffected by the refactor.
| Check | Result |
|---|---|
| msodbcsql parity | N/A — msodbcsql has no async runtime/thread-pool analog to this teardown hazard. The closest related code (sqlncli's own DllMain, DLL_PROCESS_DETACH branch checking lpvReserved) solves a different problem: detecting whether its own DLL is unloading vs. the process exiting. This PR's hazard is a caller (mssql-python) freeing an ODBC handle from another module's onexit table during LdrShutdownProcess, where no lpvReserved-equivalent is available — RtlDllShutdownInProgress is the only applicable primitive, so this isn't a comparable/divergent code path. |
| Test sufficiency | The one genuinely untested branch (process_is_shutting_down() == true reached through the real Drop impl) is called out honestly in the PR description as unreachable from a Rust unit test, and is covered instead by the cross-repo mssql-python swap job (test_025_logging_concurrency_deadlock.py). No hidden gap. |
| Divergence documented | N/A per above — not a parity divergence. |
| PR description currency | Matches the diff; checklist claims match gh pr checks (all green); linked work items (AB#47509, AB#47510) present. |
| AI slop | None found — the added doc comments explain why (loader hazard, why the flag is declared by hand instead of via the windows crate, why new_runtime was extracted) rather than restating code. |
| Severity | Count |
|---|---|
| Blocking | 0 |
| Suggestion | 0 |
| Nit | 0 |
No new findings.
Shiwani Gupta (shiwanigupta0809)
left a comment
There was a problem hiding this comment.
Read the diff, the resulting env.rs, and traced every block_on reachable from a teardown-time ODBC entry point. Both prior findings are resolved — the injectable-policy split genuinely closes the hole Copilot identified, and the doc section now presents one state. Not re-raising either.
Blocking: none. Suggestions: 1 (inline).
Verified
The policy split is the right shape and the mutation claims hold up. release_policy taking the flag as an argument rather than reading it is what makes the Leak arm reachable at all, and scheduler_still_runs_tasks is a real observable for "signalled nothing" rather than a restatement of the branch. The point in the reply — that the original test passed clean under the delete-the-condition mutation — is the part worth keeping in mind: it is the reason the refactor was necessary rather than cosmetic.
The #[cfg(not(windows))] arm is correct, not just convenient. Returning false unconditionally is right because POSIX atexit / destructor ordering does not terminate other threads before running library teardown, so shutdown_background() has live threads to signal. Worth stating that the arm is a real claim about the platform rather than a stub, since it is the half that never gets exercised by the repro.
Leaking the Runtime leaks only the runtime. The ManuallyDrop::take + mem::forget path drops nothing else on the way — EnvState's other fields still drop normally — so this does not silently suppress unrelated cleanup.
Suggestion
The fix guards SharedRuntime::drop, but that is not the only place the driver touches the scheduler during teardown. Left inline on the new primitive.
|
Reviewed — posted as a review with one inline suggestion on Short version: the policy split and the mutation evidence hold up, and the |
Review pointed out that SharedRuntime::drop is not the only teardown path that touches the scheduler, and the others reach it earlier in the same DLL_PROCESS_DETACH window. block_on is strictly worse than the shutdown_background this flag already avoids: it parks the calling thread until the scheduler's worker drives the socket, and that worker is exactly what the OS has already terminated. Three sites are reachable from a handle freed during process teardown: sp_unprepare on SQLFreeHandle(STMT), the cursor drain in drain_and_release, and the DAE cancel in unwind_dae. All three are best-effort cleanup the server redoes when the connection drops, so skip them while exiting; each still hands the client back so the DBC is left consistent. The other three block_on sites in the reviewer's list are on execute and fetch paths, not teardown, and are left alone. Also make process_is_shutting_down pub(crate) with a doc comment stating the obligation, and spell out what the FFI SAFETY argument actually rests on. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ss-exit-hang-on-handle-free
Saurabh Singh (saurabh500)
left a comment
There was a problem hiding this comment.
Unattended review sweep — automated, not checked by a human before posting. Treat the findings as a starting point, not a gate.
Verdict: no blocking issues. The core fix is sound, and I verified it independently beyond CI. I have one suggestion about an adjacent shutdown path this PR leaves unguarded.
What the PR does. It splits the runtime-release decision (release_policy(bool)) from the action (release) so both the shutdown Leak arm and the normal Detach arm are reachable from a live test process, and guards the three teardown-reachable block_on sites (unwind_dae, drain_and_release, the stmt-free unprepare) so SQLFreeHandle during DLL_PROCESS_DETACH skips its server round-trip instead of parking forever on a scheduler whose worker the OS already killed.
What this sweep checked independently (not just the green boxes):
- Mutation-tested the two new guard tests in a local worktree (
cargo nextest run -p mssqlodbc --lib): revertingLeak => std::mem::forgetback toshutdown_background(the #459 behaviour) failsthe_leak_policy_signals_nothing_and_leaves_the_runtime_running; forcingrelease_policyto always returnDetachfailsonly_a_shutting_down_process_selects_the_leak_policy. Both tests bite. - Teardown-reachability of every
block_onin the crate. The three left unguarded live on execute/fetch paths (finish_execute,release_busy_if_row_exhausted,flush_pending_unprepare) and are not reachable fromSQLFreeHandle/SQLCloseCursorteardown, so leaving them alone is correct. The three guarded ones each still hand the client back (return_client_idle, or restoringdbc.client+ clearingactive_stmt), so the DBC is left consistent whether or not the round-trip runs. - The
RtlDllShutdownInProgressFFI (BOOLEAN->u8, no args, resolved at load time) and thecfg(not(windows))->falsearm both read correctly.
Blocking: none.
Suggestion:
-
A shutdown-time
SQLDisconnectstill parks on an unguardedblock_on—mssql-odbc/src/api/txn.rs:790-797.rollback_before_disconnectcallsdbc.runtime.block_on(client.rollback_transaction(None, None))with noprocess_is_shutting_down()check. It is reachable fromSQLDisconnect(disconnect.rs:90), which a host can call from the same static-destructor /onexitpath this PR fixes forSQLFreeHandle; with an open transaction at process exit it hangs on exactly the dead scheduler AB#47510 is about. This PR actually moves the disconnect path closer to that hang:close_all_cursors->close_cursor_for_connection_op-> the now-guardeddrain_and_release, so a shutdown-timeSQLDisconnectclears the cursor sweep and proceeds straight into the unguarded rollback.txn.rsis not in this diff, so this is a pre-existing gap and a defensible deferral — but since the PR deliberately generalizedprocess_is_shutting_down()topub(crate)for exactly this shape, it is worth either guarding the sibling here:if client.has_active_transaction() && !process_is_shutting_down() && let Err(e) = dbc.runtime.block_on(client.rollback_transaction(None, None)) { error!(%e, "{OP}: rollback failed; the server will roll back on disconnect"); }
(the fall-through to
release_dbc_clientstill runs, and the server rolls the transaction back when the socket drops anyway), or filing a follow-up so the shutdown-hang fix is not silently partial. IsSQLDisconnect-at-shutdown intentionally out of scope for this PR?
Nit: none.
SQLDisconnect sits between SQLFreeHandle(STMT) and SQLFreeHandle(ENV) in a host's teardown sequence, so rollback_before_disconnect is on the same DLL_PROCESS_DETACH path those two already guard, and its block_on would park on the same dead scheduler. This PR made the gap easier to reach rather than creating it: the cursor sweep it runs first goes through drain_and_release, which now succeeds during shutdown instead of stalling, so control reaches the unguarded rollback. Leaving it would repeat the exact pattern this PR faults #459 for. Skipping is what the surrounding code already falls back to - the sweep bail-out above says the server rolls back when the socket closes, and the transaction carries no user work. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ss-exit-hang-on-handle-free
|
Saurabh Singh (@saurabh500) — thanks, this was worth catching.
No, and it shouldn't be. Guarded in You are right that That is the shape I criticised #459 for — the visible symptom moves while the hazard stays — so deferring it after making that argument would have been hard to defend. The guard is yours with one change: I log at On the reachability boundary. That is now every One thing worth flagging that is genuinely not mine. Re-running the 42-file suite after merging Also worth being explicit, since your sweep checked the tests carefully: none of these four guarded branches is reachable from a test process. |
ttk (Theekshna)
left a comment
There was a problem hiding this comment.
Unattended run found no new findings. Covered: msodbcsql parity (N/A — independently confirmed against sqlncli_main.cpp's DLL_PROCESS_DETACH/lpvReserved check, which solves a different problem: DllMain unload-vs-exit, not an ODBC entry point called from another module's onexit after the OS has killed other threads), a fresh compile (cargo check -p mssqlodbc --all-targets), the newest commit's xn.rs guard (closes the SQLDisconnect rollback gap saurabh500 raised, consistent with the three sibling guards), test sufficiency (all four shutdown branches are honestly documented as unreachable from a Rust unit test and covered instead by the cross-repo mssql-python swap job), divergence docs (N/A, not a parity divergence), PR description currency (matches the diff, checklist matches green CI, AB#47509/AB#47510 linked), and AI slop (none — doc comments explain the loader hazard and are the load-bearing safety argument, not restatements).
…ss-exit-hang-on-handle-free
David Engel (David-Engel)
left a comment
There was a problem hiding this comment.
Summary
The process-shutdown detection and guards address the dead-worker hang, but the live-process release policy still detaches runtime threads. AB#47831 reports an intermittent Windows teardown crash after this behavior landed, so normal release should synchronously join while only the already-shutting-down process path leaks.
…down Review (AB#47831): shutdown_background() returns without waiting, so after SQLFreeHandle(ENV) returns a host can unload mssqlodbc.dll while a runtime thread is still executing Tokio, mio, or driver code from that module. That produced an intermittent STATUS_STACK_BUFFER_OVERRUN in mio's IOCP completion buffer at roughly one crash per 45 runs of a single e2e binary. Restore the policy split the pre-merge 216e1ea had, now that the shutdown arm exists to carry the case shutdown_background was adopted for: process alive -> Join, the normal Runtime drop, which waits process exiting -> Leak, touching nothing (AB#47509, AB#47510) Replace the prompt-detach tests, which asserted the behaviour now known to be unsafe, with barrier-based ones proving a live-process release waits for blocking work to exit. Verified by mutation: reverting Join to shutdown_background fails all three. Add a Windows DLL load/unload stress test. Note that it only reproduces with a live connection - an allocate-and-free-only loop survives 1000 iterations because the I/O driver whose buffers fault is never exercised. Describe DLL/thread lifetime as the leading hypothesis rather than asserting Tokio frees its reactor beneath its own worker; the captured stack shows where the fault lands, not why. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ttk (Theekshna)
left a comment
There was a problem hiding this comment.
This is a well-reasoned fix — the two-state Join/Leak split is correct, the four block_on call-site guards are consistent, and the unit tests are mutation-verified (reverting Join→shutdown_background or dropping the shutdown check fails the tests that guard them, as claimed). msodbcsql parity is N/A — this is process-teardown/runtime-lifecycle machinery, not an ODBC SQL-type/attribute surface, so there's no divergence to register. Docs (tokio.md, typed-columnar-fetch-plan.md) match the code. However, the new Windows regression test for the bug this PR fixes does not compile.
| Severity | Count |
|---|---|
| Blocking | 1 |
| Suggestion | 0 |
| Nit | 0 |
[Blocking] mssql-odbc/tests/e2e/tests/dll_unload_stress_test.cpp:109 has malformed C++ — verified by actually compiling the line (not just reading it):
conn += "UID=" + uid + ";******ODBC_TEST_PWD", "") + ";";
g++ (-std=c++17) rejects it: error: expected ';' before ')' token. The shape (;******ODBC_TEST_PWD", "")) looks like PWD=" + GetEnvOr(" was replaced by ******, breaking the statement — the intended line is presumably:
conn += "UID=" + uid + ";PWD=" + GetEnvOr("ODBC_TEST_PWD", "") + ";";matching the UID= branch two lines above and the other GetEnvOr(...) call sites in this same function. This file is registered in CMakeLists.txt via add_odbc_test(dll_unload_stress_test tests/dll_unload_stress_test.cpp), and enableOdbcE2E: true is set for the Windows PR build stage (validation-stages.yml:132, templates/build-template.yml:187), so this should fail that stage's compile — yet gh pr checks currently reports Build Stage Build Windows green. Worth a look either way: if CI is genuinely compiling this file and passing, something in the toolchain is silently accepting invalid syntax (very unlikely) or masking the failure; if it isn't being compiled (e.g. a stale/reused build), that's a validation gap independent of this bug. I did not have ADO access to inspect the actual build log for this run.
This also means the checklist's "New/changed functionality has tests" isn't fully true yet: the stress test that's supposed to directly guard AB#47831 (the DLL-unload crash this PR fixes) can't currently run on any platform where it isn't skipped.
Everything else checked clean: test mutation-resistance for the Rust-side unit tests, PR description/checklist currency against the diff and CI, and no AI slop in the new doc comments (they carry load-bearing safety reasoning, not restatement).
Unattended automated review. Findings above were not checked by a human before posting.
Review reported this line as malformed C++. The file is not malformed - the bytes on disk and on GitHub are identical and well-formed, MSVC compiles it, and the line sits inside #ifdef _WIN32 so the g++ run that reported the error would never have parsed it. What happened is that the keyword-equals-value pattern trips secret-redaction filters in review and code-reading tooling, which rewrite the line to asterisks; the reviewer compiled the redacted copy. The code was correct, but the false positive is going to recur for every reviewer and tool that reads this file, so break the literal in two and say why in a comment. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ss-exit-hang-on-handle-free
PowerShell variable names are case-insensitive, so a \ helper clobbered the \ path variable in the previous commit: the rewritten source was written to a file literally named P at the repo root, the real .cpp was left untouched, and the build and test run that appeared to verify the change were actually exercising the unmodified file. Move the content to dll_unload_stress_test.cpp, delete the stray file, and re-verify: compiles under MSVC and passes 200 iterations against a live connection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Following up on the "is CI actually compiling this file?" question from the review summary, since you noted you couldn't check it without ADO access. There is no validation gap — CI does compile it, and it compiled fine. The chain:
So the green check was correct and the compile error was not reproducible from the committed source. I verified the same thing two other ways: the raw character codes of that line are byte-identical locally and via the GitHub contents API at The cause is a secret-redaction filter, not a syntax error. The keyword-equals-value pattern gets rewritten to asterisks by review and code-reading tooling, and the copy that was compiled was a redacted one. The tell is visible in your own review body: the "malformed" line you quote and the "intended" line you propose as the fix render identically, because both were redacted on the way out. My own file viewer has the same problem — I could only read the line by dumping character codes. I've restructured it anyway in Two things you flagged that were fair and that I've acted on:
Still worth a human's eye: I have not been able to reproduce AB#47831 on this machine in either direction (0 crashes in 90 runs against both the pre-fix and fixed builds, where ~2 were expected pre-fix), so the fix rests on mechanism and mutation-verified tests rather than on observing the crash disappear. |
|
Unrelated to this PR, and I checked rather than assumed:
The file itself hasn't been modified since #143, so this looks like existing flakiness in that suite on the macOS leg rather than something new. Re-running validation rather than touching an unrelated test: /azp run |
Build 172407 failed Test MacOS on two mssql-tds timeout/cancel tests (query_cancel, query_timeout_e2e). Neither is reachable from this PR: the diff is 10 files, all under mssql-odbc, and those tests live in a separate binary that never links mssqlodbc. The same mssql-tds code went 19/19 green on the two previous heads. Both assert wall-clock windows against a live server (a 2s timeout expected to land between 1500-3000ms), and both had already exhausted the ci profile's 5 retries, so more in-run retries would not settle it. /azp run drew no response from the pipelines bot, so this empty commit is the documented fallback for re-triggering validation. No code change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ttk (Theekshna)
left a comment
There was a problem hiding this comment.
Unattended re-review: the two new commits (082f4fae, 02661b4c) fix dll_unload_stress_test.cpp so the password-literal line is no longer mangled by secret-redaction filters, and the earlier #ifdef _WIN32-gated content compiles clean under the current Windows PR build stage (verified against check-runs for this exact head SHA, not a cached result). This also confirms my own prior Blocking finding on that line was a false positive from the same redaction artifact David-Engel documented in the fix commit — retracted. All 6 review threads are resolved. The merge from main and the empty re-trigger commit carry no new content of this PR's own. coverage-report's failure is a stale Azure DevOps build (172407, superseded by the passing 172423) per the re-trigger commit's own explanation, not a defect in this diff.
Covered: msodbcsql parity (N/A for this round's delta — no ODBC-table arms touched by the two new commits), test sufficiency (unchanged; stress test unaffected by the literal fix), divergence docs (N/A), PR description currency (matches current diff and checklist; AB#47510/47831/47509 linked), verbose slop (the new workaround comment at lines 109-113 records why, not slop), evidence audit (re-derived the 4-site process_is_shutting_down() gating table in free_handle.rs/close_cursor.rs/exec_common.rs/txn.rs — all four gate correctly; did not re-run the live-connection crash-rate or 42-file timing claims, no live SQL Server available in this context).
| Severity | Count |
|---|---|
| Blocking | 0 |
| Suggestion | 0 |
| Nit | 0 |
ttk (Theekshna)
left a comment
There was a problem hiding this comment.
Reviewed the change, the existing comment threads, and checked parity against msodbcsql in ODBC_2.
The policy decision is right, and I verified the parity claim rather than taking it on trust. sqlncli_main.cpp:139-141 skips cleanup outright when the process is shutting down (if (lpvReserved) break;), and sni.cpp:877 reinforces it ("AFTER THIS POINT, MEMORY APIS SHOULD NOT BE USED"). The Leak arm is the direct analogue of the shipping driver's own rule. The Join arm's argument stands on its own mechanism. Tests are good, not vacuous, and the mutation verification (revert Join -> 3 failures; delete the condition -> 1 failure) is the right bar.
Two blockers below, both the same shape: the guard does not guard anything yet. Neither is a correctness defect in the shipped logic.
One thing worth knowing, no change requested: Join makes SQLFreeHandle(ENV) unbounded, because Runtime::drop waits on spawn_blocking tasks, which are not cancellable, and auth/interactive.rs runs the OneAuth sign-in that way on this same runtime (reached via driver_connect.rs:408). Waiting is the fix and a bound would reintroduce AB#47831, so I am not asking for one -- flagging the wrinkle only. Reachability unverified.
Also: keep the "did not reproduce on the machine this was developed on" disclosure exactly as written. That is the correct call.
For the record, things I raised while reviewing and then withdrew after checking, so nobody re-litigates them: dropping the TdsClient in disconnect.rs as a residual hang (your own repro exits in 0.5s through that path, so the evidence contradicts it); Runtime::drop panicking in an async context (unreachable -- only EnvHandle/DbcHandle hold the Arc, both released from FFI entry points); and a lpvReserved-vs-RtlDllShutdownInProgress parity nit (moot -- this crate has no DllMain, so there is no detach callback to read lpvReserved from).
Review raised two blocking gaps. 1. The four process_is_shutting_down() guards read the flag directly, so every skip arm was dead code under test - deleting all four left the suite green. Apply the same decision/action seam already used for the ENV drop: each site gets an _inner taking the flag as a parameter. Two are now mutation-pinned. drain_and_release leaves the batch undrained and rollback_before_disconnect leaves the transaction open, both verified by neutering the guard and watching the test fail. Each has a flag-is-false twin so an always-skip guard fails too. The other two are not, and say so in their names and doc comments: cancel_streamed_write returns () and a failed unprepare neither errors observably nor marks the connection dead, so on a scripted client a skipped round-trip and an attempted one are indistinguishable. Those tests pin the hand-off the skip arm still owes - client returned, connection released - and the skip itself stays covered by the mssql-python job. 2. MSSQL_ODBC_DLL was set nowhere in the repo, so dll_unload_stress_test skipped unconditionally in CI while the PR description presented it as coverage. run_e2e.ps1 now passes through Invoke-CtestRun for the mssql-odbc leg only, saved and restored like the other per-leg vars. The reference leg still skips, which is parity-neutral. Verified both directions against a live server. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Unattended hourly sweep: reviewed the delta since the last automated review (c1e12249→c1c45b13, the "Cover the four shutdown guards, and wire the stress test into CI" commit). It fully addresses the prior round's blocking finding — all four process_is_shutting_down() call sites now take the flag as a parameter via an _inner seam, matching the pattern already used for the ENV drop, so the skip arms are no longer dead code under test. Source-traced each new test: the two labeled mutation-verified (drain_and_release, rollback_before_disconnect) genuinely discriminate on the flag (asserting undrained batch / open transaction only when true, with a false twin proving the guard isn't unconditional); the two labeled not-mutation-verified (unwind_dae, unprepare_on_free) are honestly described — cancel_streamed_write returns () and a failed unprepare has no observable effect, so skip-vs-attempt is indistinguishable on a scripted client, and the tests correctly pin only the hand-off (client returned, connection released) rather than the skip itself. run_e2e.ps1 now threads MSSQL_ODBC_DLL through Invoke-CtestRun for the mssql-odbc leg only (correct — the runtime under test is ours), leaving the reference leg to skip, which parity_report.py treats as parity-neutral.
Local build here hit a pre-existing toolchain/environment limitation unrelated to this PR — the ms-prod-1.95 codegen backend can't lower crc32fast's AVX-512/VPCLMULQDQ intrinsics on this host regardless of -C target-feature/target-cpu overrides — so I could not independently re-run the mutation locally. gh pr checks shows all CI legs (Windows/Linux/Linux ARM/macOS build+test, Kerberos, coverage-report, cross-repo mssql-python suite) green on this head SHA, which is what actually exercises the new tests; I relied on that plus the source trace above rather than re-deriving the same verdict.
No findings. This is an automated unattended run; findings were not checked by a human first.
| Severity | Count |
|---|---|
| Blocking | 0 |
| Suggestion | 0 |
| Nit | 0 |
|
Adding one piece of evidence that was not available at review time: the stress test is now confirmed to actually run in CI, which is the thing the previous round's second finding was about. From the Windows e2e leg of ADO build 172517 on this head ( That is exactly the intended shape, and the timings are the proof rather than the pass/fail:
Before this commit that test skipped on both legs and cost 0.01s twice, which is what made it invisible. On your local build problem — no action needed from your side. The One thing I do not want this green run to be read as: it is not confirmation that the AB#47831 crash is fixed. I have never reproduced that crash on my machine — 0 crashes in 90 runs against a deliberately-rebuilt |
…ss-exit-hang-on-handle-free
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |
…ss-exit-hang-on-handle-free
|
The Colima VM never finished coming up — its log stops at
This is agent infrastructure, not this diff. Same failure signature PR #491 hit twice on builds 172385 and 172387 earlier today, so the macOS leg looks broadly unstable at the moment rather than reacting to anything here — worth someone looking at the Colima step's timeout independently of this PR. I have not touched the failing test or the macOS configuration. Instead I merged #479 from Re-validated locally after the merge: 1355 unit tests pass, |
|
No test failed. Every test task in 172563 reports But the numbers suggest this is a budget problem rather than randomness. The step is capped at
So healthy runs land at 531–640s against a 780s cap — roughly 18% headroom on a good day. Two different stall points across the two failures, which fits "slow agent" better than "wedged VM":
I have not touched the timeout or the script. This PR changes no macOS, Docker, or pipeline-timeout behaviour, and raising a shared CI budget to get one PR green is exactly the kind of change that should not ride along in an unrelated diff. Flagging it instead: someone owning CI may want to look at whether 13 minutes is still the right cap, independently of this PR. PR #491 hit the same step twice today (172385, 172387), so it is not specific to this branch. Re-running validation: /azp run |
…ss-exit-hang-on-handle-free
|
Fourth consecutive It is repo-wide right now. Every PR that ran validation in the last hour is failing on the same step:
Identical downstream cascade in all three ( The measurements say the budget has been outgrown, not that the VM is wedged. Colima step duration across this PR's recent builds, against a
Two things stand out. Every failure lands in a 790–794s band — right at the cap, not scattered, which is the signature of a step being cut off rather than hanging. And the successful runs have drifted from ~535s to 725s over the same window, so the headroom has been eroding and 172583 is simply the first build where both macOS jobs ran out of it at once.
I am not changing the timeout in this PR. It is a shared CI setting, this diff touches no macOS/Docker/pipeline behaviour, and raising a global budget so one PR goes green is precisely the kind of change that should not ride along in an unrelated change. It needs its own PR from whoever owns CI, with the numbers above as the justification. For this PR specifically: the approval is in, |
Build 172583 failed Test MacOS on the Colima setup step timing out, and so did the concurrent runs for PRs 482 and 491 - the same step, the same cascade, no test failures anywhere. coverage-report then failed as a downstream effect: it polled 75 minutes for the Cobertura artifact that the aborted macOS job never published. No build has run repo-wide since 11:20, so there is no signal on whether the agents have recovered. This re-run is that probe. Nothing in the branch has changed; main has not moved either, so an empty commit is the honest trigger. Not touching the 13-minute Colima cap - that is a shared CI setting and needs its own PR from whoever owns CI, with the timing measurements posted on 486. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
|
Update on the failures, including one I had not yet traced: all four share the single Colima root cause.
That workflow polls the ADO build for the merged Cobertura artifact. Build 172583's macOS job aborted at the Colima step, so the artifact was never published, and the workflow waited out its full 75-minute budget for something that could not arrive. Nothing to fix in the workflow — it did exactly what it should. So the current red set is: No build has run anywhere in the repo since 11:20 (the three that did — 486, 482, 491 — all failed identically), so there was no signal on whether the agents have recovered in the hour since. I have pushed an empty commit to get that signal. If this run comes back green, the outage was transient and this PR is ready. If it fails at 780s again, that is a fifth data point and the timeout bump is worth doing on its own merits — the measurements are two comments up, and I am deliberately not making that change here since it is a shared CI setting and this diff touches no pipeline behaviour. |
Description
Teardown of the per-ENV Tokio runtime has opposite requirements depending on whether the process is still alive. This makes the choice explicitly, closing two bugs that pull in opposite directions.
Join— the normalRuntimedrop, which waits for its threadsSQLFreeHandle(ENV)lets the host unload the DLL (AB#47831)Leak— touch nothing at allThe shutdown hang (AB#47510)
A process that used this driver would hang forever on the way out, after its workload had already succeeded.
mssql-pythonfrees its pooled connection handles from its extension module's CRTonexittable, which the loader runs fromDLL_PROCESS_DETACHinsideLdrShutdownProcess— after Windows has terminated every thread but the one callingExitProcess.SQLFreeHandlethere released the lastArc<Runtime>, and the teardown waited on threads that no longer existed.#459 addressed the panic with
shutdown_background(), which skips the join but still signals the scheduler and takes locks a terminated worker may have died holding. Measured againstmainat2e2afe2:mainat2e2afe2tests/test_025_logging_concurrency_deadlock.pywent from 1 failure to 2 against that build; both pass here. The minimal repro is a singleconnect()/close()on athreading.Thread— no pooling, no logging, no concurrency, despite where AB#47510 was first observed.The live-process unload race (AB#47831)
shutdown_background()is also wrong in the other direction. It returns without waiting, so afterSQLFreeHandle(ENV)returns a host can unloadmssqlodbc.dllwhile a runtime thread is still executing Tokio, mio, or driver code from that module — an intermittentSTATUS_STACK_BUFFER_OVERRUNwhile mio's IOCP completion buffer was destroyed, at roughly one crash per 45 runs of a single e2e binary. DLL/thread lifetime is the leading hypothesis rather than an established mechanism; the captured stack shows where the fault lands, not why. Either way, waiting is what makes the unload safe.Teardown paths beyond the ENV drop
Runtime::block_onis worse than either: it parks the calling thread and needs the scheduler's worker to drive the socket, so with that worker terminated the round-trip never completes.process_is_shutting_down()ispub(crate), and everyblock_onon a host's teardown sequence —SQLFreeHandle(STMT)→SQLDisconnect→SQLFreeHandle(ENV)— consults it:free_handle.rsunprepareSQLFreeHandle(SQL_HANDLE_STMT)close_cursor.rsclose_querydrain_and_release, from free /SQLCloseCursorexec_common.rscancel_streamed_writeunwind_dae, fromSQLFreeHandle(SQL_HANDLE_STMT)txn.rsrollback_transactionrollback_before_disconnect, fromSQLDisconnectAll four are best-effort cleanup the server redoes when the connection drops, so they are skipped while exiting; each still hands the client back so the DBC stays consistent. Three further
block_onsites are on execute and fetch paths, unreachable during process shutdown, and are deliberately left alone.Each site takes the flag as a parameter rather than reading it, so the skip arms — unreachable in a live process — are testable at all.
Validation
Mutation-verified, i.e. the guard was neutered and the test observed to fail:
the_join_policy_waits_for_blocking_work_to_finish(+EnvHandleand DBC-outlives-ENV twins)the_leak_policy_touches_nothing_and_leaves_the_runtime_runningonly_a_shutting_down_process_selects_the_leak_policydrain_and_release_skips_the_round_trip_while_the_process_is_exiting(+ flag-false twin)rollback_before_disconnect_skips_the_round_trip_while_the_process_is_exitingNot mutation-verified, and named accordingly:
unwind_dae_leaves_the_connection_usable_while_the_process_is_exitingandunprepare_on_free_leaves_the_connection_usable_while_the_process_is_exiting.cancel_streamed_writereturns(), and a failedunprepareneither errors observably nor marks the connection dead, so on a scripted client a skipped round-trip is indistinguishable from an attempted one. These pin the hand-off the skip arm still owes — client returned,active_stmtcleared — and the skip itself is covered by themssql-pythonswap job.dll_unload_stress_testdrives the AB#47831 window directly: load, connect, query, free, unload, repeat.run_e2e.ps1setsMSSQL_ODBC_DLLfor the mssql-odbc leg only, so it runs there and skips on the reference leg (parity-neutral —parity_report.pytreats a one-sidedSKIPas not-compared). It only reproduces with a live connection: an allocate-and-free-only loop survived 1000 iterations against a known-badshutdown_backgroundbuild, because the I/O driver whose buffers fault is never exercised. The connect-and-query shape is load-bearing, and the file says so.Limit worth stating plainly: AB#47831 did not reproduce on the machine this was developed on. Following the work item's own repro (
execute_test.exe, 90 runs), a pre-fixshutdown_backgroundbuild produced 0 crashes and this build produced 0 crashes, where roughly 2 were expected in the first column. That is a null result on this hardware, not confirmation — and it means a greendll_unload_stress_testis a regression guard going forward, not evidence the crash is gone. Confirming that needs a run on a machine where it reproduces. The change rests on the mechanism, on tests that provably catch a revert, and on no measurable regression.End to end, a per-file run of all 42
mssql-pythonfiles on Windows shows no regression, with zero timeouts.Related Issues
https://sqlclientdrivers.visualstudio.com/mssql-rs/_workitems/edit/47510
Checklist
cargo bfmtpassescargo bclippypasses — including thecheck-private-itemslint added by Enforce safety docs for private unsafe functions #485cargo btestpasses —mssqlodbc's 1303 tests pass. Themssql-tdslive-server integration and bench binaries fail locally for want of credentials (Login failed for user ''); they are unaffected by this change, which touches onlymssql-odbc. Left to CI to confirm.tokio.mdanddocs/typed-columnar-fetch-plan.mdare updated to describe the two-state teardown.