Skip to content

fix(test): give spawned CLI children their input as a file, not a socket - #232

Merged
iperev merged 7 commits into
mainfrom
chain/tx-cli-stdin
Oct 25, 2026
Merged

iperev merged 7 commits into
mainfrom
chain/tx-cli-stdin

Conversation

@iperev

@iperev iperev commented Oct 24, 2026

Copy link
Copy Markdown
Contributor

fix(test): give spawned CLI children their input as a file, not a socket

Part of the UI-DESIGN-01 chain. Base branch: chain/v1b-media-emulation (#231, the media-emulation assertion). This PR is one commit on that branch's head, and it goes into the chain before the design-token PR. The next change in this chain is stacked on this one and fixes the shipped TypeScript adapter's hang (see Non-claims). Merge after #231.

Why

The Node test suites run the Proofkit CLI with spawnSync(binary, argv, {input, timeout}). On a macOS host, a small share of these calls never ended on their own. They failed with ETIMEDOUT, a different test each time, and this failed full local gates.

For the input option, Node's spawnSync gives the child one end of a Unix-domain socket pair. It writes the input, calls shutdown(SHUT_WR) on its end and keeps that end open until the child exits or the timeout kills it. On macOS, a child blocked in read(2) on that socket is sometimes not woken by the shutdown. The socket already holds end of file, but the read returns only when the parent closes its end, which spawnSync does at its timeout.

Evidence, darwin/arm64:

  • Stalled children. Each of the 94 instrumented stalls at 440,000 bytes had received all of its input within 1 ms of its start. A watchdog thread in the child saw recv(MSG_PEEK|MSG_DONTWAIT) return 0 (end of file) every 500 ms until the timeout, while its read(2) stayed blocked: 66 stalls, 330 probes, all 0.
  • Parent trace. An interposer in the Node parent recorded every stalled call: the write of all bytes, then shutdown(fd, SHUT_WR) = 0 2 to 3 ms after the spawn, and the close about 3000 ms later, at the timeout.
  • Without Node or libuv. The same syscall sequence written in C (socket pair, non-blocking write, shutdown, descriptor kept open) stalled 8 times in 3000 calls. With a close instead of the shutdown, with a pipe(2) or with a regular file, it stalled 0 times in 3000 calls each.
  • Node versions. Node 22.23.2, 24.22.0, 26.11.1 and 26.12.0 all stall. With 440,000 bytes of input to a Go child that reads stdin with io.ReadAll or its read loop, 8 to 52 of 3,000 calls stalled per run.
  • Linux. In the pinned Playwright arm64 image (Node 24.20.0), and with the official Node 26.11.1 tarball (checksum and signature verified), there were 0 stalls in 24,000 calls.
  • Python and async spawn. Python subprocess uses pipes, and a separate run on the same host saw 0 stalls in 472 runs with it. Node's asynchronous spawn with stdin.end() also shuts the socket down, but it closes it within 16 ms, and it stalled 0 times in 3,000 calls.

So the defect is in the macOS kernel's wake-up of a reader blocked on a Unix-domain socket, not in the CLI and not in Node's or libuv's logic. Node's spawnSync exposes it because it keeps its end open after the shutdown. No upstream report naming this mechanism was found.

What changes

One transport helper. spawnWithInputFile(file, args, options) in scripts/cli-process.mjs:

  • It is spawnSync(file, args, options), except that options.input is written to a temporary file that the child gets as its standard input. A regular file ends at its size, so the child always reads end of file.
  • The file has mode 0600, in a fresh temporary directory of mode 0700. The directory is removed as soon as the file is open, before the child starts; the open descriptor keeps the file. So while the child runs, no input remains on disk, and a signal that ends the parent then leaves none behind. A throw while the file is written or opened removes the directory too. The removal uses path calls only (rmSync of the file, then rmdirSync of the directory), so it needs no descriptor of its own; if it fails, the input descriptor is closed and the call throws.
  • status, signal, error, stdout, stderr, timeout, maxBuffer, encoding, env, cwd and killSignal keep spawnSync's meaning. Writing the input can no longer fail with EPIPE.
  • A string input is encoded with options.encoding, and a falsy input is no input, as in spawnSync.
  • Only the options the caller set are passed. An explicit maxBuffer: undefined would have lifted spawnSync's 1 MiB default bound, and a test pins that.
  • Any other option, and any stdio except ["ignore", "pipe", "pipe"], is refused. So is any args that is not an array, including the two-argument form spawnWithInputFile(file, options): spawnSync would read the options from the args position and drop the third argument, so the input would go through the socket again.
  • The result also carries stdinUnreadBytes: the input bytes past the file offset that the child shares with the parent when it exits. read(2) and lseek(2) on the inherited descriptor move it; pread(2), mmap(2) and, on Linux, reopening /dev/stdin do not.

Every site. All 42 spawnSync calls with input, in 29 test files under scripts/, now call the helper. Each change is the call name and the import. Apart from the early-refusal test below, no timeout, argument, expected value or assertion changed. The other spawnSync and execFileSync calls under scripts/ and tests/ pass no input, for example Go builds, go version -m, --help, file inputs, the integration-structure invocations, the git and tsc calls, the visual environment probes and argv-only rows with an ignored stdin.

The EPIPE oracle, kept. assertCLITransport keeps its EPIPE rule, which the asynchronous pipe transport of scripts/schema-completeness.test.mjs still needs. It applies the same rule to a non-zero stdinUnreadBytes: it is accepted only from a child that exited normally with a non-zero status and no signal. With status 0 it fails.

The pipe could report EPIPE only when an exiting child left more input than the pipe buffer held. The shared file offset leaves every byte that the child did not read with read(2) past it. So the early-refusal test in scripts/browser-server-structure.test.mjs now asserts that stdinUnreadBytes equals the size of its input (over 8 MiB), that is, the refusal read none of it, instead of asserting EPIPE.

Tests. scripts/cli-process.test.mjs:

  • covers the early refusal, status-0 and signal cases through both transports (file and pipe);
  • checks that the child's stdin is a regular file that it reads to the end, for string, Buffer, Uint8Array, empty and absent input;
  • checks the null-device path, the encoding, the default maxBuffer bound and the refused options, including the two-argument form and a non-array args;
  • checks that the child reads an unlinked 0600 file while no input remains in its temporary root, that the directory and the file are 0700 and 0600 up to the open, and that nothing remains after a call, after spawnSync throws, after the open throws and after the write throws; and that a failed removal closes the input descriptor (the process holds as many descriptors after the call as before it);
  • adds a source check: a lexical scan of the .js, .cjs, .mjs, .ts, .cts and .mts files under scripts/ and tests/ refuses an input member in the arguments of a direct spawnSync, execFileSync or execSync call, also when the function is called as a member (cp.spawnSync(…)). That covers plain, quoted, string-literal computed and shorthand keys, methods with a plain or quoted name, and object literals spread or nested in the arguments. The scan skips comments, strings, template text and regular expressions, and it has its own positive and negative cases.

Declared inputs. In proofkit/witness-plan.json, the proofkit.boundary-contract-check policy now declares scripts and tests, which the source check reads. witness-scheduler-plan admits the plan (passed). Any change under scripts/ or tests/ now selects this policy in a selective run; its 37 listed scripts/ entries are now redundant. No CI workflow selects from this plan.

Requirements

No requirement text changes. No requirement or binding names the test transport. The REQ-PROOFKIT-QUALITY rows cover coverage metrics, sanitizers and the visual reference gate. The bindings name the witness files, which keep their tests and names. Each touched suite asserts the same CLI behavior, and its transport check is stricter. No clause was false before this change or becomes false with it.

Compatibility

Not breaking. CLI behavior, JSON contracts, exit codes and Go code are unchanged.

The one shipped byte change is proofkit/witness-plan.json, which gains two input selectors of the boundary-contract-check policy. That changes its proof node digest, which no record pins.

The test helper is Node-only and adds no dependency.

Proof

Proof: A local npm run check sequence on darwin/arm64 passed (exit 0) on tree 600c4bf80032 with Node 26.11.1, npm 12.2.0, CPython 3.15.0 and Go 1.27.3, with no Go test verdict replayed from the test cache: 561/561 runtime browser cases in Chromium, Firefox and WebKit with zero retries. One recorded deviation: the host firewall blocks Go-built binaries from the network, so go:vulncheck ran against a vulnerability-database snapshot fetched with curl (modified 2026-10-21T00:27:29Z): no vulnerabilities.

Narrow evidence on the darwin/arm64 host, Node 26.11.1 (CI's version):

  • Falsifier, minimal child. Go io.ReadAll child, 440,000 bytes, 3 s timeout. spawnSync with input: 52 stalls in 3,000 calls in one run, 44 in 3,000 in another, and 21 in 1,000 in the same window as the helper run below. spawnWithInputFile: 0 stalls in 3,000 calls, and 0 in 30,000 with two variants of the helper that differ from it only in when and how the input file is removed.
  • Falsifier, the CLI. spec-proof-bundle-admission on the 59 captured ~430 KB inputs, 5 s timeout. spawnSync with input: 9 stalls in 590 calls. spawnWithInputFile: 0 in 590, and 0 in 5,900 with those two variants.
  • Formerly flaky files. admission-records-structure, report-admission-structure and impact-structure: 0 failures in 3 runs each, and in 15 runs each with those two variants. In interleaved runs, a copy of the base tree failed admission-records-structure once and report-admission-structure once, each on one test with an ETIMEDOUT diagnostic.
  • Gates. On this tree, scripts/cli-process.test.mjs and the three formerly flaky files passed 3 times each, and command-contract:check, source-hygiene, text-policy and the Go tests that read the witness plan passed. With one of those two variants, the file lists of compact-contract:check, receipt-contract:check, spec-tree-contract:check and boundary-contract:check passed 5 times each (run with --test-concurrency=3 on the shared host).
  • Descriptor limit. Under a hard limit of 256 descriptors, with 1, 2 or 3 left free at the call, the helper returned spawnSync's EMFILE result with no descriptor leaked and no input left on disk, on this host and in the pinned Playwright arm64 image (Node 24.20.0, as root and as uid 1000). A variant that removed the directory with a recursive rmSync threw instead, leaked the input descriptor and left the input file, with 1 free descriptor on both systems and with 2 on macOS.
  • Mutants. Each of these fails cli-process.test.mjs:
    • one site reverted to spawnSync({input}), as a shorthand and as an input: key;
    • an execFileSync with a quoted "input" key added under tests/;
    • the helper fed through spawnSync's input;
    • the unread-input rule removed;
    • maxBuffer: undefined passed;
    • the input removed only after the child ran, never removed, or removed only after a successful open, or its directory left in place after the file is removed;
    • the input file created with mode 0644, or in a predictable directory of mode 0755;
    • the write moved before the try that removes the input when it throws;
    • a failed removal that leaves the input descriptor open, alone or as a recursive rmSync of the directory;
    • the args check removed, or narrowed to the two-argument object form;
    • the scanner blind to method members.
  • One mutant passes cli-process.test.mjs: a recursive rmSync of the directory that still closes the descriptor when it fails. The suite does not exhaust descriptors. The descriptor-limit probe refuses this mutant, because it leaves the input file at 1 and 2 free descriptors on macOS.
  • Signals. SIGTERM, SIGINT and SIGHUP to a parent inside the helper left no input file; the same probe left one per signal with a variant that removes the directory only after the child exits.
  • git diff --check over the range: clean.

Non-claims

  • The kernel code path that loses the wake-up is not identified. The claim is the observed behavior on this host: macOS 15.8.1 (Darwin 24.6.0) on an Apple M1, under the recorded loads. Other macOS versions and Intel Macs were not tested.
  • Linux was tested in the Docker Desktop VM (linuxkit kernel), not on a CI runner.
  • A signal that ends the parent between the creation of the temporary directory and its removal, which precedes the child's start, still leaves that directory and, once written, its 0600 file. A removal that itself fails leaves them too; the call then throws.
  • stdinUnreadBytes reads falsely for a child that reads its input only with pread(2) or mmap(2), or by reopening /dev/stdin on Linux (it reports bytes it read as unread), and for a child that seeks past input it did not read (it reports that input as read). On macOS, reopening /dev/stdin shares the offset and reads correctly. No current caller depends on these cases: its children are Proofkit CLI builds and the Node probes of cli-process.test.mjs, which read their standard input with read(2) or not at all.
  • The source check is lexical. It does not see options assembled outside the call, and it does not see a synchronous spawn function called under another name: scripts/write-browser-proof.mjs and scripts/write-browser-visual-proof.mjs pass spawnSync and execFileSync as values, and their callee passes no input. Nor does it see calls through .call, .apply, Reflect.apply or an optional call (spawnSync?.(…)), a bracketed callee (cp["spawnSync"](…)), a TypeScript type-argument call (spawnSync<T>(…)), getter, setter, async or generator members, methods with a computed key ({["input"]() {…}}), computed keys other than string literals, or escaped key names. Its lexer chooses between a division and a regular expression from the previous token alone, so it misreads some code: for example a / after ++, --, a template literal, an object literal, a property named like a keyword that a regular expression can follow (x.in / y) or a name with a non-ASCII letter (π / 2), a regular expression after ) or export default (export default /'/), and a hashbang line with an unbalanced quote. A misreading can hide the code after it. On this tree every spawnSync, execFileSync and execSync name outside cli-process.* is lexed as a name, and no regular-expression token lies inside any call's arguments.
  • Asynchronous spawns that write stdin (schema-completeness, two browser-server session tests) are unchanged. Node's asynchronous transport closes the socket right after the shutdown, and it stalled 0 times in 3,000 reproducer calls. That is evidence, not proof that a lost wake-up can never delay these sites.
  • The generated TypeScript adapter of json-report-cli-adapter-source, a shipped product surface, still passes its input through spawnSync's input on this tree. It sets no timeout, and ProofkitCommandRunOptions offers none, so on macOS the same lost wake-up can block a consumer's call, and with it the consumer's event loop, until the CLI child is killed. TestGeneratedTypeScriptAdapterExecutesCoreSemantics (internal/command/jsonreportcliadaptersource/json_report_cli_adapter_source_test.go) runs that adapter under go test with small inputs and no timeout of its own. This PR does not change product output; the adapter hang is fixed by the next change in this chain.

…y vulnerability

govulncheck reports GO-2026-6809 (crypto/x509 distrusts compromised
intermediate certificates) as reachable in Go 1.27.2 through the CLI's
diagnostic output path, so the release candidate and every source gate
fail at go:vulncheck. Go 1.27.3 contains the fix.

- go.mod: toolchain go1.27.3; the go directive and every module
  requirement are unchanged.
- release/change-record.v2.json: the toolchain addition now states that
  release binaries are built with Go 1.27.3 and why; the pinned copy in
  internal/tools/releasechange/record_test.go is regenerated from it.

Proof on this tree:
A local npm run check sequence on darwin/arm64 passed (exit 0) on tree
f7cfc5f with npm 12.2.0, CPython 3.15.0 and Go 1.27.3: 561/561
browser cases in Chromium, Firefox and WebKit with zero retries. One
recorded deviation: the host firewall blocks Go-built binaries from the
network, so the go:vulncheck step ran govulncheck against a vulnerability
database snapshot fetched with curl (modified 2026-10-21T00:27:29Z) and
found no vulnerabilities. With the same snapshot, Go 1.27.2 is reported
affected by GO-2026-6809.
Add the required job "quality / platform smoke / linux-arm64" on the
GitHub-hosted ubuntu-24.04-arm runner. Its first step fails unless uname -m
reports aarch64. It then sets up the same pinned Node 26.11.1, verified npm,
Go and Python 3.15.0 toolchains as the macOS platform jobs, runs the
rootpath, repositorytransaction, processgroup and projectstatus packages
natively, runs npm run npm:version and npm run platform:smoke, builds the
complete current release wheel set, and runs the CPython 3.11.0
installed-wheel smoke, which selects the manylinux_2_17_aarch64 wheel and
the pinned linux/arm64 CPython 3.11.0 image digest. The required aggregate
needs the job and requires its success.

The workflow oracles pin the job's name, runner, 15-minute timeout and exact
ordered steps, the five required leaf jobs and the aggregate shell. They
refuse mutants that move the job to another runner, drop, move or weaken its
architecture check, drop or narrow its invariant, smoke, wheel-set or
CPython 3.11.0 steps, or remove it from the aggregate needs or result
checks. The Python runtime oracle counts the job as a pinned Python
consumer.

README states that required CI executes Linux arm64 on ubuntu-24.04-arm and
x64 on ubuntu-24.04, that CI does not execute a host at the manylinux 2.17
floor, and that the CPython 3.11.0 smoke runs on both Linux architectures;
its projection test follows. REQ-PROOFKIT-QUALITY-011 now names the Linux
arm64 platform job, its architecture check and its CPython 3.11.0 step, and
five required leaf jobs instead of four. CONTRIBUTING lists ubuntu-24.04-arm
among the source-check targets.

Proof on this tree:
A local npm run check sequence on darwin/arm64 passed (exit 0) on tree
5573507 with npm 12.2.0, CPython 3.15.0 and Go 1.27.3: 561/561
browser cases in Chromium, Firefox and WebKit with zero retries. One
recorded deviation: the host firewall blocks Go-built binaries from the
network, so go:vulncheck ran against a vulnerability database snapshot
fetched with curl (modified 2026-10-21T00:27:29Z): no vulnerabilities.
The new arm64 job's success is proven only by hosted CI on this PR.
Add a visual reference gate for the rendered requirement views. npm run
browser:visual, which npm run check does not run, compares fourteen views (the
source, spec-tree, structured proof, compact proof and coverage static
renderings in Cards and Table modes, and the Specifications, Coverage, Diff and
Traceability workspace tabs) in Chromium, Firefox and Playwright WebKit, light
and dark, at 320 and 1440 CSS pixels, plus one Chromium README scene: 169
references. A reference is a digest, not an image. tests/visual/references.json
binds, for each reference ID <engine>/<view>.<scheme>.<width>, the width, the
height and the SHA-256 of the canonical pixel encoding that
REQ-PROOFKIT-QUALITY-028 specifies. No reference image is stored in the
repository.

tests/visual/environment.json records the reference environment: the
mcr.microsoft.com/playwright:v1.63.0-noble image with its index and platform
digests on linux/arm64, and the observed OS identity, machine architecture,
image marker, fontconfig, FreeType, HarfBuzz and font package versions, the
SHA-256 of the committed tests/visual/fonts.conf, the fc-match results for
monospace, sans-serif, serif, system-ui and ui-monospace, the installed engine
revisions, the absence of emulation and the launched browser versions. fc-match
reads the text after an unescaped hyphen as a point size, so each family is
queried by its whole name with every hyphen escaped. fonts.conf includes the
system configuration and prefers Liberation Sans for sans-serif and system-ui,
Liberation Serif for serif and Liberation Mono for monospace and ui-monospace,
which have real bold faces. The system configuration appends sans-serif to
every pattern that names no generic family, so the strong sans-serif
preference makes each family fontconfig does not know a strong alias of
Liberation Sans, which WebKit accepts for the first family that WebKit looks up
in fontconfig; without the ui-monospace mapping WebKit drew the product's
monospace stacks in Liberation Sans. The gate copies fonts.conf into its run
directory and sets the copy as FONTCONFIG_FILE before observing, so fc-match
and every browser read the hashed bytes. The engine fixture requires the copy
to equal the test snapshot's fonts.conf and requires that no project sets its
own browser environment, so every browser inherits that FONTCONFIG_FILE.
references.json also binds the SHA-256 of the environment record's canonical
JSON.

The gate observes the environment, writes it with the CPU vendor, model and
features to observed-environment.json in its run directory and prints the CPU
before launching any browser. On another environment it records the state
unverified with the divergent fields and exits 0; --require-reference-environment
makes that state exit 1. Firefox drops color-scheme emulation at a navigation
into or out of a document served with Cross-Origin-Opener-Policy: same-origin
unless both documents have the same origin and send that header, including the
first navigation from about:blank; emulation applied after the last such
navigation holds across reload and across navigation within one fixture. The
gate therefore applies the color scheme and reduced motion after the first
navigation, reloads, applies them again after the reload, the last navigation
of every captured view, and asserts both through the matching media queries
before each capture. It fits the window to the document height, cut at 2400
CSS pixels, captures each view twice, requires byte-equal PNG captures, decodes
them with node:zlib, refusing tRNS outside palette images and a second IHDR,
and requires the dimensions and the canonical digest to equal the inventory
entry; a failure names the ID and both digests and sizes. Every
reference-environment run, passing or failing, keeps its captures, a candidate
inventory and the observed environment below its run directory, and a run
after which the materialized tests/visual tree gained, lost or changed a file
fails. Six controls per engine serve a copied workspace stylesheet: a routed
unchanged copy must keep the admitted digest, so routing alone cannot change
the pixels, and a one-unit text color change, a one-pixel shift, a font-weight
change from 400 to 700, a change of the sans-serif family to serif and a
change of the monospace family to sans-serif must each change it. A font-face
check per engine reads the font stacks of the workspace and static roots and
of the workspace and static monospace elements from their rendered views,
draws each beside the named Liberation faces in regular and bold, and requires
exact pixel equality with Liberation Sans for the roots and with Liberation
Mono for the monospace stacks, which must also be monospaced. The visual
Chromium project adds --disable-partial-raster, without which repeated
launches rasterized rounded corners differently, and the visual Firefox
project adds browser.chrome.site_icons=false; the runtime harness is
unchanged. One runtime-harness site with the same Firefox pattern remains:
tests/browser/workspace-evidence-layout.spec.mjs emulates the color scheme
before its first navigation, so its two dark Firefox rows render light; the
runtime harness site is fixed by the next change in this chain,
test(browser): assert media emulation in the document under test.

browserproofverify selects its input manifest, witness command, record path,
admissible states and run-directory retention from a closed table. The runtime
manifest, witness command, record path, record format and run cleanup are
unchanged. The visual row keeps its run directory, writes
artifacts/proofkit/browser-visual-proof.json with the state passed or
unverified, recomputes the environment divergence itself, requires each engine
to pass exactly its expected comparisons, controls and font-face check with
zero retries, and refuses an inventory with a missing, extra, duplicate or
malformed entry, dimensions out of bounds or a binding to another environment.
Any failure exits non-zero without writing a record. References are admitted
by the owner action --admit-references --from <run directory>, which the gate
never calls. It admits only a run whose observed environment equals the
recorded one and that holds exactly one regular capture file per ID and no
unstable directory, recomputes every digest from the PNG captures through the
same canonical encoding, refuses a run whose candidate inventory disagrees or
whose observed environment claims a divergence, and only then writes
references.json. It cannot authenticate the run directory as unmodified gate
output; only a passing gate on the admitting tree proves the admitted digests.

browsertestserver gains a compact proof selector and a README project
selector, which materializes a committed synthetic example.dispatch project,
serves it through the same project inspection path as the view command and
removes the temporary project on every exit path. A test admits that project
through the project plan.

CI gains the required job "quality / browser visual references" on
ubuntu-24.04-arm in the digest-pinned image with --user 1001 --ipc=host
--init. It sets up Node 26.11.1, the verified npm, Go and the locked tooling,
runs npm run browser:visual -- --require-reference-environment, uploads the
captures, the candidate inventory and the observed environment after every
outcome as browser-visual-captures-<sha>-<attempt> for 30 days, and uploads the
record only after success. The required aggregate needs the job, and
REQ-PROOFKIT-QUALITY-011 now counts six required leaf jobs in the aggregate.
REQ-PROOFKIT-QUALITY-025 admits exactly this job container and requires every
workflow container image reference to be pinned by a sha256 digest; the
workflow oracles pin the job's container, steps, uploads, runner, timeout and
aggregate membership and refuse an unpinned image.

REQ-PROOFKIT-QUALITY-028 states the reference gate, the canonical pixel
encoding, the font-face check and the admission with its bindings, coverage
inventory entries, overview line and non-claims: weights 400 and 500, and
weights 600 to 900, each render identically because only regular and bold
faces are installed; a family name that WebKit looks up and fontconfig does
not know, other than ui-monospace, resolves to Liberation Sans in WebKit;
pixel identity is claimed only on the calibrated Apple M1 and Arm Neoverse N2
CPU models; admission does not authenticate its run directory; admitted
images are reproducible only while the pinned image and the pinned toolchain
and dependency downloads remain available. The binding record carries the
requirement's source non-claims. The witness plan adds the browser-visual
command and policy, the container-linux-arm64-browser environment class and
the browser-visual parallel group, and the go-test policy lists the visual
records and the digest script its tests read. CONTRIBUTING.md explains how to
view an admitted image from a clone in the pinned image with checksum-verified
toolchains and how references are re-admitted, and requires, before merge, a
passing gate on each CPU model named in the non-claims or a change of those
non-claims. The UI-DESIGN-01 backlog row now says that reference screenshot
digests are committed and the screenshots are reproducible in the recorded
environment, as the owner decided.

Proof on this tree:
A local npm run check sequence on darwin/arm64 passed (exit 0) on tree
f67bb17 with Node 26.11.1, npm 12.2.0, CPython 3.15.0 and Go 1.27.3:
561/561 runtime browser cases in Chromium, Firefox and WebKit with zero
retries. One recorded deviation: the host firewall blocks Go-built binaries
from the network, so go:vulncheck ran against a vulnerability-database
snapshot fetched with curl (modified 2026-10-21T00:27:29Z): no
vulnerabilities. npm run check does not run the visual gate. Its proof is
two fresh-container runs in the pinned arm64 image on Apple M1 (190/190
each, candidates equal to references.json) and three hosted CI runs of this
tree on Arm Neoverse N2 (190/190 each, all jobs green).
Pinned Firefox drops color-scheme emulation at a navigation into or out of
a document served with Cross-Origin-Opener-Policy: same-origin, unless both
documents have the same origin and send that header. That includes the
first navigation from about:blank, a navigation to another fixture (each
fixture server has its own port) and history traversal across such a
boundary; every fixture document sends the header. Emulation applied after
the last such navigation holds across reload and across navigation between
documents of the same fixture. Chromium and WebKit are unaffected, and
reduced-motion and forced-colors emulation survived every such navigation
tested in every engine. Playwright's default light scheme is applied before
navigation as well, so pinned Firefox drops it; tests that do not call the
helper run in Firefox under its own preference.

The reflow spec tests/browser/workspace-evidence-layout.spec.mjs, the
native reflow witness of REQ-PROOFKIT-SPEC-039 and REQ-PROOFKIT-SPEC-040,
emulated before openWorkspace, so its two Firefox dark rows (390x844 and
1280x800) ran in the light scheme. No emulation site asserted that its
emulation took effect.

- tests/browser/workspace-navigation-harness.mjs: emulateMediaInEffect
  applies page.emulateMedia and then asserts, in one observation, that the
  page holds a served http document and that matchMedia matches every
  given color-scheme, reduced-motion and forced-colors value. It refuses
  any other feature or value, and the http check refuses a call made in
  the initial about:blank document, where a match says nothing about the
  next document. Every pinned engine reflects forced-colors emulation in
  matchMedia, so that feature is asserted in all three. The assertion
  proves the media feature, which switches the author forced-colors rules,
  not an engine forced-color palette, which WebKit does not apply.
- tests/browser/workspace-evidence-layout.spec.mjs: each reflow row
  emulates after openWorkspace through the helper, without a reload. The
  workspace reads the color scheme and the motion preference only through
  CSS (media queries and the root color-scheme property), which follows
  emulation in the loaded document.
- tests/browser/workspace.spec.mjs (rendered light and dark contrast),
  tests/browser/static-view.spec.mjs (static control contrast) and
  tests/browser/workspace-graph.spec.mjs (dark and forced-colors graph
  paint) emulate through the helper. They already emulated after
  navigation.

Test titles, requirement statements, bindings and the browser proof input
manifest are unchanged. No statement of REQ-PROOFKIT-SPEC-039 or
REQ-PROOFKIT-SPEC-040 names a color scheme, the light and dark contrast
oracles of REQ-PROOFKIT-QUALITY-022 already ran under the intended
scheme, and the runtime proof inputs select tests/browser as a whole.

Proof on this tree:
A local npm run check sequence on darwin/arm64 passed (exit 0) on tree
14809a0 with Node 26.11.1, npm 12.2.0, CPython 3.15.0 and Go 1.27.3:
561/561 runtime browser cases in Chromium, Firefox and WebKit with zero
retries. One recorded deviation: the host firewall blocks Go-built binaries
from the network, so go:vulncheck ran against a vulnerability-database
snapshot fetched with curl (modified 2026-10-21T00:27:29Z): no
vulnerabilities.
The Node suites passed CLI input with spawnSync's `input` option. Node then
gives the child one end of a Unix-domain socket pair, writes the input, calls
shutdown(SHUT_WR) on its end and keeps that end open until the child exits
or the timeout kills it. On macOS, a child blocked in read(2) on that socket
is sometimes not woken by the shutdown, although the socket already holds
end of file, so the call ends only at its timeout. Node 22, 24 and 26 stall
this way on darwin/arm64, and so does the same syscall sequence written in
C, without Node or libuv. With a pipe, with a close instead of the shutdown
or with a regular file, that reproducer did not stall, and neither did Node
on Linux. The suites saw the stall as an occasional spawnSync ETIMEDOUT of
one CLI call.

- scripts/cli-process.mjs adds spawnWithInputFile(file, args, options). It
  is spawnSync(file, args, options) except that options.input is written to
  a temporary file that the child gets as its standard input. The file has
  mode 0600 in a fresh directory of mode 0700, and the directory is removed
  as soon as the file is open, before the child starts, or at once when
  writing or opening the file throws. The removal uses path calls only; if
  it fails, the input descriptor is closed and the call throws. Status,
  signal, error, stdout, stderr, timeout, maxBuffer and the other passed
  options keep spawnSync's meaning, except that writing the input can no
  longer fail with EPIPE. An unsupported option is refused, and so is any
  args that is not an array, including the two-argument form
  spawnWithInputFile(file, options), which spawnSync would read as its
  options while it drops the third argument. The result also reports
  stdinUnreadBytes, the input bytes past the file offset that the child
  shares with the parent when it exits: read(2) and lseek(2) on the
  inherited descriptor move it; pread(2), mmap(2) and, on Linux, reopening
  /dev/stdin do not.
- All 42 spawnSync calls with input in 29 test files under scripts/ use it.
- assertCLITransport accepts a non-zero stdinUnreadBytes under the rule it
  already applies to EPIPE: only from a child that exited normally with a
  non-zero status and no signal. EPIPE stays accepted for the asynchronous
  pipe transport of the schema-completeness test.
- The early-refusal test of the browser server asserts that stdinUnreadBytes
  equals the size of its input, so the refusal read none of it, instead of
  asserting EPIPE.
- scripts/cli-process.test.mjs covers the file transport, the unread-input
  and EPIPE rules through both transports, the kept and refused options,
  the removal of the input file before the child runs and after a throw,
  a failed write included, and the closing of the input descriptor after a
  failed removal. It adds a lexical source check that refuses an `input`
  member in the arguments of a direct spawnSync, execFileSync or execSync
  call under scripts/ and tests/.
- proofkit/witness-plan.json: the boundary-contract-check policy declares
  scripts and tests, which that source check reads.

No timeout, retry or assertion is relaxed.

Proof on this tree:
A local npm run check sequence on darwin/arm64 passed (exit 0) on tree
600c4bf with Node 26.11.1, npm 12.2.0, CPython 3.15.0 and Go 1.27.3,
with no Go test verdict replayed from the test cache: 561/561 runtime
browser cases in Chromium, Firefox and WebKit with zero retries. One
recorded deviation: the host firewall blocks Go-built binaries from the
network, so go:vulncheck ran against a vulnerability-database snapshot
fetched with curl (modified 2026-10-21T00:27:29Z): no vulnerabilities.
Base automatically changed from chain/v1b-media-emulation to main October 25, 2026 21:05
@iperev
iperev marked this pull request as draft October 25, 2026 21:05
@iperev
iperev marked this pull request as ready for review October 25, 2026 21:23
@iperev
iperev merged commit d3c9fad into main Oct 25, 2026
15 checks passed
@iperev
iperev deleted the chain/tx-cli-stdin branch October 25, 2026 21:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant