Symptom
A Windows CI job aborts after every test has already passed:
√ streaming-class › class compress output is byte-identical across all chunkings
Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94
× __test__\streaming-class.spec.ts exited with a non-zero exit code: 3221226505
...
32 tests passed
Exit code 3221226505 is 0xC0000409, which MSVC uses for __fastfail / abort().
Zero tests fail. The ava worker process crashes at exit.
Occurrences
| run |
date |
branch |
commit |
job |
Node |
| 33768993932 |
2026-09-03 |
renovate/lock-file-maintenance |
037461f |
aarch64-pc-windows-msvc node@24 |
v24.19.0 |
| 34307595091 |
2026-09-09 |
fix/cjs-entry-require-esm |
a9bfe04 |
x86_64-pc-windows-msvc node@24 |
v24.19.0 |
Both hits are in __test__/streaming-class.spec.ts, both on node@24, one per architecture.
The first is a lockfile-only commit, so this is not caused by any source change.
Rate is roughly 2%: 2 hits across 96 Windows test jobs.
Cause
This is a known Node.js bug, already fixed upstream. It is not a libuv bug and not a bug in
this repo's Rust code.
DelayedTaskScheduler in src/node_platform.cc owns a uv_async_t flush_tasks_. A V8
platform worker can post a delayed task after Stop() has begun closing that handle, so
uv_async_send() runs on a handle that already carries UV_HANDLE_CLOSING.
Node 24.19.0 ─► DelayedTaskScheduler::PostDelayedTask() races Stop()
uv_async_send(&flush_tasks_) after uv_close
│
▼
libuv 1.52.1 win/async.c:94 assert(!(flags & UV_HANDLE_CLOSING)) ─► abort() ─► 0xC0000409
▲
└── Windows only. src/unix/async.c has no such assert.
The upstream fix is nodejs/node#61999, which
closed #56645 and
#64322:
void PostDelayedTask(...) {
auto locked = tasks_.Lock();
+ if (has_shut_down_) return;
...
void Stop() {
auto locked = tasks_.Lock();
+ has_shut_down_ = true;
locked.Push(std::make_unique<StopTask>(this));
uv_async_send(&flush_tasks_);
}
I checked src/node_platform.cc at each release tag. DelayedTaskScheduler::Stop sets
has_shut_down_ only from 24.20.0 on:
| tag |
guard in DelayedTaskScheduler |
| v22.23.2 |
no |
| v24.19.0 |
no ← what CI ran |
| v24.20.0 |
yes |
| v24.21.0 |
yes |
| v26.8.1 |
yes |
Why CI ran 24.19.0
.github/workflows/CI.yaml pins node-version: 24 and does not set check-latest, which
defaults to false. The runner served a stale tool cache entry:
Found in cache @ C:\hostedtoolcache\windows\node\24.19.0\x64
What was ruled out
I traced the full control flow of the class API before blaming Node.
DecompressorState::spawn() (src/stream.rs:250) starts a plain OS thread. It holds only
std::sync::mpsc channels and a JoinHandle. It never calls into napi, V8 or libuv, so it
cannot reach uv_async_send.
- That thread is joined in
into_finish() (src/stream.rs:299 and :316). Every test in the
spec awaits finish(), so every thread was joined.
Decompressor has no Drop and no custom finalizer. No test leaves one unfinished.
src/stream_web.rs runs no code in this spec. The spec calls only Compressor,
Decompressor and uncompressSync.
- Local repro on macOS arm64, Node 24.21.0: 20 runs of the spec and 4 × 150 child-process
teardown probes, all clean. Expected — the assert does not exist in src/unix/async.c, and
24.21.0 already carries the fix.
Suggested fix
Set check-latest: true on actions/setup-node, or pin node-version: 24.20.0 or later.
Warning: Node 22 is still exposed. The fix is not in any 22.x release, so node@22 in the
matrix can still hit this.
Not proven
- Which
uv_async_t asserted. The message does not name the handle. Three live in that
process: Node's flush_tasks_, libuv's internal loop->wq_async, and the custom-GC
threadsafe function napi-rs creates per environment. The version evidence points at
flush_tasks_, but that is inference, not a stack trace.
- Why only
streaming-class.spec.ts. That spec creates and joins the most threads and issues
the most async work, so it plausibly holds the widest teardown window. n=2 is not proof.
- That upgrading Node removes it. That is a version-consistent prediction, not an observation.
Links
🤖 Generated with Claude Code
https://claude.ai/code/session_01Qa2uh946PBdYTn9Tjw19TZ
Symptom
A Windows CI job aborts after every test has already passed:
Exit code
3221226505is0xC0000409, which MSVC uses for__fastfail/abort().Zero tests fail. The ava worker process crashes at exit.
Occurrences
renovate/lock-file-maintenance037461ffix/cjs-entry-require-esma9bfe04Both hits are in
__test__/streaming-class.spec.ts, both onnode@24, one per architecture.The first is a lockfile-only commit, so this is not caused by any source change.
Rate is roughly 2%: 2 hits across 96 Windows test jobs.
Cause
This is a known Node.js bug, already fixed upstream. It is not a libuv bug and not a bug in
this repo's Rust code.
DelayedTaskSchedulerinsrc/node_platform.ccowns auv_async_t flush_tasks_. A V8platform worker can post a delayed task after
Stop()has begun closing that handle, souv_async_send()runs on a handle that already carriesUV_HANDLE_CLOSING.The upstream fix is nodejs/node#61999, which
closed #56645 and
#64322:
void PostDelayedTask(...) { auto locked = tasks_.Lock(); + if (has_shut_down_) return; ... void Stop() { auto locked = tasks_.Lock(); + has_shut_down_ = true; locked.Push(std::make_unique<StopTask>(this)); uv_async_send(&flush_tasks_); }I checked
src/node_platform.ccat each release tag.DelayedTaskScheduler::Stopsetshas_shut_down_only from 24.20.0 on:DelayedTaskSchedulerWhy CI ran 24.19.0
.github/workflows/CI.yamlpinsnode-version: 24and does not setcheck-latest, whichdefaults to
false. The runner served a stale tool cache entry:What was ruled out
I traced the full control flow of the class API before blaming Node.
DecompressorState::spawn()(src/stream.rs:250) starts a plain OS thread. It holds onlystd::sync::mpscchannels and aJoinHandle. It never calls into napi, V8 or libuv, so itcannot reach
uv_async_send.into_finish()(src/stream.rs:299and:316). Every test in thespec awaits
finish(), so every thread was joined.Decompressorhas noDropand no custom finalizer. No test leaves one unfinished.src/stream_web.rsruns no code in this spec. The spec calls onlyCompressor,DecompressoranduncompressSync.teardown probes, all clean. Expected — the assert does not exist in
src/unix/async.c, and24.21.0 already carries the fix.
Suggested fix
Set
check-latest: trueonactions/setup-node, or pinnode-version: 24.20.0or later.Warning: Node 22 is still exposed. The fix is not in any 22.x release, so
node@22in thematrix can still hit this.
Not proven
uv_async_tasserted. The message does not name the handle. Three live in thatprocess: Node's
flush_tasks_, libuv's internalloop->wq_async, and the custom-GCthreadsafe function napi-rs creates per environment. The version evidence points at
flush_tasks_, but that is inference, not a stack trace.streaming-class.spec.ts. That spec creates and joins the most threads and issuesthe most async work, so it plausibly holds the widest teardown window. n=2 is not proof.
Links
🤖 Generated with Claude Code
https://claude.ai/code/session_01Qa2uh946PBdYTn9Tjw19TZ