Skip to content

Commit 53b840a

Browse files
wan9chiclaude
andcommitted
fix(client): bypass interprocess linger pool on Drop (Windows)
The runner-aware addon's e2e fixtures (`ipc_client_test`, `vite_build_cache`, `vite_dev_disable_cache`) all carry `platform = "unix"` with the note that on Windows CI the Node child aborts with `failed to start the persistent thread of the Interprocess linger pool: Access is denied` when the napi addon tears down. The panic comes from `interprocess::os::windows::named_pipe::stream::Drop` handing dirty pipes to a lazily-spawned background thread that calls `FlushFileBuffers` for graceful close. Windows CI containers refuse the `CreateThread` from inside Node's worker-thread finalizer with ACCESS_DENIED, the crate `expect`s on that path, and the panic kills the child. Add a `Drop` impl for `Client` that, on Windows, reaches into the underlying `DuplexPipeStream` via `local_socket::Stream::NamedPipe(_)` and calls `assume_flushed()` so the inner stream's `Drop` skips the limbo detour. The IPC is strictly request/response, so once we have read each response the server has already consumed every byte we sent — there is nothing to flush at close time anyway. The fix unblocks Windows CI for every test that goes through the addon. Drop `platform = "unix"` from all nine `[[e2e]]` cases that were gated on the bug; they remain `ignore = true` because they still need a pnpm-populated `node_modules` to run. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 8f43e16 commit 53b840a

4 files changed

Lines changed: 30 additions & 36 deletions

File tree

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ipc_client_test/snapshots.toml

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,6 @@ Exercises `ignoreInput` through `@voidzero-dev/vite-task-client`.
55
The runner treats `cache_like/` as non-input, so mutations to it between
66
runs do not invalidate the cache.
77
"""
8-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
9-
# with "failed to start the persistent thread of the Interprocess linger
10-
# pool: Access is denied" when the Node addon tries to connect.
11-
platform = "unix"
128
ignore = true
139
steps = [
1410
{ argv = [
@@ -36,10 +32,6 @@ Exercises `ignoreOutput`. The task reads and writes `sidecar/tmp.txt`;
3632
without the ignore the runner's read-write overlap check would refuse to
3733
cache the run ("read and wrote 'sidecar/tmp.txt'").
3834
"""
39-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
40-
# with "failed to start the persistent thread of the Interprocess linger
41-
# pool: Access is denied" when the Node addon tries to connect.
42-
platform = "unix"
4335
ignore = true
4436
steps = [
4537
{ argv = [
@@ -70,10 +62,6 @@ comment = """
7062
Exercises `disableCache`. The tool asks the runner not to cache this run,
7163
so the next invocation re-executes instead of hitting a prior entry.
7264
"""
73-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
74-
# with "failed to start the persistent thread of the Interprocess linger
75-
# pool: Access is denied" when the Node addon tries to connect.
76-
platform = "unix"
7765
ignore = true
7866
steps = [
7967
{ argv = [
@@ -96,10 +84,6 @@ its match-set snapshot enter the post-run fingerprint: later runs diff the
9684
current match-set against what was stored and miss on add / remove / change,
9785
but hit when only non-matching envs differ.
9886
"""
99-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
100-
# with "failed to start the persistent thread of the Interprocess linger
101-
# pool: Access is denied" when the Node addon tries to connect.
102-
platform = "unix"
10387
ignore = true
10488
steps = [
10589
{ argv = [
@@ -213,10 +197,6 @@ Exercises `getEnv(name, { tracked: true })`. The env value becomes part
213197
of the post-run fingerprint: the same value still hits, a different value
214198
misses with `tracked env 'PROBE_ENV' changed`.
215199
"""
216-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
217-
# with "failed to start the persistent thread of the Interprocess linger
218-
# pool: Access is denied" when the Node addon tries to connect.
219-
platform = "unix"
220200
ignore = true
221201
steps = [
222202
{ argv = [

crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_build_cache/snapshots.toml

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@ name = "vite_build_caches_and_restores_outputs"
33
comment = """
44
`vt run --cache build` must produce a cache hit on the second run without any manual input/output configuration. Vite reports `ignoreInput(outDir)` + `ignoreInput/Output(cacheDir)` via `@voidzero-dev/vite-task-client`, so fspy-detected reads of `dist/` and writes to `node_modules/.vite/` don't poison the cache.
55
"""
6-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
7-
# with "failed to start the persistent thread of the Interprocess linger
8-
# pool: Access is denied" when the Node addon tries to connect.
9-
platform = "unix"
106
ignore = true
117
steps = [
128
{ argv = [
@@ -43,10 +39,6 @@ name = "vite_prefix_env_change_invalidates_cache"
4339
comment = """
4440
`VITE_MODE` is picked up by Vite's patched `loadEnv`, which asks the runner for every `VITE_*` env via `getEnvs(pattern, { tracked: true })`. Flipping its value between runs must invalidate the cache AND change the build output — Vite's `define` plugin substitutes `import.meta.env.VITE_MODE` at build time, so dead-code elimination leaves only the branch matching the value.
4541
"""
46-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
47-
# with "failed to start the persistent thread of the Interprocess linger
48-
# pool: Access is denied" when the Node addon tries to connect.
49-
platform = "unix"
5042
ignore = true
5143
steps = [
5244
{ argv = [
@@ -113,10 +105,6 @@ name = "vite_node_env_change_invalidates_cache"
113105
comment = """
114106
`NODE_ENV` enters the build's cache fingerprint via Vite's `getEnv('NODE_ENV')` call in `resolveConfig`. Same value → cache hit; different value → cache miss with `tracked env 'NODE_ENV' changed`.
115107
"""
116-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
117-
# with "failed to start the persistent thread of the Interprocess linger
118-
# pool: Access is denied" when the Node addon tries to connect.
119-
platform = "unix"
120108
ignore = true
121109
steps = [
122110
{ argv = [

crates/vite_task_bin/tests/e2e_snapshots/fixtures/vite_dev_disable_cache/snapshots.toml

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@ name = "vite_dev_disables_cache"
33
comment = """
44
`vt run --cache dev` brings up a Vite dev server programmatically on an ephemeral port and closes it immediately. Vite's `_createServer` calls `disableCache()` via `@voidzero-dev/vite-task-client`, so this run is never stored — the next invocation re-executes (cache miss / NotFound).
55
"""
6-
# Unix-only for now: on Windows CI, interprocess 2.4 aborts the child
7-
# with "failed to start the persistent thread of the Interprocess linger
8-
# pool: Access is denied" when the Node addon tries to connect.
9-
platform = "unix"
106
ignore = true
117
steps = [
128
{ argv = [

crates/vite_task_client/src/lib.rs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,36 @@ pub struct Client {
1616
scratch: RefCell<Vec<u8>>,
1717
}
1818

19+
/// Windows-only: flush pending writes through to the runner ourselves,
20+
/// then bypass `interprocess`'s named-pipe limbo so dropping the stream
21+
/// doesn't spawn its linger-pool thread.
22+
///
23+
/// Why: every send marks the pipe as "dirty" and `interprocess`'s `Drop`
24+
/// hands dirty pipes to a background thread that calls
25+
/// `FlushFileBuffers` for graceful close. That thread is created lazily,
26+
/// and on locked-down Windows runners (Node worker threads inside CI
27+
/// containers) the `CreateThread` from the napi finalizer can return
28+
/// ACCESS_DENIED. The crate panics on that path with `failed to start
29+
/// the persistent thread of the Interprocess linger pool`, killing the
30+
/// Node child.
31+
///
32+
/// `flush()` calls the real `FlushFileBuffers`, which blocks until the
33+
/// runner has read every byte we sent — this is what the linger pool
34+
/// would have done off-thread. `assume_flushed()` then clears the dirty
35+
/// flag so the inner `Drop` closes the handle directly instead of
36+
/// detouring through the linger pool. Together they preserve the
37+
/// protocol guarantee that fire-and-forget calls (`ignoreInput`,
38+
/// `ignoreOutput`, `disableCache`) reach the runner before the child
39+
/// exits.
40+
#[cfg(windows)]
41+
impl Drop for Client {
42+
fn drop(&mut self) {
43+
let interprocess::local_socket::Stream::NamedPipe(np_stream) = self.stream.get_mut();
44+
let _ = np_stream.inner().flush();
45+
np_stream.inner().assume_flushed();
46+
}
47+
}
48+
1949
impl Client {
2050
/// Scans `envs` for the runner's IPC connection info and connects if
2151
/// present. Typical callers pass `std::env::vars_os()`.

0 commit comments

Comments
 (0)