Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,34 @@ 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 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')
&& (github.event_name == 'push'
|| github.event.pull_request.head.repo.full_name == github.repository) }}
shell: pwsh
run: |
foreach ($path in @('${{ github.workspace }}', '${{ runner.temp }}')) {
try {
Add-MpPreference -ExclusionPath $path -ErrorAction Stop
Comment thread
Brooooooklyn marked this conversation as resolved.
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
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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-*
74 changes: 69 additions & 5 deletions packages/void/src/cli/provision.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1424,6 +1424,32 @@ 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 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';
}
// 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);
Comment thread
Brooooooklyn marked this conversation as resolved.
return 'signaled';
}
if (child.kill(signal)) {
return 'signaled';
}
Expand All @@ -1447,11 +1473,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) {
Expand Down Expand Up @@ -2259,7 +2305,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 {
Expand Down Expand Up @@ -2354,6 +2404,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
Expand All @@ -2366,7 +2430,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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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', () => {
Expand Down
16 changes: 13 additions & 3 deletions packages/void/test/unit/cloudflare-deploy.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -103,9 +104,18 @@ function emittedVars(vars: Record<string, unknown> = {}): 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>): 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.
Expand Down
Loading