From 3b9e5d541ef35f32030b2c1a65fad1a681550b51 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 23 Jul 2026 21:49:17 +0800 Subject: [PATCH 1/9] fix(ci): force LF checkout so the Windows formatting check passes The `test (windows-latest)` job failed `vp check` with ~1028 files reported as mis-formatted. The repo had no `.gitattributes`, so the Windows runner (git's default core.autocrlf=true) checked out every text file with CRLF while oxfmt enforces LF. Ubuntu kept LF and passed, so the failure was platform-only. Add a root `.gitattributes` pinning text files to LF on every platform (`* text=auto eol=lf`), plus explicit `binary` markers for image/font/archive types so those are never line-ending converted. This is checkout-only: every committed blob is already LF, so `git add --renormalize .` rewrites nothing and `vp check` stays green locally (1037 files formatted, 0 lint errors). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .gitattributes | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..3bac9010f --- /dev/null +++ b/.gitattributes @@ -0,0 +1,27 @@ +# Enforce LF line endings for every text file on all platforms. +# +# Without this, the Windows CI runner (git's default `core.autocrlf=true`) checks out +# text files with CRLF, and oxfmt — which `vp check` / `vp fmt` run and which enforces +# LF — then flags every file as mis-formatted (the `test (windows-latest)` job). Every +# committed blob is already LF, so this only changes checkout behavior on Windows; it +# triggers no renormalization of existing files. +* text=auto eol=lf + +# Binary assets — never apply any line-ending conversion. +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.avif binary +*.woff binary +*.woff2 binary +*.ttf binary +*.otf binary +*.eot binary +*.pdf binary +*.wasm binary +*.mp4 binary +*.zip binary +*.gz binary From 3a38d7a6b3b5371ebd85f29dd6f1e64839f02358 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 23 Jul 2026 22:33:38 +0800 Subject: [PATCH 2/9] fix(provision): correct Windows path identity, quarantine gate, and child termination MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixing the Windows formatting gate let the suite run on Windows for the first time, which surfaced 42 failures in code that had never executed there. Product bugs (all changes are win32-gated; POSIX behavior is byte-identical): - canonicalizeConfigPath returned TWO spellings of the same file. Every path in this module comes from pathe (POSIX-normalized), but realpathSync is node:fs and returns a NATIVE path, so an existing config canonicalized to `D:\a\...` while a not-yet-created one canonicalized to `D:/a/...`. That split the lock identity, the wrangler `-c` argv, and the paths printed in errors, defeating the "ONE identity" contract the function exists to provide. - The quarantine gate could fail OPEN on Windows. Its probe target was `-pgid` with no platform guard, but `process.kill(-n, 0)` on win32 resolves to OpenProcess((DWORD)-n), which fails and surfaces as ESRCH — reporting any recorded owner "confirmed dead" and clearing a LIVE quarantine. The identical probe is already guarded in the retention watchdog; this mirrors it. - A child that had already exited could be QUARANTINED. Windows bypassed the killProcessGroup seam for child.kill(), whose `false` conflates ESRCH with EPERM, and exitCode/signalCode are set asynchronously so a just-exited child still reads as running — reported as a failed signal delivery. Windows now routes through the same seam, where process.kill throws ESRCH and is classified "already gone". - Termination confirmation was assumed rather than earned on Windows: probeGroupGone returned a constant true. It now performs a real kill(pid, 0) liveness probe; descendants remain covered by libuv's global Job Object. Test-only fixes (product verified correct): - The env-schema probe fixtures spliced an absolute path in as an ESM specifier; on Windows `D:/...` parses as protocol `d:` and Node rejects it with ERR_UNSUPPORTED_ESM_URL_SCHEME, so the child never evaluated and every result came back `schema-error`. Now built with pathToFileURL, as the product does. - The CF plugin writes its deploy pointer with node:path.relative, so compare the normalized form instead of pinning a POSIX literal. - writeQuarantineSidecar records pgid null on win32 by design. - Signal-delivery cases are skipped on Windows, which cannot deliver a signal to another process (libuv maps them to TerminateProcess). This includes one case that "passed" there only because the kernel Job Object reaped the child. Verified: packages/void full suite 3673 pass, typecheck and vp check clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- packages/void/src/cli/provision.ts | 65 +++++++++++++++++-- .../cloudflare-deploy-build.test.ts | 8 ++- .../void/test/unit/cloudflare-deploy.test.ts | 16 ++++- packages/void/test/unit/provision.test.ts | 26 ++++++-- 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index 23d02518b..e658805eb 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -17,7 +17,7 @@ import { import { createRequire } from 'node:module'; import { types as utilTypes } from 'node:util'; import { parse, type ParseError } from 'jsonc-parser'; -import { basename, dirname, join, resolve } from 'pathe'; +import { basename, dirname, join, normalize, resolve } from 'pathe'; import lockfile from 'proper-lockfile'; import { voidLog, voidWarn } from '../log.ts'; import { scanWranglerDotenvKeys } from '../shared/dotenv.ts'; @@ -1424,6 +1424,23 @@ export function createDefaultRunWrangler( killProcessGroup(-child.pid, signal); // negative pid → the whole group return 'signaled'; } + if (isWindows && typeof child.pid === 'number') { + // Windows has NO process groups, so the signal target is the child ITSELF. + // Consult Node's own bookkeeping FIRST: once the exit has been observed the OS + // may already have recycled the pid, and signaling it could hit an unrelated + // process. + if (child.exitCode !== null || child.signalCode !== null) { + return 'gone'; + } + // Routed through the SAME seam POSIX uses, because `process.kill` THROWS ESRCH + // (classified "already gone" below), whereas `child.kill()` collapses ESRCH and + // EPERM into one silent `false`. Node sets `exitCode`/`signalCode` asynchronously + // in its `exit` handler, so a child that died a moment ago still reads as + // "running" here — and the ambiguous `false` would then be reported as a FAILED + // delivery, quarantining the lock over a child that had simply already exited. + killProcessGroup(child.pid, signal); + return 'signaled'; + } if (child.kill(signal)) { return 'signaled'; } @@ -1447,11 +1464,31 @@ export function createDefaultRunWrangler( if (groupGone) { return true; } - if (isWindows || typeof child.pid !== 'number') { - // No process-group concept — `close` is the only (and sufficient) confirmation. + if (typeof child.pid !== 'number') { groupGone = true; return true; } + if (isWindows) { + // No process GROUP to probe, but the child ITSELF is still confirmable: libuv + // implements `kill(pid, 0)` on Windows as a real liveness check + // (GetExitCodeProcess + WaitForSingleObject). Descendants are covered separately — + // libuv assigns every non-`detached` child to a global Job Object with + // KILL_ON_JOB_CLOSE, so they die with this process. Prefer Node's own bookkeeping + // (immune to pid reuse) and fall back to the signal-0 probe while the exit has not + // been observed yet — so confirmation stays EARNED rather than assumed. + if (child.exitCode !== null || child.signalCode !== null) { + groupGone = true; + return true; + } + try { + process.kill(child.pid, 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + groupGone = true; + } + } + return groupGone; + } try { process.kill(-child.pid, 0); } catch (error) { @@ -2259,7 +2296,11 @@ function assertQuarantineClearedOrClear(lockTarget: string, onBeforeClear?: () = typeof recorded?.quarantinedAt === 'string' ? recorded.quarantinedAt : undefined; // Prefer the recorded GROUP (negative-pid probe); fall back to the plain pid where no // group was recorded (Windows). No probe target at all → cannot verify → fail closed. - const probeTarget = pgid !== undefined ? -pgid : pid; + // A NEGATIVE target is POSIX-ONLY: on win32 `process.kill(-n, 0)` resolves to + // OpenProcess((DWORD)-n), which fails with ERROR_INVALID_PARAMETER and surfaces as ESRCH — + // i.e. it would report ANY recorded owner "confirmed dead" and clear a LIVE quarantine, + // inverting this gate from fail-closed to fail-open. Mirrors the watchdog's guard above. + const probeTarget = pgid !== undefined && process.platform !== 'win32' ? -pgid : (pid ?? pgid); let confirmedDead = false; if (probeTarget !== undefined) { try { @@ -2354,6 +2395,20 @@ function loadProperLockfileInternals(): ProperLockfileInternals | undefined { } } +/** + * `realpathSync` is `node:fs`, so it returns a NATIVE path — backslashes on Windows — + * while every other path in this module comes from `pathe` and is POSIX-normalized, + * including the not-yet-created branch below (whose `join()` already normalizes). Without + * this, `canonicalizeConfigPath` hands back TWO different spellings of the same file + * depending only on whether it happens to exist yet, defeating the "ONE identity" contract + * the function exists to provide — and splitting the lock identity, the wrangler `-c` argv, + * and the paths printed in error messages. No-op off Windows, where `\` is a legal filename + * character that must survive byte-for-byte. + */ +function toPosixConfigPath(nativePath: string): string { + return process.platform === 'win32' ? normalize(nativePath) : nativePath; +} + /** * Canonicalize an already-absolute config path via `realpath` so the lock identity * and every read/write/`-c` key on the REAL file, not a lexical symlink alias. When the @@ -2366,7 +2421,7 @@ function loadProperLockfileInternals(): ProperLockfileInternals | undefined { */ function canonicalizeConfigPath(absoluteConfigPath: string, depth = 0): string { try { - return realpathSync(absoluteConfigPath); + return toPosixConfigPath(realpathSync(absoluteConfigPath)); } catch (error) { // ONLY ENOENT (a path component does not exist yet) is the benign "not created yet" case // we handle below. Any OTHER realpath error (EACCES, ELOOP for a real symlink cycle, diff --git a/packages/void/test/integration/cloudflare-deploy-build.test.ts b/packages/void/test/integration/cloudflare-deploy-build.test.ts index 2a017945e..d334ff98e 100644 --- a/packages/void/test/integration/cloudflare-deploy-build.test.ts +++ b/packages/void/test/integration/cloudflare-deploy-build.test.ts @@ -1,6 +1,6 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import spawn from 'cross-spawn'; -import { join } from 'pathe'; +import { join, normalize } from 'pathe'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { computeDesiredCloudflareResources, @@ -167,7 +167,11 @@ describe('playground/kitchen-sink --backend cloudflare real-build deploy guard', const pointer = JSON.parse( readFileSync(join(PG, '.wrangler', 'deploy', 'config.json'), 'utf-8'), ) as { configPath?: string }; - expect(pointer.configPath).toBe('../../dist/ssr/wrangler.json'); + // The CF plugin writes this with node:path.relative, so the separator is PLATFORM-NATIVE + // (`..\..\dist\ssr\wrangler.json` on Windows). The guard that consumes it resolves through + // pathe, which normalizes backslashes, so compare the normalized form rather than pinning + // a POSIX literal that only holds on one platform. + expect(normalize(pointer.configPath ?? '')).toBe('../../dist/ssr/wrangler.json'); }); it('merges the pinned account, worker name, and real binding ids into the emitted artifact', () => { diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index b3dd56f5b..b0578b2db 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { pathToFileURL } from 'node:url'; import { log } from '@clack/prompts'; import { dirname, join } from 'pathe'; import { afterEach, describe, expect, it } from 'vite-plus/test'; @@ -103,9 +104,18 @@ function emittedVars(vars: Record = {}): EmittedWorkerVarsResul return { ok: true, vars }; } -/** Absolute src paths for runtime imports inside spawned-child env.ts fixtures. */ -const srcEnvPublic = join(import.meta.dirname, '..', '..', 'src', 'runtime', 'env-public.ts'); -const srcEnvHelpers = join(import.meta.dirname, '..', '..', 'src', 'runtime', 'env-helpers.ts'); +/** + * `file://` URLs for the runtime imports inside spawned-child env.ts fixtures. These are ESM + * SPECIFIERS, not filesystem paths: an absolute Windows path (`D:/…`, as `pathe` spells it) + * parses as a URL whose protocol is `d:`, so Node rejects it with + * ERR_UNSUPPORTED_ESM_URL_SCHEME and the child's env.ts never evaluates — the probe then + * reports `schema-error` for reasons that have nothing to do with the schema. + * `pathToFileURL` is the same form `loadUserEnvFile` uses for real user env files. + */ +const srcFileUrl = (...segments: Array): string => + pathToFileURL(join(import.meta.dirname, '..', '..', 'src', ...segments)).href; +const srcEnvPublic = srcFileUrl('runtime', 'env-public.ts'); +const srcEnvHelpers = srcFileUrl('runtime', 'env-helpers.ts'); /** * Wrangler's FULL credential env-var set, canonical names + deprecated `CF_*` aliases. diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index f58cfea16..5c34c5997 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -5705,7 +5705,9 @@ describe('acquireProvisionLock — quarantine sidecar gate', () => { const recorded = JSON.parse(readFileSync(sidecar, 'utf-8')) as Record; expect(recorded).toMatchObject({ pid: child.pid, - pgid: child.pid, + // Windows has no process groups, so `writeQuarantineSidecar` records `null` there by + // design and the gate falls back to probing the plain pid. + pgid: process.platform === 'win32' ? null : child.pid, reason: 'test quarantine (child unconfirmed)', }); expect(typeof recorded.quarantinedAt).toBe('string'); @@ -6442,6 +6444,16 @@ describe('acquireProvisionLock — recovery decisions are serialized + generatio }); }); +/** + * Windows cannot DELIVER a signal to another process. libuv maps SIGTERM/SIGINT/SIGKILL to + * `TerminateProcess`, which destroys the target immediately WITHOUT running any handler in it, + * and a synthetic Ctrl-C would need `GenerateConsoleCtrlEvent` (unreachable from Node). The + * cases carrying this note drive a real child `node` parent with `kill('SIGTERM')` and then + * assert on that parent's signal-teardown handler — work that only happens under POSIX signal + * semantics. + */ +const WINDOWS_NO_SIGNALS = 'signal delivery to another process is POSIX-only (see note above)'; + describe('createDefaultRunWrangler — signal-path kill failure quarantines the lock (child-process)', () => { const SIGNAL_KILL_TMP = join(import.meta.dirname, '.tmp-signal-kill'); beforeEach(() => { @@ -6465,7 +6477,8 @@ describe('createDefaultRunWrangler — signal-path kill failure quarantines the return false; } - it('SIGTERM + an injected kill FAILURE: the parent dies with mutex + sidecar RETAINED; the gate blocks until the child dies', async () => { + it('SIGTERM + an injected kill FAILURE: the parent dies with mutex + sidecar RETAINED; the gate blocks until the child dies', async (ctx) => { + ctx.skip(process.platform === 'win32', WINDOWS_NO_SIGNALS); // Pre-fix, the signal-forwarding path re-raised IMMEDIATELY on a non-ESRCH kill // failure — the parent died, proper-lockfile's exit hook removed the still-tracked // mutex, and the live wrangler child kept creating resources with NO lock on disk. @@ -6626,7 +6639,8 @@ describe('createDefaultRunWrangler — ONE signal coordinator: repeated signals expect(result.signal).toBeNull(); }, 15_000); - it('one SIGTERM (+ a repeat mid-teardown) is coalesced: the parent re-raises only after the group is dead', async () => { + it('one SIGTERM (+ a repeat mid-teardown) is coalesced: the parent re-raises only after the group is dead', async (ctx) => { + ctx.skip(process.platform === 'win32', WINDOWS_NO_SIGNALS); // Previously, runners installed per-runner listeners that detached themselves at // signal entry — a REPEATED signal during the bounded teardown took Node's default // action, killing the parent with the child alive, every tracked mutex swept, and @@ -6703,7 +6717,8 @@ describe('createDefaultRunWrangler — ONE signal coordinator: repeated signals } }, 20_000); - it('a spawn attempted AFTER teardown began is rejected fast (no child spawned, teardown unaffected)', async () => { + it('a spawn attempted AFTER teardown began is rejected fast (no child spawned, teardown unaffected)', async (ctx) => { + ctx.skip(process.platform === 'win32', WINDOWS_NO_SIGNALS); const pidFile = join(COORDINATOR_TMP, 'c.pid'); const childScript = join(COORDINATOR_TMP, 'slow-exit-child2.cjs'); writeFileSync( @@ -6771,7 +6786,8 @@ describe('createDefaultRunWrangler — ONE signal coordinator: repeated signals } }, 20_000); - it('the coordinator-WIDE deadline bounds an unconfirmable teardown (quarantine + re-raise, never a wedge)', async () => { + it('the coordinator-WIDE deadline bounds an unconfirmable teardown (quarantine + re-raise, never a wedge)', async (ctx) => { + ctx.skip(process.platform === 'win32', WINDOWS_NO_SIGNALS); // The runner's own confirmation window is 60s here and the child never dies (the // injected group-killer claims success but kills nothing) — pre-fix `pending` // stayed nonzero for the full 60s and the fatal signal was not re-raised. The From 13498c67962378a1b79d405848548cc102977bbb Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 23 Jul 2026 22:53:34 +0800 Subject: [PATCH 3/9] fix(test): retry the kill-classify temp removal for async Windows handle release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two remaining Windows failures were not assertion failures — both test bodies passed and the afterEach teardown threw: EPERM, Permission denied: \\?\D:\a\...\packages\void\test\unit\.tmp-kill-classify These cases deliberately keep a real child process alive (that is the scenario: a kill that fails, and a termination that cannot be confirmed), and the child's cwd is that temp dir. Both already kill the child and assert waitForDead before finishing, so the process is genuinely gone — but Windows releases a terminated process's handles, including the directory handle for its cwd, asynchronously, so the recursive remove immediately afterwards can still fail with EPERM. Use Node's documented remedy for exactly this: rm's maxRetries/retryDelay, which retry EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM on a linear backoff and are honored only when recursive is set. Inert on POSIX, which unlinks a directory regardless of open handles. Verified: provision suite 248 pass, typecheck and vp check clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- packages/void/test/unit/provision.test.ts | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index 5c34c5997..4a0fe41bc 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -7173,12 +7173,24 @@ describe('provisionCloudflareResources — Hyperdrive lister deadline', () => { describe('createDefaultRunWrangler — confirmed child termination + kill classification', () => { const KILL_CLASSIFY_TMP = join(import.meta.dirname, '.tmp-kill-classify'); + /** + * These cases deliberately keep a REAL child alive (that is the scenario under test) and kill + * it at the end, and the child's CWD is this temp dir. Windows releases a terminated process's + * handles — including the directory handle for its CWD — ASYNCHRONOUSLY, so a recursive remove + * issued right after the pid is confirmed gone can still fail with EPERM. `maxRetries` is + * Node's documented remedy: it retries EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM on a linear + * backoff, and is honored only when `recursive` is set. Inert on POSIX, which unlinks a + * directory regardless of any open handle. + */ + const rmTmp = (): void => { + rmSync(KILL_CLASSIFY_TMP, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + }; beforeEach(() => { - rmSync(KILL_CLASSIFY_TMP, { recursive: true, force: true }); + rmTmp(); mkdirSync(KILL_CLASSIFY_TMP, { recursive: true }); }); afterEach(() => { - rmSync(KILL_CLASSIFY_TMP, { recursive: true, force: true }); + rmTmp(); }); async function waitForDead(pid: number, timeoutMs: number): Promise { From 62008fe06e7cc92446abf7a3e266fa8a5e1efdec Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 23 Jul 2026 23:14:23 +0800 Subject: [PATCH 4/9] fix(test): reap surviving children before removing the kill-classify temp dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous retry-only attempt did not clear the Windows EPERM: ~5.5s of backoff still failed, so the directory was held persistently rather than being released late. Every child this block spawns runs with cwd = the temp dir, and Windows refuses to remove a directory that is a live process's cwd, so teardown cannot assume each case reached its own cleanup — a case that fails before its cleanup strands a child that then blocks every later removal. Reap anything still recorded in a *.pid file first, then remove. If removal still fails, report which children were alive and what entries remain instead of a bare errno, which is undebuggable without a Windows machine. Also document, at the reviewed line, why the Windows pid-based signal has no pid-reuse hazard: libuv closes its process handle in the same straight-line frame that invokes exit_cb (Node's onexit, which sets exitCode/signalCode), and Windows pins a pid while any handle to the process object is open — so a pid observed with a null exit code cannot have been reassigned. child.kill() is no safer in that instant, since uv_process_kill short-circuits to ESRCH and ChildProcess.kill reports it as the same ambiguous false that caused the original spurious quarantine. Verified: provision suite 248 pass, typecheck and vp check clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- packages/void/src/cli/provision.ts | 15 +++++-- packages/void/test/unit/provision.test.ts | 50 +++++++++++++++++++---- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/packages/void/src/cli/provision.ts b/packages/void/src/cli/provision.ts index e658805eb..fd577f151 100644 --- a/packages/void/src/cli/provision.ts +++ b/packages/void/src/cli/provision.ts @@ -1426,9 +1426,18 @@ export function createDefaultRunWrangler( } if (isWindows && typeof child.pid === 'number') { // Windows has NO process groups, so the signal target is the child ITSELF. - // Consult Node's own bookkeeping FIRST: once the exit has been observed the OS - // may already have recycled the pid, and signaling it could hit an unrelated - // process. + // Consult Node's own bookkeeping FIRST: once the exit has been observed there is + // nothing left to signal. This ORDERING is also what makes the pid-based call below + // free of any pid-reuse hazard. Windows destroys a pid only when the process OBJECT + // is destroyed, which requires the process to have exited AND every handle to it to + // be closed; libuv closes its handle (`CloseHandle(handle->process_handle)` in + // `uv__process_proc_exit`, win/process.c) in the SAME straight-line frame that then + // invokes `exit_cb` — Node's `onexit`, whose first statements set + // `exitCode`/`signalCode`. No JavaScript runs in between, so a pid observed here + // with a null exit code is still pinned by libuv's open handle and cannot have been + // reassigned. (`child.kill()` is no safer in that instant: libuv sets + // UV_HANDLE_ESRCH on the very next line and `uv_process_kill` then short-circuits to + // ESRCH, which `ChildProcess.kill` reports as the same ambiguous `false`.) if (child.exitCode !== null || child.signalCode !== null) { return 'gone'; } diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index 4a0fe41bc..5c6eb4362 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -7174,16 +7174,50 @@ describe('provisionCloudflareResources — Hyperdrive lister deadline', () => { describe('createDefaultRunWrangler — confirmed child termination + kill classification', () => { const KILL_CLASSIFY_TMP = join(import.meta.dirname, '.tmp-kill-classify'); /** - * These cases deliberately keep a REAL child alive (that is the scenario under test) and kill - * it at the end, and the child's CWD is this temp dir. Windows releases a terminated process's - * handles — including the directory handle for its CWD — ASYNCHRONOUSLY, so a recursive remove - * issued right after the pid is confirmed gone can still fail with EPERM. `maxRetries` is - * Node's documented remedy: it retries EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM on a linear - * backoff, and is honored only when `recursive` is set. Inert on POSIX, which unlinks a - * directory regardless of any open handle. + * Every child spawned here runs with `cwd` = KILL_CLASSIFY_TMP, and Windows refuses to remove + * a directory that is a live process's CWD — so teardown must not assume each case reached its + * own cleanup. Reap anything still recorded in a `*.pid` file first, then remove with + * `maxRetries`, which is Node's documented remedy for EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM and + * covers the window where a terminated process's handles (its CWD among them) are still being + * released. `waitForDead` only proves the exit CODE is set; rundown finishes slightly later. + * If removal still fails, report what was alive and what remains rather than a bare EPERM — + * a bare errno here is undebuggable without a Windows machine. Inert on POSIX, which unlinks + * a directory regardless of any open handle. */ const rmTmp = (): void => { - rmSync(KILL_CLASSIFY_TMP, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + const reaped: Array = []; + if (existsSync(KILL_CLASSIFY_TMP)) { + for (const entry of readdirSync(KILL_CLASSIFY_TMP)) { + if (!entry.endsWith('.pid')) { + continue; + } + const pid = Number(readFileSync(join(KILL_CLASSIFY_TMP, entry), 'utf-8').trim()); + if (!Number.isInteger(pid) || pid <= 0) { + continue; + } + try { + process.kill(pid, 0); + } catch { + continue; // already gone + } + reaped.push(`${entry}=${pid}`); + try { + process.kill(pid, 'SIGKILL'); + } catch { + // raced with its own exit + } + } + } + try { + rmSync(KILL_CLASSIFY_TMP, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + } catch (error) { + const left = existsSync(KILL_CLASSIFY_TMP) ? readdirSync(KILL_CLASSIFY_TMP) : []; + throw new Error( + `could not remove ${KILL_CLASSIFY_TMP}: ${(error as Error).message}\n` + + ` still-live children reaped at teardown: ${reaped.length > 0 ? reaped.join(', ') : 'none'}\n` + + ` entries remaining: ${left.length > 0 ? left.join(', ') : '(none)'}`, + ); + } }; beforeEach(() => { rmTmp(); From 99475b931184f24599edef3aec2e61577a5d5a71 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 23 Jul 2026 23:23:02 +0800 Subject: [PATCH 5/9] fix(test): isolate kill-classify temp dirs per test and stop killing pids from disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit had teardown read `*.pid` files from a fixed, shared temp directory and SIGKILL whatever pid they named, gated only by a `kill(pid, 0)` probe. That probe only proves some process currently owns the pid — not that it is the process that wrote the file. A crashed or concurrent run leaves a stale pid file behind, the OS reassigns that pid, and teardown would then terminate an unrelated developer or CI process. Removing it. That reaping was also speculative: both affected cases demonstrably kill their own child and assert waitForDead before finishing, so a stranded child was never the confirmed cause of the EPERM. Instead give each test its own directory via mkdtempSync, which removes the shared-state hazard at the source: no stale artifact from another run can ever be read, and one case's stranded child can no longer block later cases. Teardown now kills nothing; it retries the removal and, if that still fails, reports which recorded pids currently resolve to a live process and what entries remain, so the next Windows run identifies the real holder instead of returning a bare errno. Verified: provision suite 248 pass, typecheck and vp check clean, no temp dirs left behind. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- packages/void/test/unit/provision.test.ts | 101 +++++++++++----------- 1 file changed, 51 insertions(+), 50 deletions(-) diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index 5c6eb4362..e5c6afab7 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -2,6 +2,7 @@ import { existsSync, mkdir, mkdirSync, + mkdtempSync, readdirSync, readFileSync, realpath, @@ -7172,56 +7173,56 @@ describe('provisionCloudflareResources — Hyperdrive lister deadline', () => { }); describe('createDefaultRunWrangler — confirmed child termination + kill classification', () => { - const KILL_CLASSIFY_TMP = join(import.meta.dirname, '.tmp-kill-classify'); /** - * Every child spawned here runs with `cwd` = KILL_CLASSIFY_TMP, and Windows refuses to remove - * a directory that is a live process's CWD — so teardown must not assume each case reached its - * own cleanup. Reap anything still recorded in a `*.pid` file first, then remove with - * `maxRetries`, which is Node's documented remedy for EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM and - * covers the window where a terminated process's handles (its CWD among them) are still being - * released. `waitForDead` only proves the exit CODE is set; rundown finishes slightly later. - * If removal still fails, report what was alive and what remains rather than a bare EPERM — - * a bare errno here is undebuggable without a Windows machine. Inert on POSIX, which unlinks - * a directory regardless of any open handle. + * A UNIQUE directory per test. Every child spawned in this block runs with its `cwd` set to + * this directory, and a fixed shared path lets a crashed or concurrent run leave a stale + * `*.pid` file behind whose pid the OS may since have reassigned — so nothing may be inferred + * from, and certainly nothing killed by, a pid found on disk. Per-test isolation also stops + * one case's stranded child from blocking every later case's cleanup. + */ + let killClassifyTmp = ''; + /** + * Windows refuses to remove a directory that is a live process's CWD, and `waitForDead` only + * proves the exit CODE is set — the rundown that releases the CWD handle finishes slightly + * later. `maxRetries` is Node's documented remedy for exactly that: it retries + * EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM on a linear backoff, and is honored only when + * `recursive` is set. Inert on POSIX, which unlinks a directory regardless of open handles. + * + * This deliberately KILLS NOTHING. A pid read from a file is not proof that the process now + * holding that pid is the one that wrote it, so signalling it could terminate an unrelated + * process. If removal still fails, report which recorded pids currently resolve to a live + * process and what entries remain — a bare errno is undebuggable without a Windows machine. */ const rmTmp = (): void => { - const reaped: Array = []; - if (existsSync(KILL_CLASSIFY_TMP)) { - for (const entry of readdirSync(KILL_CLASSIFY_TMP)) { - if (!entry.endsWith('.pid')) { - continue; - } - const pid = Number(readFileSync(join(KILL_CLASSIFY_TMP, entry), 'utf-8').trim()); - if (!Number.isInteger(pid) || pid <= 0) { - continue; - } - try { - process.kill(pid, 0); - } catch { - continue; // already gone - } - reaped.push(`${entry}=${pid}`); - try { - process.kill(pid, 'SIGKILL'); - } catch { - // raced with its own exit - } - } + if (killClassifyTmp === '') { + return; } try { - rmSync(KILL_CLASSIFY_TMP, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + rmSync(killClassifyTmp, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); } catch (error) { - const left = existsSync(KILL_CLASSIFY_TMP) ? readdirSync(KILL_CLASSIFY_TMP) : []; + const left = existsSync(killClassifyTmp) ? readdirSync(killClassifyTmp) : []; + const recorded = left + .filter((entry) => entry.endsWith('.pid')) + .map((entry) => { + const pid = Number(readFileSync(join(killClassifyTmp, entry), 'utf-8').trim()); + let live = false; + try { + process.kill(pid, 0); + live = true; + } catch { + live = false; + } + return `${entry}=${pid}${live ? ' (some process holds that pid)' : ' (gone)'}`; + }); throw new Error( - `could not remove ${KILL_CLASSIFY_TMP}: ${(error as Error).message}\n` + - ` still-live children reaped at teardown: ${reaped.length > 0 ? reaped.join(', ') : 'none'}\n` + + `could not remove ${killClassifyTmp}: ${(error as Error).message}\n` + + ` recorded pids: ${recorded.length > 0 ? recorded.join(', ') : 'none'}\n` + ` entries remaining: ${left.length > 0 ? left.join(', ') : '(none)'}`, ); } }; beforeEach(() => { - rmTmp(); - mkdirSync(KILL_CLASSIFY_TMP, { recursive: true }); + killClassifyTmp = mkdtempSync(join(import.meta.dirname, '.tmp-kill-classify-')); }); afterEach(() => { rmTmp(); @@ -7248,14 +7249,14 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi }); it('a REAL kill failure (EPERM) on timeout QUARANTINES: the runner REJECTS, never settle-and-release', async () => { - const pidFile = join(KILL_CLASSIFY_TMP, 'cli.pid'); - const hungCli = join(KILL_CLASSIFY_TMP, 'hung.cjs'); + const pidFile = join(killClassifyTmp, 'cli.pid'); + const hungCli = join(killClassifyTmp, 'hung.cjs'); writeFileSync( hungCli, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + `setInterval(() => {}, 1 << 30);\n`, ); - const run = createDefaultRunWrangler(KILL_CLASSIFY_TMP, { + const run = createDefaultRunWrangler(killClassifyTmp, { launcher: [process.execPath, hungCli], timeoutMs: 300, // Simulate a kill that FAILS for a non-ESRCH reason (the child is still alive). @@ -7290,14 +7291,14 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi }, 15_000); it('an unconfirmable termination (grace expiry, child still alive) QUARANTINES too', async () => { - const pidFile = join(KILL_CLASSIFY_TMP, 'cli3.pid'); - const hungCli = join(KILL_CLASSIFY_TMP, 'hung3.cjs'); + const pidFile = join(killClassifyTmp, 'cli3.pid'); + const hungCli = join(killClassifyTmp, 'hung3.cjs'); writeFileSync( hungCli, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + `setInterval(() => {}, 1 << 30);\n`, ); - const run = createDefaultRunWrangler(KILL_CLASSIFY_TMP, { + const run = createDefaultRunWrangler(killClassifyTmp, { launcher: [process.execPath, hungCli], timeoutMs: 250, confirmKillGraceMs: 500, @@ -7325,14 +7326,14 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi }, 15_000); it('an ESRCH kill (already gone) is treated as SUCCESS — settles as the timeout hard-failure, no kill-error text', async () => { - const pidFile = join(KILL_CLASSIFY_TMP, 'cli2.pid'); - const hungCli = join(KILL_CLASSIFY_TMP, 'hung2.cjs'); + const pidFile = join(killClassifyTmp, 'cli2.pid'); + const hungCli = join(killClassifyTmp, 'hung2.cjs'); writeFileSync( hungCli, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + `setInterval(() => {}, 1 << 30);\n`, ); - const run = createDefaultRunWrangler(KILL_CLASSIFY_TMP, { + const run = createDefaultRunWrangler(killClassifyTmp, { launcher: [process.execPath, hungCli], timeoutMs: 300, // Kill FOR REAL, then report ESRCH (the child exited in the race window) → treated as gone. @@ -7355,12 +7356,12 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi // Drive a FULL provision whose wrangler runner is the REAL default runner spawning a hung // CLI. The `d1 list` times out → confirmed-termination hard failure → provision rejects → // the whole-provision lock is released. The child must be dead and the mutex gone. - const cfgDir = join(KILL_CLASSIFY_TMP, 'app'); + const cfgDir = join(killClassifyTmp, 'app'); mkdirSync(cfgDir, { recursive: true }); const cfgPath = join(cfgDir, 'wrangler.jsonc'); writeFileSync(cfgPath, JSON.stringify({ name: 'app', account_id: 'acct' })); - const pidFile = join(KILL_CLASSIFY_TMP, 'prov-cli.pid'); - const hungCli = join(KILL_CLASSIFY_TMP, 'prov-hung.cjs'); + const pidFile = join(killClassifyTmp, 'prov-cli.pid'); + const hungCli = join(killClassifyTmp, 'prov-hung.cjs'); writeFileSync( hungCli, `require('fs').writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\n` + From 6565365c19958c6cab83ab9db3b88792bb34190d Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 23 Jul 2026 23:34:35 +0800 Subject: [PATCH 6/9] fix(test): classify the Windows temp-removal failure instead of failing on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Diagnostics from the last Windows run identified the actual cause, and it is neither a stranded child nor a product defect: entries remaining: cli.pid, hung.cjs still-live children reaped at teardown: none No process of ours is running, yet the child's own script (hung.cjs, the image the node child was executing) and its pid file cannot be unlinked. Windows keeps a terminated process's mapped image alive until rundown completes, which here outlasts ~10s of retries. waitForDead only proves the exit code is set. So teardown now classifies rather than blanket-tolerates. A recorded pid that is still live means a child was genuinely stranded — a real leak, still failed loud. Otherwise the only residue is the dead child's own files, which is an OS artifact, and the case's assertions have already passed; warn (keeping it visible) rather than reddening a green behavioural test over temp-file hygiene. Each directory is unique per test, so a leftover cannot affect any other case, and .tmp-* is now gitignored so one cannot dirty a working tree either. Confirmed no tracked path matches that pattern. Verified: provision suite 248 pass, typecheck and vp check clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .gitignore | 4 +++ packages/void/test/unit/provision.test.ts | 38 ++++++++++++++--------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index a90ec7a4c..166f8ceea 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,7 @@ playground-local .worktrees .svelte-kit/ docs/superpowers/ + +# Test temp dirs (unique per test; a Windows run may leave one behind when the OS has not +# yet unmapped a terminated child process image — see provision.test.ts teardown). +**/.tmp-* diff --git a/packages/void/test/unit/provision.test.ts b/packages/void/test/unit/provision.test.ts index e5c6afab7..4c2945385 100644 --- a/packages/void/test/unit/provision.test.ts +++ b/packages/void/test/unit/provision.test.ts @@ -7190,8 +7190,13 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi * * This deliberately KILLS NOTHING. A pid read from a file is not proof that the process now * holding that pid is the one that wrote it, so signalling it could terminate an unrelated - * process. If removal still fails, report which recorded pids currently resolve to a live - * process and what entries remain — a bare errno is undebuggable without a Windows machine. + * process. + * + * A removal that still fails is then classified, not blanket-tolerated: a recorded pid that is + * STILL LIVE means a child was genuinely stranded — a real leak, failed loud. Otherwise the + * only things left are the dead child's own script and pid file, which Windows keeps mapped + * until process rundown completes; that can outlast any practical retry budget and is an OS + * artifact rather than a defect in the code under test, whose assertions have already passed. */ const rmTmp = (): void => { if (killClassifyTmp === '') { @@ -7199,26 +7204,31 @@ describe('createDefaultRunWrangler — confirmed child termination + kill classi } try { rmSync(killClassifyTmp, { recursive: true, force: true, maxRetries: 20, retryDelay: 50 }); + return; } catch (error) { const left = existsSync(killClassifyTmp) ? readdirSync(killClassifyTmp) : []; - const recorded = left + const live = left .filter((entry) => entry.endsWith('.pid')) - .map((entry) => { - const pid = Number(readFileSync(join(killClassifyTmp, entry), 'utf-8').trim()); - let live = false; + .map((entry) => Number(readFileSync(join(killClassifyTmp, entry), 'utf-8').trim())) + .filter((pid) => { try { process.kill(pid, 0); - live = true; + return true; } catch { - live = false; + return false; } - return `${entry}=${pid}${live ? ' (some process holds that pid)' : ' (gone)'}`; }); - throw new Error( - `could not remove ${killClassifyTmp}: ${(error as Error).message}\n` + - ` recorded pids: ${recorded.length > 0 ? recorded.join(', ') : 'none'}\n` + - ` entries remaining: ${left.length > 0 ? left.join(', ') : '(none)'}`, - ); + const detail = + `${(error as Error).message}\n` + + ` entries remaining: ${left.length > 0 ? left.join(', ') : '(none)'}\n` + + ` recorded pids still resolving to a live process: ${live.length > 0 ? live.join(', ') : 'none'}`; + if (live.length > 0) { + throw new Error(`could not remove ${killClassifyTmp}: ${detail}`); + } + // Nothing of ours is running. Warn so it stays visible, rather than reddening a green + // behavioural test over temp-file hygiene; the directory is unique per test and ignored by + // git, so a leftover cannot affect any other case. + console.warn(`warn: leaving ${killClassifyTmp} behind — ${detail}`); } }; beforeEach(() => { From 564862a823d30ba140684bc1b947beb78ea9f5f8 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 24 Jul 2026 00:00:37 +0800 Subject: [PATCH 7/9] perf(ci): parallelize the integration suite, exclude the workspace from Defender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Windows `vp run test:all` was 828s, of which `vp test run` was 609s. vitest reported 775s of test time against that 609s wall — only 1.27x parallelism on a 4-vCPU runner. Two causes, both addressed here. 1. `fileParallelism: false` on the `core-markdown` project serialized all 24 integration files to protect a conflict between a handful of them. Mapping every file to the playground it drives shows only two clusters actually share one: kitchen-sink / kitchen-sink-build / cloudflare-deploy-build on playground/kitchen-sink, and named-layouts / full-deploy-manifest on playground/named-layouts — 34.6s of the 143.5s being serialized. Those five move to their own serial `shared-playground` project and the remaining 20 run in parallel. The new project needs its own `sequence.groupOrder`: `fileParallelism: false` resolves to `maxWorkers: 1`, and vitest rejects projects that share a group order but disagree on `maxWorkers`. Verified locally at higher concurrency than CI uses: 25 files, 197 passed, 44.9s of test time in 18.5s wall. 2. Defender's real-time scan is synchronous on every file write, and this suite writes a great many of them — dependency install, ~26 playground builds, and per-worker temp dirs. The workspace and runner temp are now excluded on the Windows runner only. It warns rather than fails if the call is refused, so a runner image that disallows it still runs the suite. Same-run per-file cost shows a uniform ~3-3.7x Windows tax, with analog (4.9x) and nuxt (4.8x) the outliers and the largest absolute contributors. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .github/workflows/ci.yml | 18 ++++++++++++++++++ vite.config.ts | 34 +++++++++++++++++++++++++++++----- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4a2fd414..ecc56d668 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,24 @@ jobs: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 with: persist-credentials: false + # Defender's real-time scan is synchronous on every file write, and this suite writes a lot of + # them (dependency install, ~26 playground builds, per-worker temp dirs). Excluding the paths + # we generate is safe — their contents come from the checkout and the lockfile-pinned install, + # both already trusted by every other step. Only a speed measure: it never changes what runs. + # Warn instead of fail, so a runner image that refuses the call still runs the suite. + - name: Exclude build paths from Windows Defender + if: ${{ contains(matrix.os, 'windows') }} + shell: pwsh + run: | + foreach ($path in @('${{ github.workspace }}', '${{ runner.temp }}')) { + try { + Add-MpPreference -ExclusionPath $path -ErrorAction Stop + Write-Host "Defender exclusion added: $path" + } catch { + Write-Host "::warning::could not add Defender exclusion for ${path}: $_" + } + } + - uses: voidzero-dev/setup-vp@2dec1e33f4ab2c6d5bce1b0c4607961bb1a3f7a1 # v1.12.0 with: node-version-file: .node-version diff --git a/vite.config.ts b/vite.config.ts index 08426ff5d..2c215521a 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -19,6 +19,21 @@ const ciTimeoutScale = process.platform === 'win32' ? 3 : 1; const testTimeout = 120_000 * ciTimeoutScale; const hookTimeout = 120_000 * ciTimeoutScale; +// Integration files that share a playground directory with another file in the same project. A +// parallel run lets one file's build wipe state (.void/, dist/) that another is actively using — +// e.g. kitchen-sink-build.test.ts deletes .void/, destroying the miniflare persistence that +// kitchen-sink.test.ts's dev server depends on. These run serially in their own project so the +// rest of the integration suite does not have to. +const sharedPlayground = [ + // playground/kitchen-sink + 'packages/void/test/integration/kitchen-sink.test.ts', + 'packages/void/test/integration/kitchen-sink-build.test.ts', + 'packages/void/test/integration/cloudflare-deploy-build.test.ts', + // playground/named-layouts + 'packages/void/test/integration/named-layouts.test.ts', + 'packages/void/test/integration/full-deploy-manifest.test.ts', +]; + const metaframeworks = [ 'packages/void/test/integration/analog.test.ts', 'packages/void/test/integration/astro.test.ts', @@ -350,12 +365,21 @@ export default defineConfig({ 'packages/void/test/e2e/**/*.test.ts', 'packages/md/test/integration/**/*.test.ts', ], - exclude: metaframeworks, + exclude: [...metaframeworks, ...sharedPlayground], maxWorkers, - // kitchen-sink.test.ts (dev server) and kitchen-sink-build.test.ts - // (build) both use playground/kitchen-sink/. The build test deletes - // .void/ before running, which destroys miniflare persistence that - // the dev server depends on. Serialize to prevent the conflict. + }, + }, + { + test: { + // The only integration files that share a playground directory. Serialized among + // themselves so one file's build cannot delete state another is mid-way through using. + // This needs its own group order: `fileParallelism: false` resolves to `maxWorkers: 1`, + // and vitest rejects projects that share a group order but disagree on `maxWorkers`. + name: 'shared-playground', + sequence: { groupOrder: 4 }, + include: sharedPlayground, + testTimeout, + hookTimeout, fileParallelism: false, }, }, From 32d3f8448e3d4853ae3405ae97c4875d7e46feb4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 24 Jul 2026 00:08:17 +0800 Subject: [PATCH 8/9] fix(ci): serialize the two suites that rewrite playground/spa/wrangler.jsonc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit spa-middleware-auth.test.ts and the gated cloudflare-deploy-live e2e both rewrite playground/spa/wrangler.jsonc — the first deletes and restores it, the second writes a deploy overlay over it. Making core-markdown file-parallel let those two race. The earlier survey that found the kitchen-sink and named-layouts clusters globbed only test/integration, so it never covered test/e2e and missed this pair. Not reachable from the live-e2e job, which runs the e2e file by path and so collects nothing else, but it is reachable from a local `test:all` with credentials set — the case the credential forwarding exists to serve. Moving the e2e file also moves where its credentials have to be forwarded past vitest's worker env filter: `shared-playground` now carries `env: e2eWorkerEnv`. Without it the suite would read empty credentials and self-skip, turning the live deployment gate into a green no-op. core-markdown keeps the same env for whichever e2e file is added next. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- vite.config.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/vite.config.ts b/vite.config.ts index 2c215521a..651d6f5c6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -32,6 +32,11 @@ const sharedPlayground = [ // playground/named-layouts 'packages/void/test/integration/named-layouts.test.ts', 'packages/void/test/integration/full-deploy-manifest.test.ts', + // playground/spa — both rewrite its wrangler.jsonc. The live suite only runs when its + // credentials are set, which is never the case in the `test` job, but is the case for a local + // `test:all` run — the scenario the credential forwarding below exists to support. + 'packages/void/test/integration/spa-middleware-auth.test.ts', + 'packages/void/test/e2e/cloudflare-deploy-live.test.ts', ]; const metaframeworks = [ @@ -356,7 +361,9 @@ export default defineConfig({ sequence: { groupOrder: 3 }, testTimeout, hookTimeout, - // Forward the live-e2e credentials past vitest's worker env filter (see e2eWorkerEnv). + // Kept alongside the e2e glob below so a newly added e2e file gets its credentials + // forwarded past vitest's worker env filter here too (see e2eWorkerEnv). The one + // existing e2e file drives playground/spa, so it is in `shared-playground` instead. env: e2eWorkerEnv, include: [ 'packages/void/test/integration/**/*.test.ts', @@ -378,6 +385,11 @@ export default defineConfig({ name: 'shared-playground', sequence: { groupOrder: 4 }, include: sharedPlayground, + // The live-e2e suite lives here, so this project — not core-markdown — is the one that + // has to forward its credentials past vitest's worker env filter (see e2eWorkerEnv). + // Without this the suite reads empty credentials and self-skips, quietly turning the + // live deployment gate into a green no-op. + env: e2eWorkerEnv, testTimeout, hookTimeout, fileParallelism: false, From 1daa7f86879ae8e0bc5f2ee37cbc5b9bd3b93d5f Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 24 Jul 2026 00:09:31 +0800 Subject: [PATCH 9/9] ci: skip the Defender exclusion for fork pull requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaves real-time scanning on for the only checkout that is code no maintainer has pushed. Not because scanning is a boundary for this job — it has no secrets, a read-only token, an ephemeral runner, and already executes the PR's own build and test code by design — but because keeping it there costs nothing: Windows runs on a PR only when a maintainer applies the `test: windows` label, so fork runs are rare, while main pushes and maintainer branches keep the speedup. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_019wnwcxjavdb2xys8XGZora --- .github/workflows/ci.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ecc56d668..658cdd6df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,11 +35,21 @@ jobs: persist-credentials: false # Defender's real-time scan is synchronous on every file write, and this suite writes a lot of # them (dependency install, ~26 playground builds, per-worker temp dirs). Excluding the paths - # we generate is safe — their contents come from the checkout and the lockfile-pinned install, - # both already trusted by every other step. Only a speed measure: it never changes what runs. - # Warn instead of fail, so a runner image that refuses the call still runs the suite. + # we generate is purely a speed measure: it never changes what runs. + # + # Skipped for fork pull requests, the only case where the checkout is code no maintainer has + # pushed. Not because scanning is a boundary for this job — it has no secrets, a read-only + # token, an ephemeral runner, and already executes the PR's own build and test code by design + # — but because leaving the scan on for untrusted checkouts costs us nothing: Windows runs on + # a PR only when a maintainer applies the `test: windows` label, so this is rare, while main + # pushes and maintainer branches (the everyday path) keep the speedup. + # + # Warns instead of failing, so a runner image that refuses the call still runs the suite. - name: Exclude build paths from Windows Defender - if: ${{ contains(matrix.os, 'windows') }} + if: >- + ${{ contains(matrix.os, 'windows') + && (github.event_name == 'push' + || github.event.pull_request.head.repo.full_name == github.repository) }} shell: pwsh run: | foreach ($path in @('${{ github.workspace }}', '${{ runner.temp }}')) {