Skip to content

Windows: use-after-free — unsubscribe() destroys Subscription while a ReadDirectoryChangesW completion APC is still pending #262

Open

Description

@Manuel05UX02

Summary

On Windows, unsubscribe() destroys the Subscription object from a libuv worker thread while the kernel still holds a pointer to the OVERLAPPED embedded in it. The pending ReadDirectoryChangesW completion is then delivered as an APC to the backend thread, which dereferences the freed object.

CancelIo in Subscription::stop() does not prevent this, because it only cancels I/O issued by the calling thread, and the read was issued on a different one.

This reproduces deterministically under page heap (4/4 runs, within 25 subscribe/unsubscribe rounds) and is the cause of a long-standing intermittent native crash in our Electron host process without it.

Verified against v2.6.0, Windows 11 26200, x64, Electron 40.8.5 (Node 24.14).

The lifetime

Subscription embeds its own OVERLAPPED and hands the kernel a raw this through it (src/windows/WindowsBackend.cc:77-78):

ZeroMemory(&mOverlapped, sizeof(OVERLAPPED));
mOverlapped.hEvent = this;

poll() issues the read with a completion routine that recovers the object from that raw pointer (WindowsBackend.cc:138-155):

ReadDirectoryChangesW(mDirectoryHandle, ..., &mOverlapped,
  [](DWORD errorCode, DWORD numBytes, LPOVERLAPPED overlapped) {
    auto subscription = reinterpret_cast<Subscription *>(overlapped->hEvent);
    ...
  });

poll() runs on the backend thread — subscribe() reaches it via QueueUserAPC(..., mThread.native_handle(), ...) (WindowsBackend.cc:269-272), and processEvents() calls poll() again from inside the completion routine, so every read is issued on mThread.

Teardown happens on a different thread. UnsubscribeRunner::execute() runs on a libuv worker via napi_create_async_work (src/PromiseRunner.hh:16-17, src/binding.cc), and calls:

void Backend::unwatch(WatcherRef watcher) {   // src/Backend.cc
  std::unique_lock<std::mutex> lock(mMutex);
  size_t deleted = mSubscriptions.erase(watcher);
  if (deleted > 0) {
    this->unsubscribe(watcher);
    unref();
  }
}

void WindowsBackend::unsubscribe(WatcherRef watcher) {   // WindowsBackend.cc:280
  watcher->state = nullptr;
}

watcher->state is the std::shared_ptr<WatcherState> holding the Subscription (src/Watcher.hh:35), and it is the last reference — the sub local in subscribe() went out of scope. So = nullptr runs ~Subscription() on the libuv worker:

void stop() {
  if (mRunning) {
    mRunning = false;
    CancelIo(mDirectoryHandle);
    CloseHandle(mDirectoryHandle);
  }
}

Two problems:

  1. CancelIo cancels nothing here. It only cancels operations issued by the calling thread. The read was issued on mThread; this call is on a libuv worker, so the pending read survives. CancelIoEx is the cross-thread form.
  2. Even a successful cancel still completes. Cancellation delivers the completion with ERROR_OPERATION_ABORTED rather than suppressing it — and the delivery path dereferences the OVERLAPPED before any user code gets to check errorCode.

So the object is freed, and the kernel then delivers an APC carrying a pointer into it to a thread parked in SleepEx(INFINITE, true) (WindowsBackend::start(), WindowsBackend.cc:59-61).

Stack

The fault is in the delivery thunk reading the OVERLAPPED's status fields, before the completion lambda runs:

KERNELBASE!BasepIoCompletionSimple+0xa   mov eax,dword ptr [rdx]   ds:...=????????
ntdll!KiUserApcDispatch+0x2e
ntdll!NtDelayExecution+0x14
ntdll!RtlDelayExecution+0x34
KERNELBASE!SleepEx+0x91
watcher!napi_register_module_v1+0x26c50        <- WindowsBackend::start
watcher!napi_register_module_v1+0x9819
watcher!napi_register_module_v1+0xaa5e
watcher!napi_register_module_v1+0x31a0f
kernel32!BaseThreadInitThunk+0x17
ntdll!RtlUserThreadStart+0x2c

rdx points at a page that page heap has already made inaccessible. Bucket: INVALID_POINTER_READ_AVRF_c0000005_watcher.node!Unknown.

Reproducer

const watcher = require('@parcel/watcher');
const fs = require('node:fs'), os = require('node:os'), path = require('node:path');

const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pw-repro-'));

// Keep completions landing continuously, so an APC is in flight at teardown.
let n = 0;
setInterval(() => {
  for (let i = 0; i < 40; i++) fs.writeFileSync(path.join(root, `f${i}.txt`), String(n++));
}, 5).unref();

(async () => {
  for (let round = 0; round < 300; round++) {
    const sub = await watcher.subscribe(root, () => {});
    await new Promise((r) => setTimeout(r, 20));
    await sub.unsubscribe();          // remove this line and it survives
    if (round % 25 === 0) console.log('round', round);
  }
  console.log('SURVIVED');
})();

Enable page heap on the host image first, otherwise the bad read usually hits still-mapped memory and silently corrupts it instead of faulting — which is why this took us a long time to place. Without page heap our crash arrived roughly once every 2–4 days, always with a stack pointing at whatever allocated next; with it, once every 1–3 minutes.

reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\node.exe" /v GlobalFlag /t REG_SZ /d 0x02000000 /f
reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\node.exe" /v PageHeapFlags /t REG_SZ /d 0x3 /f

Results, same watching and same event load in both arms, unsubscribe the only variable:

arm outcome
with unsubscribe() died 4/4 with 0xC0000005, always within the first 25 rounds
without unsubscribe() 300 rounds, clean exit

Suggested direction

The free has to happen on the thread that owns the I/O, after the completion has actually been delivered. Roughly:

  • use CancelIoEx(mDirectoryHandle, &mOverlapped) so cancellation works cross-thread at all;
  • have unsubscribe() queue the teardown to mThread (it already uses QueueUserAPC for subscribe) rather than destroying inline, and keep the Subscription alive — e.g. hold a shared_ptr self-reference — until the completion routine has run once with ERROR_OPERATION_ABORTED, releasing it there.

That also closes the smaller sibling race: the QueueUserAPC in subscribe() passes sub.get(), so a subscribe immediately followed by an unsubscribe can free the Subscription before that APC ever runs.

Happy to test a patch against the workload that produced this.

Relationship to #259

Different defect, same lifetime-management area, and I think they are independent: #259 is the unsynchronized global getSharedBackends() registry, observed on macOS/FSEvents and explicitly not reproducible by its author. This one is Windows-only, is a plain single-object use-after-free rather than a container race, and reproduces on demand. Fixing #259 would not address it.

Workaround

For anyone hitting this before a fix lands: don't unsubscribe. Holding subscriptions for the process lifetime removes the trigger entirely — the crash is only ever on the teardown path.

Activity

  1. matz3 commented on Aug 26, 2026

    @matz3

    @Manuel05UX02 thank you for reporting this and providing a way to reproduce. We were hitting the same issue occasionally in our CI, where the test teardown failed.

    I would also volunteer to test a fix.

  2. Blind-Striker commented on Sep 18, 2026

    @Blind-Striker

    Independent reproduction at 2.5.1, without page heap, under both Bun and Node — and a real host process that dies from it.

    We hit this from a test suite that starts opencode servers: they watch skill directories natively through @parcel/watcher and release and re-create those subscriptions on every configuration refresh. An unrelated tool writing ~200 files into one watched directory while that churn was going on killed every server with a segmentation fault inside watcher.node.

    Standalone reproducer — only @parcel/watcher 2.5.1, one directory, one lane, no directory deletion: write a file, await subscription.unsubscribe(), subscribe again, repeat.

    import { createRequire } from 'node:module';
    import { mkdirSync, writeFileSync } from 'node:fs';
    import { join, resolve } from 'node:path';
    
    const require = createRequire(import.meta.url);
    const watcher = require('@parcel/watcher');
    const rounds = Number(process.argv[2] ?? 5000);
    const lanes = Number(process.argv[3] ?? 1);
    const mode = process.argv[4] ?? 'serial';
    const writeEvents = process.argv[5] !== 'no-write';
    const backend = process.platform === 'win32' ? 'windows' : 'inotify';
    const run = resolve('runs', `${process.pid}`);
    const directories = Array.from({ length: lanes }, (_, index) => join(run, `${index}`));
    for (const directory of directories) mkdirSync(directory, { recursive: true });
    const callback = error => { if (error) throw error; };
    const subscribe = directory => watcher.subscribe(directory, callback, { backend, ignore: [] });
    console.log(JSON.stringify({ node: process.versions.node, bun: process.versions.bun, package: require('@parcel/watcher/package.json').version, backend, rounds, lanes, mode, writeEvents }));
    let subscriptions = await Promise.all(directories.map(subscribe));
    for (let round = 0; round < rounds; round++) {
      if (writeEvents) for (const directory of directories) writeFileSync(join(directory, 'tick.txt'), String(round));
      const releasing = Promise.all(subscriptions.map(subscription => subscription.unsubscribe()));
      if (mode === 'serial') await releasing;
      const acquiring = Promise.all(directories.map(subscribe));
      [subscriptions] = await Promise.all([acquiring, releasing]);
      if (round % 100 === 0) console.log(`round=${round}`);
    }
    await Promise.all(subscriptions.map(subscription => subscription.unsubscribe()));
    console.log(`PASS rounds=${rounds} lanes=${lanes} mode=${mode}`);
    npm ci --ignore-scripts        # package.json pins "@parcel/watcher": "2.5.1"
    bun repro.mjs 10000 1 serial write
    node repro.mjs 1000 1 serial write

    Windows 11 x64 build 26200, windows backend, native module SHA-256 a8199cf7b6c5102267a5889f80321a4cc92e631d7504c474d32a35b4d440f315:

    • Bun 1.4.2: 4 native segmentation faults in 5 runs of 10,000 cycles; the no-write control passed 5/5.
    • Node 24.13.0: 1 native access violation in 3 runs of 1,000 cycles; the no-write control passed 3/3.
    • A fresh, independently installed copy of the package reproduced the Bun crash in 6.9 s.
    • Ubuntu 22.04 under WSL2 on a native Linux filesystem (inotify backend), same versions: 20/20 runs passed (Bun/Node × write/no-write × 5).

    The process exit code is the outcome to watch: a final PASS line does not rule out a native failure during shutdown. It is a race, so independent runs matter more than a single long one.

    Awaiting unsubscribe() before subscribing again still crashes, so no JavaScript-level overlap is needed — consistent with the pending ReadDirectoryChangesW completion outliving its Subscription as described above, and with CancelIo cancelling only the calling thread's I/O. I have not symbolicated the faulting instruction, so the mechanism attribution is this issue's; the reproduction is ours.

    Happy to run a candidate fix against this reproducer.

  3. lokicik commented on Nov 2, 2026

    @lokicik

    I opened #277 with a fix along the lines suggested here. The subscription keeps itself alive while a read is pending, and unsubscribe stops it in an APC on the backend thread, so CancelIo runs on the thread that issued the read. It also adds a regression test that crashes the stock build in 9 of 10 runs on my machine.

    @Manuel05UX02 @matz3, since you offered to test: it would help to know whether it holds up against your workloads.

  4. jgs-jeeves commented on Nov 5, 2026

    @jgs-jeeves

    Another independent reproduction at v2.6.0, stock build without page heap, on Windows Server 2025 (build 26100), x64, Node 24.22.2. I'm adding it in case the numbers help with #277.

    Setup: each subscription watched a temp tree of 500 directories while a separate process wrote about 50 files a second into it. We drove the teardown three ways, each in its own run:

    Path What it does Crashed after
    unsubscribe + subscribe again on the same root error recovery 181 cycles (6.4 min)
    unsubscribe, then a fresh subscribe shutdown + start 181 cycles (3.4 min)
    unsubscribe inside a full app rebuild config reload 240 cycles (4.4 min)

    Each run exited with 0xC0000005. Handles stayed flat, and the crash never reached Windows Error Reporting (no event 1000, no LocalDumps dump). With unrelated load on the machine it came within about 11 cycles. In an earlier trial, an idle-ish machine took over 1,750 cycles.

    Dump (captured with procdump -ma -e 1 -f C0000005), matching the analysis in #262:

    • Faulting thread: a thread started by watcher.node, not a libuv worker. Its stack runs kernel32!BaseThreadInitThunk → watcher.node+0x3670f → … → KERNELBASE (alertable wait) → ntdll APC dispatch → watcher.node. So it fits the ReadDirectoryChangesW completion routine running on the backend thread.
    • Fault: watcher.node+0xefb1, a read at 0x48, so a member access through a pointer that has already been cleared or freed.

    One more observation: watching without ever unsubscribing showed no problem over 160 minutes on six real directory trees under live writes. As a workaround, our app now never calls unsubscribe on Windows; it restarts the process instead.

    We'd be glad to run the same soak against a build of #277 if a binary or prerelease becomes available.

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

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions