test: prove captured action stdin isolation

This commit is contained in:
2026-08-20 17:39:10 +09:00
parent a8b88afcba
commit b996099270
4 changed files with 321 additions and 54 deletions

View File

@@ -5928,6 +5928,117 @@ Checked command-global allocation-failure parity remains owned by
crosses the former fixed environment-size boundary and verifies that no
partial execution environment or staged `.new` state is published.
### 11.25 Implemented null standard input for captured actions
Every process launched through WW's captured asynchronous executor now receives
an explicit fd 0. An empty `exec.command.stdinpath`, which is the production
default, opens the null device read-only; a nonempty value opens that exact path.
Consequently every coordinator-executed directory test product observes
immediate EOF instead of inheriting and consuming the invoking terminal, pipe,
or file. Captured directory build plans and the compiler, assembler, and linker
processes that inherit their stdio receive the same noninteractive boundary.
#### Pinned Go evidence and pre-fix WW behavior
The authority is official Go 1.26.5 at commit
`c19862e5f8415b4f24b189d065ed739517c548ba`:
- `runTestActor.Act` constructs an `exec.Cmd`, assigns its package directory,
environment, stdout, stderr, cancellation, and wait delay, and invokes
`Run` without assigning `Stdin`
([`cmd/go/internal/test/test.go`, lines 16611697](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/internal/test/test.go#L1661-L1697)).
- `Cmd.Stdin` specifies that a nil value reads from `os.DevNull`;
`childStdin` opens that device and retains the file for the child; and
`Start` installs it as the first child file before process creation
([`os/exec/exec.go`, lines 193206, 531538, and 710738](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/os/exec/exec.go#L193-L206)).
- The ordinary build-command path has the same default. `Shell.runOut` creates
an `exec.Cmd`, assigns output, directory, and environment, and runs it without
assigning `Stdin`
([`cmd/go/internal/work/shell.go`, lines 600663](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/internal/work/shell.go#L600-L663)).
- Official `os/exec` tests define a `cat` helper that copies stdin to EOF and
require that helper to terminate successfully when run with no `Stdin`
assignment
([`os/exec/exec_test.go`, lines 201204 and 416459](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/os/exec/exec_test.go#L416-L459)).
The command testdata separately exercises deliberately supplied stdin-pipe
lifetime and closure for orphaned test descendants
([`cmd/go/testdata/script/test_timeout_stdin.txt`, lines 121 and 3988](https://github.com/golang/go/blob/c19862e5f8415b4f24b189d065ed739517c548ba/src/cmd/go/testdata/script/test_timeout_stdin.txt#L1-L21));
that script is adjacent stream-lifetime evidence, while the default null-fd
conclusion comes directly from the implementation chain above.
Before this slice, `lib/os/exec.start` redirected only stdout and stderr. A
directory driver invoked with a nonempty stdin file passed the same open file
description through the top-level inherited-stdio handoff, the package
coordinator, its captured builder, and the generated product. Serial products
could consume caller data; parallel products raced on the shared file offset;
a test that waited for input could wait on an interactive caller. Direct
measurement with a one-byte pipe made the same directory `@test` fail under
both Cstage and WWstage because its first read returned that byte. The raw
single-file route also read the byte and failed, but that route intentionally
remains inherited-stdio compatibility behavior.
#### Descriptor ownership, action boundaries, and concurrency
`exec.start` validates `stdinpath`, selects `/dev/null` for the empty value, and
opens the input before creating either output capture. `safefd` moves all three
standard streams above fd 2 when a caller had closed a standard descriptor.
After fork, the child maps the owned input to fd 0 before mapping the captures
to fd 1 and fd 2; setup failures travel through the existing close-on-exec
marker. The parent closes its input copy immediately after fork. Every
pre-fork error path closes every successfully acquired descriptor.
The package coordinator does not read or mutate its own fd 0. Each captured
build or run child opens an independent null descriptor, so `-j N` products
share neither readable caller data nor an input offset. Production, internal,
external, recompiled-for-test, support, and generated-main actions still form
the same graph and the one directory product still owns one process. Package
and test-only dependency initialization observes EOF inside that process.
Filters, list mode, no-match execution, failure, and timeout use the same
boundary.
Standard input is request-time process metadata only. It does not enter
canonical dotted identity, declared-name binding, actions, units, exports,
symbols, archives, generated main, executable bytes, product names, storage
keys, or diagnostics. The source path accepted by `stdinpath` is an executor
resource, not a package or filesystem-identity input.
#### Inherited-stdio routes, failure, persistence, and proof
`exec.runstdio` remains unchanged. The top-level driver therefore preserves
inherited stdin for raw single-file tests and runs, and a published test binary
invoked directly receives its invoker's fd 0. Directory `ww test -c`, including
`-c -o`, starts no product; the compiled binary acquires no embedded stdin
policy. No-selected-test packages likewise start no product. Directory build
and compile-only plans are captured actions and therefore noninteractive, but
their output, cwd, environment, graph, and publication rules are unchanged.
Failure to open an explicit input path or the default null device is a
pre-fork `termination.ERROR` with positive errno. Because input opens first,
neither output capture exists. A child-side `dup2` or close failure is reported
through the setup marker, distinguished from exit 127, and follows the existing
process-group cleanup path. Test failures, timeouts, post-build directory
removal, sibling isolation, transaction rollback, and scratch removal retain
their prior contracts.
No test-result cache exists. Caller stdin bytes never affect source actions or
persistent artifacts, and changing only the explicit proof input causes no
compile or assemble work beyond the established warm final relink. Build
workdir format remains `18`, test workdir format remains `19`, and semantic
storage remains `3` because no persisted byte schema changed.
The focused native owner remains
`directory_test_execution_working_directory` in
`test/package/package_test.ww`. It now drives every relevant command with a
known nonempty input file and requires EOF across all directory action/test
variants, production and test-only dependency initialization, serial and
parallel products, filters/list/no-match, recursive and equivalent roots,
failure, timeout, persistent cold/warm/data-only runs, and post-build child
setup failure. Tool wrappers require EOF without changing cwd, argv, locale, or
`TMPDIR`. Direct published and raw single-file binaries must instead read the
supplied data. The observer also proves input-open failure creates no captures,
source-class rejection creates no persistent state, Cstage/WWstage diagnostics
and output match, compile-only binaries are equal, persisted artifact bytes do
not change, and no `.new` residue survives.
## 12. Candidate architectures and hard-gate decision
Five candidates were developed as coherent systems, not as feature bins.