Repository navigation
Description
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:
CancelIocancels nothing here. It only cancels operations issued by the calling thread. The read was issued onmThread; this call is on a libuv worker, so the pending read survives.CancelIoExis the cross-thread form.- Even a successful cancel still completes. Cancellation delivers the completion with
ERROR_OPERATION_ABORTEDrather than suppressing it — and the delivery path dereferences theOVERLAPPEDbefore any user code gets to checkerrorCode.
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 tomThread(it already usesQueueUserAPCforsubscribe) rather than destroying inline, and keep theSubscriptionalive — e.g. hold ashared_ptrself-reference — until the completion routine has run once withERROR_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
matz3 commentedon Aug 26, 2026
@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.
Blind-Striker commentedon Sep 18, 2026
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
opencodeservers: they watch skill directories natively through@parcel/watcherand 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 insidewatcher.node.Standalone reproducer — only
@parcel/watcher2.5.1, one directory, one lane, no directory deletion: write a file,await subscription.unsubscribe(), subscribe again, repeat.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 writeWindows 11 x64 build 26200,
windowsbackend, native module SHA-256a8199cf7b6c5102267a5889f80321a4cc92e631d7504c474d32a35b4d440f315:no-writecontrol passed 5/5.no-writecontrol passed 3/3.inotifybackend), same versions: 20/20 runs passed (Bun/Node × write/no-write × 5).The process exit code is the outcome to watch: a final
PASSline 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 pendingReadDirectoryChangesWcompletion outliving itsSubscriptionas described above, and withCancelIocancelling 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.
Stop a Windows quit crash from hiding restored terminal tabs (#4996)
Stop a Windows quit crash from hiding restored terminal tabs (#4996)
fix(engine): cancel file watchers only once they have really gone quiet
fix(engine): cancel file watchers only once they have really gone quiet
lokicik commentedon Nov 2, 2026
I opened #277 with a fix along the lines suggested here. The subscription keeps itself alive while a read is pending, and
unsubscribestops it in an APC on the backend thread, soCancelIoruns 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.
jgs-jeeves commentedon Nov 5, 2026
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:
Each run exited with
0xC0000005. Handles stayed flat, and the crash never reached Windows Error Reporting (no event 1000, noLocalDumpsdump). 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:watcher.node, not a libuv worker. Its stack runskernel32!BaseThreadInitThunk→watcher.node+0x3670f→ … →KERNELBASE(alertable wait) →ntdllAPC dispatch →watcher.node. So it fits theReadDirectoryChangesWcompletion routine running on the backend thread.watcher.node+0xefb1, a read at0x48, 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
unsubscribeon 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.