Skip to content

Flaky Windows CI crash: UV_HANDLE_CLOSING assertion at ava worker exit on Node 24.19.0 #360

Description

@Brooooooklyn

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions