Skip to content

Wire native WSL tool runtime - #124

Merged
AviBackToBlack merged 8 commits into
mainfrom
codex/wsl-tool-runtime
Oct 4, 2026
Merged

AviBackToBlack merged 8 commits into
mainfrom
codex/wsl-tool-runtime

Conversation

@AviBackToBlack

@AviBackToBlack AviBackToBlack commented Oct 3, 2026 •

Copy link
Copy Markdown
Owner

Summary

  • compose the native WSL2 managed-tool path end to end through the fixed installed binary, registry, lockfile, project and shim identities
  • add the complete direct Docker Desktop Engine lifecycle: namespaced state planning, create/attach/start/wait, strict non-TTY output framing, raw TTY resize and signal forwarding, exact exit propagation, and proof-bound cleanup
  • retain runtime containers until wait captures the exit status, closing the fast-process auto-remove race, and conservatively clean up transport-ambiguous start failures
  • keep production WSL tool dispatch fail-closed until proof-bound retained-container orphan reconciliation exists; an uncatchable host-shim SIGKILL cannot be made safe by in-process defers
  • report completed stdin-copy failures before a successful tool status and preserve failed stop/wait diagnostics on every cleanup path
  • keep unsupported WSL management commands and Windows-shaped host_mounts fail-closed

Validation

  • gofmt -l .
  • go vet ./...
  • go test ./...
  • go test -race ./...
  • python -m unittest -v internal/registry/pipx_wrapper_test.py internal/cli/pipx_discovery_test.py
  • powershell.exe -NoLogo -NoProfile -ExecutionPolicy Bypass -File .\scripts\test-compare-startup-benchmarks.ps1
  • release-style version injection smoke test
  • cross-builds: windows/amd64, windows/arm64, linux/amd64, linux/arm64

Remaining WSL v2 activation gates

This intentionally does not enable production WSL tool dispatch or claim WSL release support. Proof-bound retained-container orphan reconciliation, the necessary native state-management surface, Windows-filesystem/WSL-filesystem and mixed-boundary integration coverage, and real WSL2 + Docker Desktop qualification remain.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

Devin Review

Comment thread internal/wslrun/plan.go
Comment thread internal/wslrun/runner.go Outdated
Comment thread internal/wslrun/runner.go
Comment thread main.go

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Signal handling can leak retained containers, fast TTY exits can lose their exit status, and incomplete installations are reported as enabled.

Review effort: Balanced
Findings: 3 Medium severity

Open (3)
What changed in this PR

Adds native WSL2 managed-tool execution through Docker Desktop while retaining release qualification gates.

Changes:

  • Adds fixed-layout WSL dispatch, planning, streaming, signals, and cleanup.
  • Extends Docker container retention and explicit lockfile resolution.
  • Updates tests and WSL documentation.
File Description
README.md Updates WSL2 status and limitations.
main.go Dispatches native WSL tool shims.
main_test.go Tests WSL dispatch behavior.
internal/​wslrun/​terminal_linux.go Implements terminal and signal handling.
internal/​wslrun/​runner.go Orchestrates container execution and cleanup.
internal/​wslrun/​runner_test.go Tests runtime lifecycle behavior.
internal/​wslrun/​run_other.go Rejects WSL execution outside Linux.
internal/​wslrun/​run_linux.go Wires production WSL dependencies.
internal/​wslrun/​plan.go Builds commands, mounts, and environment.
internal/​wslrun/​plan_test.go Tests runtime planning.
internal/​wslrun/​frontend.go Validates installation and dispatch identity.
internal/​wslrun/​frontend_test.go Tests fixed-layout frontend validation.
internal/​wslinstall/​install.go Reports runtime availability.
internal/​wslinstall/​install_test.go Updates installation-report assertions.
internal/​wslfs/​command.go Updates preflight documentation.
internal/​wsldocker/​remove.go Supports retained-container cleanup.
internal/​wsldocker/​remove_test.go Tests retained-container removal.
internal/​wsldocker/​remove_linux.go Documents retention verification.
internal/​wsldocker/​create.go Adds configurable container retention.
internal/​wsldocker/​create_test.go Tests retained container creation.
internal/​lockfile/​lockfile.go Adds explicit-path image resolution.
internal/​lockfile/​lockfile_test.go Tests explicit WSL lock paths.
internal/​hostenv/​wsl_layout.go Documents enabled layout consumption.
internal/​hostenv/​hostenv.go Enables canonical native WSL2 hosts.
internal/​hostenv/​hostenv_test.go Tests the revised host boundary.
docs/​wsl.md Documents implemented WSL runtime behavior.
docs/​wsl-process-contract.md Defines WSL process semantics.
docs/​shell-contract.md References the implemented WSL frontend.
docs/​security-model.md Adds WSL runtime security guarantees.
docs/​roadmap-implementation-requirements.md Updates remaining WSL gates.
docs/​roadmap-decisions.md Records completed runtime wiring.
docs/​architecture.md Documents the new orchestration package.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/wslinstall/install.go Outdated
Comment thread internal/wslrun/runner.go Outdated
Comment thread internal/wslrun/terminal_linux.go

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Initial review of head 41f4924 (this is the first review round — no prior reviews or comments on the PR).

The composition is coherent: the frontend revalidates the fixed layout, managed binary, registry and shim identities before touching Docker; the plan validates the complete spec before any volume mutation; create/attach/start/wait ordering is right (attach before start, retained container so wait always collects the exit code); and cleanup is conservative without force-deleting ambiguous state. The RetainUntilCleanup change to wsldocker create/remove correctly closes the fast-process auto-remove race, including the inspect re-proof. host_mounts and Windows-shaped state correctly fail closed, and docs match the implementation I read.

Two actionable findings:

[important] Non-terminal character devices are treated as interactive, so routine redirections hard-fail

terminal.Interactive() (internal/terminal/terminal.go) only checks os.ModeCharDevice on stdin/stdout. /dev/null (and any other non-tty char device) satisfies that check, so deps.interactive() in runFrontend (internal/wslrun/frontend.go:109) selects spec.TTY = true. Then in internal/wslrun/terminal_linux.go:

  • getTermios(os.Stdin.Fd()) issues TCGETS — fails with ENOTTY when stdin is /dev/null, /dev/zero, etc.;
  • terminalSize(os.Stdout.Fd()) issues TIOCGWINSZ — fails with ENOTTY when stdout is /dev/null.

Concrete failure mode: cb tool >/dev/null in a real terminal fails with "read native WSL terminal size: inappropriate ioctl for device"; cb tool </dev/null or cb tool </dev/null >/dev/null fails with "inspect native WSL terminal mode: …". These are ordinary scripting/cron patterns, and the Windows frontend handles them today (the docker run -t path never performs host termios ioctls). Cleanup runs correctly, but the tool never executes.

Remediation direction: select TTY mode on real terminal-ness (e.g. a TCGETS/isatty probe on stdin — matching the docker -t convention of gating on stdin), not character-device-ness; or treat ENOTTY as non-interactive before spec.TTY is committed. Fixing this in the interactive seam keeps the Windows behavior untouched.

[important] Signal/resize forwarding can race container exit and mask the real exit status

In internal/wslrun/runner.go the event cases call deps.resize/deps.signal unconditionally (:243-249). The Engine returns an error (HTTP 409 "container is not running", or 404) when the container has already exited, and the select loop may still consume a queued SIGWINCH/signal event after wait has effectively decided the outcome — select picks randomly among ready cases, and a signal arriving between container exit and the waitDone read also forwards to a dead container.

Concrete consequence: Ctrl-C, SIGHUP on terminal close, or a resize arriving just as a fast tool exits converts a completed run into "forward signal N to native WSL tool container: Docker Desktop Engine API … HTTP 409" and exit code 120 instead of the tool's real status. The deferred cleanup then behaves correctly (non-force remove of the stopped container), so only the reported result is wrong — but it is intermittently wrong in a common interactive gesture.

Remediation direction: tolerate not-running/not-found responses for forwarded signals and resizes (the forward is best-effort once the container is gone), or stop consuming events once the wait result is known.

Summary

The wiring is disciplined and fail-closed throughout: identity proofs are repeated at each boundary, the spec is validated before any mutation, transport-ambiguous start failures get a conservative kill/wait/remove path, and exit codes propagate exactly. The two findings above are real behavioral defects in the new runtime path (spurious failures for standard stream redirections, and a forwarding race that can misreport a completed run's exit status); neither undermines the security model, but both are worth addressing before the qualification gates the PR describes are exercised. CI was still pending at the time of this review; Go toolchain was unavailable in my environment, so findings are from source inspection of the current head.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed both important findings from the initial review at e47dba5:

  • Native WSL TTY selection now probes stdin with Linux TCGETS; character devices such as /dev/null are non-interactive. Terminal sizing also uses stdin, so redirected stdout does not disable or break a genuine terminal session. Added Linux coverage for the /dev/null case.
  • Docker wait collection starts immediately after start. Initial and queued resize/signal 404/409 responses are treated as completion races and arbitrated by the authoritative wait result; wait still fails if the retained container genuinely vanishes. Added fast-exit and queued-event regressions.
  • The adjacent broken-pipe cleanup hole identified by Copilot is also closed by intercepting SIGPIPE, with a real-signal test.

Validation on the current head: go test -count=20 ./internal/wslrun, go test -race ./internal/wslrun, go test -race ./..., go vet ./..., and Windows/Linux amd64/arm64 release-style builds all pass locally. Production activation remains gated pending orphan reconciliation and real WSL2 + Docker Desktop qualification.

@CherylSnowVeil please re-review current head e47dba5.

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of head e47dba5 (second round — initial review covered 41f4924).

Validation of previous findings

Both findings from the initial review are fixed, verified against the current source, not just the replies:

  • Non-terminal character devices → interactive (important): fixed. interactiveHostTerminal now probes stdin with a real TCGETS ioctl (internal/wslrun/terminal_linux.go:58), so /dev/null, /dev/zero and friends classify as non-interactive. prepareHostTerminal and terminalSize also operate on the stdin fd, so cb tool </dev/null, cb tool >/dev/null and combined redirections no longer hard-fail — the exact failure modes from round one. TestInteractiveHostTerminalRejectsNonTerminalCharacterDevice covers the /dev/null case.
  • Signal/resize race masking exit status (important): fixed. The wait goroutine now starts immediately after a successful start (internal/wslrun/runner.go:165-169), before the initial resize, and containerCompletionRace tolerates Engine 404/409 from the initial and any queued resize/signal forwards while the already-started wait stays authoritative. TestExecuteToolPreservesFastTTYExitAcrossInitialResizeRace and TestExecuteToolPreservesExitAcrossQueuedControlEvents pin both cases.

The other review-round findings also hold up under re-verification: finishToolResult non-blockingly collects a completed stdin copy/half-close error before reporting a successful status; lifecycleErr is joined on every cleanup path; SIGPIPE is now intercepted so broken output writes surface as EPIPE through the normal error/cleanup path; the install report distinguishes INSTALL REQUIRED from INSTALLED; and production dispatch remains behind requireHostFrontend pending orphan reconciliation, so the retained-container orphan risk cannot be exercised in this build.

Fresh findings (complete re-review of the current head)

[important] A zero-size host terminal hard-fails every interactive run, and a transient size-read error kills a running tool

terminalSize (internal/wslrun/terminal_linux.go:147) treats TIOCGWINSZ returning 0 rows or columns as fatal. prepareHostTerminal therefore errors after interactiveHostTerminal has already selected TTY mode (a pty passes TCGETS regardless of winsize), so executeTool aborts before start and the invocation exits 120. Programmatic ptys that never set a size — pexpect/expect-style harnesses, creack/pty-based runners and similar — report 0x0, and those are plausible consumers of tool shims. docker run -t proceeds in the same environment (it never gates the run on the host winsize).

Same class of strictness mid-run: a SIGWINCH whose terminalSize read fails is delivered as event.err (terminal_linux.go:100-104) and runner.go:240 returns it, which aborts the session and force-kills the container. An unmeasurable resize should not kill the tool.

Consequence: deterministic failure of a legitimate environment at startup, and a fragile abort path during resize. Remediation direction: treat a 0x0 winsize as "unknown" — skip the initial resize or substitute a conventional default — and drop/swallow unreadable resize events rather than failing the run.

[suggestion] stdin-only TTY selection diverges from the Windows frontend for redirected stdout

interactiveHostTerminal selects TTY whenever stdin is a real terminal; stdout is never consulted. The Windows frontend's shared terminal.Interactive() requires both stdin and stdout to be character devices. So cb tool > out.txt inside an interactive WSL session now runs the tool with a pty — emitting ONLCR \r\n and any TTY-only output such as colors/progress into out.txt — while the same invocation on Windows produces plain non-TTY output. wsl-process-contract.md documents this ("Stdout may be redirected"), so it is a deliberate contract choice rather than an oversight, and it does fix the hard-failure reported in round one. Flagging it once for the record because it is a real cross-frontend byte-level divergence; if intentional, no action needed.

[nit] Stale import edge in docs/architecture.md

The regenerated import-edge list claims wslrun -> …, terminal, …, but internal/wslrun does not import internal/terminal (actual edges: hostenv, lockfile, policy, registry, wsldocker, wslfs, wslpathmap, wslproject, wslshim, wslvolume) — a leftover from when interactivity used the shared helper. Since the doc presents the list as exact go list output, worth regenerating.

Summary

The round-two hardening is real and the design remains disciplined: attach-before-start, retained-until-wait container identity, proof-bound mounts/volumes/labels, conservative transport-ambiguous cleanup, exact exit propagation, and every operation repeating the endpoint proof. The activation gate stays intact; docs accurately describe the wired-but-gated state. CI is fully green at this head (Go toolchain was unavailable in my environment, so findings are from source inspection of e47dba5). One non-blocking observation for the interactive path: SIGTSTP is forwarded to the container while the shim itself cannot suspend, so Ctrl-Z in a terminal wedges the session (a stopped container makes even SIGINT pending until SIGCONT) — this mirrors docker attach semantics and the alternative of letting the shim stop would strand a retained running container, but it is worth being aware of before activation.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed the complete second-round review at unsigned head 36f6965:

  • Zero/unreadable terminal dimensions: terminal size is now advisory. A zero or failed winsize read yields an unknown size, so startup skips the initial resize instead of aborting. Unmeasurable SIGWINCH events are dropped instead of becoming fatal runner errors. Docker resize/signal failures remain fail-closed except for the already-reviewed 404/409 completion race.
  • Redirected stdout divergence: I adopted the suggestion rather than retaining the divergence. WSL TTY selection now requires real termios on both stdin and stdout, matching the Windows frontend; redirected stdin or stdout selects non-TTY execution.
  • Stale architecture edge: regenerated the exact wslrun internal import list and removed the obsolete terminal edge.
  • Updated docs/wsl.md and docs/wsl-process-contract.md for the measurable-size and two-stream TTY contract.

Added regressions for unknown initial TTY size, zero/error dimension classification, and both-stream TTY selection.

Validation on current head:

  • go test -count=20 ./internal/wslrun
  • go test -race ./internal/wslrun
  • go vet ./...
  • go test -race ./...
  • gofmt -l . clean
  • exact go list import-edge verification
  • release-style Windows/Linux amd64/arm64 builds plus injected-version smoke

@CherylSnowVeil please re-review current head 36f6965.

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of head 36f6965 (third round — initial review covered 41f4924, second e47dba5).

Validation of previous findings

All three round-two findings are resolved, verified against source rather than the replies:

  • Zero/unreadable terminal dimensions (important): fixed. usableTerminalSize (internal/wslrun/terminal_linux.go:141) now classifies a failed or zero-dimension TIOCGWINSZ as unknown. prepareHostTerminal records 0x0 rather than failing, the initial resize is skipped when dimensions are unknown (runner.go:170), and an unmeasurable SIGWINCH is dropped at terminal_linux.go:102-107 instead of aborting the session. TestUsableTerminalSize and TestExecuteToolSkipsUnknownInitialTTYSize pin the behavior.
  • stdin-only TTY divergence (suggestion): adopted. interactiveTerminalPair requires a real TCGETS result on both stdin and stdout (terminal_linux.go:54-64), matching the Windows frontend's two-stream rule — cb tool > out.txt now selects non-TTY execution on both frontends. TestInteractiveTerminalPairRequiresBothStreams covers all four combinations, and the contract doc was updated to match.
  • Stale architecture import edge (nit): fixed. docs/architecture.md now lists wslrun -> hostenv, lockfile, policy, registry, wsldocker, wslfs, wslpathmap, wslproject, wslshim, wslvolume, which I re-verified against the package's actual imports — no terminal edge remains.

The previously noted SIGTSTP observation is unchanged and remains non-blocking: forwarding TSTP to the container while the shim itself stays running mirrors docker attach semantics, and the alternative strands a running container.

Fresh findings (complete re-review of the current head)

[suggestion] stopEvents() restores default signal disposition before the cleanup window, reopening a catchable orphan path

In executeTool's deferred cleanup (internal/wslrun/runner.go:96-141), stopEvents() runs first — before stream.Close() and before the bounded signal(9)/wait/remove sequence (up to cleanupTimeout = 30s). ossignal.Stop restores default handling, so a SIGTERM/SIGHUP/SIGINT arriving during cleanup kills the shim instantly and can strand a still-running retained container — the exact orphan class the activation gate cites for SIGKILL, except these signals are catchable and the window is avoidable in-process. Moving stopEvents() after the removal block would keep the signals suppressed through cleanup. The trade-off is that the shim then appears signal-immune for the cleanup bound (the user retains SIGKILL), so this is a judgment call rather than a defect; given the gate explicitly exists for this orphan class, narrowing the window seems worthwhile.

[nit] pythonBootstrap is duplicated verbatim across both frontends

The bootstrap literal if [ ! -x /venv/bin/python ]; then ... __CB_PIP__ ... exists identically in internal/wslrun/plan.go:28 and internal/dockerrun/dockerrun.go:266. Nothing ties them together; a future fix to one (e.g., the sentinel ordering) silently diverges Windows and WSL Python behavior. A shared constant or a parity test would prevent drift.

Summary

The round-three hardening is real and verified in code: terminal size is advisory end to end, TTY selection now matches the Windows contract on both streams, wait remains authoritative over completion-race 404/409s, and every boundary repeats the fixed layout, managed-binary, registry, shim, volume and socket/peer proofs. The activation gate stays intact — RequireFrontend still rejects WSL2Native before the dispatch branch, so the wired runtime cannot be exercised in production, and the docs accurately describe the wired-but-gated state including the retained-container orphan caveat. CI is fully green at this head; Go toolchain was unavailable in my environment, so findings are from source inspection of 36f6965. No blocking or important issues found.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed both third-round observations at unsigned head 804a6cb:

  • Cleanup signal window: stopEvents() now runs only after stream/terminal teardown and the complete bounded proof-bound stop/wait/remove sequence. Catchable HUP/INT/TERM signals therefore remain intercepted throughout cleanup and cannot regain default disposition inside the orphan-prone window. The forced-stream-failure regression now asserts event teardown occurs after removal. SIGKILL remains uncatchable and stays covered by the activation gate/orphan-reconciliation requirement.
  • Python bootstrap drift: the Windows frontend now names its command as dockerrun.PythonBootstrap, and the WSL planner has an explicit parity test against it. This prevents silent command drift without adding a production dependency between the parallel frontends.
  • Updated the WSL process contract with the cleanup signal guarantee.

Validation:

  • go test -count=20 ./internal/wslrun ./internal/dockerrun
  • go test -race ./internal/wslrun ./internal/dockerrun
  • go vet ./...
  • go test -race ./...
  • gofmt -l . clean
  • release-style Windows/Linux amd64/arm64 builds plus injected-version smoke

@CherylSnowVeil please re-review current head 804a6cb.

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of head 804a6cb (fourth round — initial review covered 41f4924, second e47dba5, third 36f6965).

Validation of previous findings

Both third-round observations are resolved, verified against source rather than the replies:

  • stopEvents() restoring default disposition before cleanup (suggestion): adopted. internal/wslrun/runner.go:135-143 now invokes stopEvents() only after stream.Close(), terminal restoration, the bounded signal(9)/wait sequence and proof-bound removal. Catchable HUP/INT/TERM remain intercepted through the entire cleanup window, closing the in-process variant of the orphan path the activation gate exists for. TestExecuteToolForceStopsOwnedContainerAfterStreamFailure now asserts remove precedes stop-events, and docs/wsl-process-contract.md documents the guarantee including the remaining uncatchable-SIGKILL caveat.
  • pythonBootstrap duplicated across frontends (nit): fixed. internal/dockerrun/dockerrun.go:35-37 exports PythonBootstrap, the Windows frontend consumes the same constant (dockerrun.go:270), and TestPythonBootstrapMatchesWindowsFrontend pins exact parity without adding a production dependency edge between the parallel frontends.

The earlier SIGTSTP observation is unchanged and remains non-blocking: TSTP is still forwarded to the container while the shim itself cannot suspend, which mirrors docker attach semantics; the alternative strands a retained running container.

Fresh findings (complete re-review of the current head)

[nit] A failed stream.Close() diagnostic is dropped when the run already failed

In the deferred cleanup (internal/wslrun/runner.go:98-101) the attach-stream close error is assigned to retErr only when retErr == nil; on an already-failed run it is silently discarded. term.restore and lifecycleErr on the same path are joined unconditionally, so this one case is inconsistent with the PR's otherwise careful preserve-every-cleanup-diagnostic policy. Optional: retErr = errors.Join(retErr, fmt.Errorf("close native WSL attach stream: %w", err)) matches the surrounding treatment. Not blocking.

Summary

The round-four changes are exactly as described and verified in code: catchable signals stay suppressed through the full bounded cleanup, and the Python bootstrap literal is parity-locked to the Windows frontend by test. The broader composition still holds up under a fresh pass — attach-before-start, retained-until-wait identity, wait-authoritative 404/409 completion-race arbitration, exact 0..255 exit propagation into osExit, advisory terminal sizing, two-stream TTY selection matching the Windows contract, proof-bound mounts/volumes/labels/socket checks, and conservative transport-ambiguous cleanup with joined diagnostics. The activation gate remains intact: requireHostFrontend still rejects WSL2Native before the dispatch branch, so the wired runtime cannot be exercised in production, and the docs accurately describe the wired-but-gated state including the retained-container orphan caveat. CI was partially pending at review time (zizmor and govulncheck green; build/test jobs still running); the Go toolchain is unavailable in my environment, so findings are from source inspection of 804a6cb. No blocking or important issues found — only the single nit above.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed the optional fourth-round cleanup-diagnostics nit at unsigned head bef9801:

  • stream.Close() failures are now always joined into the named return error, preserving both the original execution failure and the attach-stream cleanup diagnostic.
  • Extended the forced stream-failure regression so one run produces both failures and requires both diagnostics.

Validation:

  • go test -count=20 ./internal/wslrun
  • go test -race ./internal/wslrun
  • go vet ./...
  • go test -race ./...
  • gofmt -l . clean and git diff --check
  • release-style Windows/Linux amd64/arm64 builds
  • canonical cb.exe injected-version smoke (v0.0.0-pr124-review5)

@CherylSnowVeil please re-review current head bef9801 when convenient.

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of head bef9801 (fifth round — initial review covered 41f4924, second e47dba5, third 36f6965, fourth 804a6cb).

Validation of previous findings

The fourth-round nit is resolved, verified against source rather than the reply:

  • stream.Close() diagnostic dropped on already-failed runs (nit): fixed. internal/wslrun/runner.go:99-101 now joins the attach-stream close error unconditionally via errors.Join(retErr, fmt.Errorf("close native WSL attach stream: %w", err)), matching the term.restore and lifecycleErr treatment on the same path. TestExecuteToolForceStopsOwnedContainerAfterStreamFailure now produces both a stream decode failure and a close failure in one run and requires both diagnostics.

The earlier SIGTSTP observation is unchanged and remains non-blocking: TSTP is still forwarded to the container while the shim itself cannot suspend, which mirrors docker attach semantics; the alternative strands a running container.

Fresh findings (complete re-review of the current head)

[important] A remote-close write failure on the stdin copy masks the tool's real exit status

internal/wslrun/runner.go:191-198 feeds io.Copy(stream, deps.stdin) and stream.CloseWrite() results into inputDone, and both the select loop (:233-237) and finishToolResult (:269-282) treat any completed non-io.ErrClosedPipe input error as fatal — deliberately, so genuinely truncated piped input is not reported as success.

The problem is the classification boundary: io.ErrClosedPipe is only produced by AttachStream.Write after a local close (stdinClosed/Close). When the daemon closes the attach connection because the container exited — the normal case for a tool that finishes without draining stdin — the in-flight or next write instead fails with *net.OpError wrapping syscall.EPIPE ("write: broken pipe"), and CloseWrite can similarly surface ENOTCONN/EPIPE on a reset connection. Neither matches the tolerated sentinel.

Concrete failure mode: yes | cb tool or cat bigfile | cb tool where the tool exits before consuming all of stdin (the standard SIGPIPE-style pipeline) — the EPIPE lands in inputDone essentially at container exit, before the wait round-trip completes. The inputErr case or finishToolResult's non-blocking read then reports "copy native WSL tool input: write unix …: broken pipe" and the invocation exits 120 instead of the tool's real status (including a successful 0). The same is reachable in TTY mode if the user types in the window between container exit and the wait result.

This is the same completion-race class the PR already fixed for resize/signal forwards (containerCompletionRace): forwarding stdin to a container that is already gone is best-effort, and the Engine wait is authoritative for the outcome. It also diverges from the Windows frontend, where exec.Cmd deliberately ignores stdin-copy failures after the child exits. A remote-close EPIPE is not truncated input — the tool exited; it consumed what it wanted — so it should not win over the tool status the way a real source-read failure should.

Remediation direction: distinguish source-read failures (keep reporting; they are genuine truncation) from sink-write failures caused by a gone peer — e.g., wrap stream.Write/CloseWrite so errors.Is(err, syscall.EPIPE) (and net.ErrClosed-class errors) maps to the same treatment as io.ErrClosedPipe, or make input errors non-fatal once wait has produced a result. A regression test could use a fake attach whose Write fails EPIPE while wait returns a real status.

Summary

The round-five change is exactly as described and verified in code: stream-close diagnostics are preserved on both success and failure paths, and the broader composition still holds up under a fresh pass — attach-before-start, retained-until-wait identity, wait-authoritative 404/409 completion-race arbitration, exact 0..255 exit propagation, advisory terminal sizing, two-stream TTY selection matching the Windows contract, proof-bound mounts/volumes/labels/socket checks, and conservative transport-ambiguous cleanup with joined diagnostics. The activation gate remains intact: requireHostFrontend still rejects WSL2Native before the dispatch branch, so the wired runtime cannot be exercised in production, and the docs accurately describe the wired-but-gated state including the retained-container orphan caveat. CI is fully green at this head; the Go toolchain is unavailable in my environment, so findings are from source inspection of bef9801. One important issue above — the same completion-race shape as the already-fixed resize/signal case, this time on the stdin forward path.

@AviBackToBlack

Copy link
Copy Markdown
Owner Author

Addressed the fifth-round important finding at unsigned head 42473ab:

  • Stdin forwarding now distinguishes the source from the attach sink. Source-read failures remain fatal, preserving the fail-closed truncated-input behavior.
  • Only sink-side peer closure is normalized to the existing closed-pipe completion path: portable io.ErrClosedPipe / net.ErrClosed, plus Linux EPIPE, ENOTCONN, and ECONNRESET (including wrapped net.OpError values).
  • Docker Engine wait therefore remains authoritative when a tool exits without draining stdin; unrelated sink failures still fail closed.
  • Added end-to-end exit-status coverage, Linux wrapped-error coverage for write and half-close paths, source-failure coverage, and an explicit unexpected-sink-error negative regression.
  • Updated the WSL runtime and process-contract docs with the source-vs-sink boundary.

Validation:

  • go test -count=20 ./internal/wslrun
  • go test -race ./internal/wslrun
  • go vet ./...
  • go test -race ./...
  • Windows amd64 test cross-compilation for internal/wslrun
  • release-style Windows/Linux amd64/arm64 builds
  • canonical cb.exe injected-version smoke (v0.0.0-pr124-review6)
  • gofmt -l . clean and git diff --cached --check

@CherylSnowVeil please re-review current head 42473ab.

@CherylSnowVeil CherylSnowVeil left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review of head 42473ab (sixth round — initial review covered 41f4924, second e47dba5, third 36f6965, fourth 804a6cb, fifth bef9801).

Validation of previous findings

The fifth-round important finding is resolved, verified against source rather than the reply:

  • Remote-close stdin sink failure masking the tool exit status (important): fixed. copyToolInput (internal/wslrun/runner.go:269-279) now runs io.Copy through inputSinkWriter (runner.go:255-267), which normalizes only attach-sink errors classified by inputPeerClosed to io.ErrClosedPipe. The classifier (internal/wslrun/input_error.go, input_error_linux.go) covers io.ErrClosedPipe, net.ErrClosed, and Linux EPIPE/ENOTCONN/ECONNRESET — the exact *net.OpError-wrapped sentinels the real hijacked Unix connection produces on a dead peer. Source-read failures bypass the wrapper and remain fatal, CloseWrite is still attempted unconditionally, and non-peer CloseWrite errors remain fatal. Both tolerance sites (runner.go:229 select case and finishToolResult at runner.go:295) already accept io.ErrClosedPipe, so a tool that exits without draining stdin — yes | cb tool, cat bigfile | cb tool, TTY input racing container exit — now reports the authoritative Engine wait status instead of "copy native WSL tool input: … broken pipe" and exit 120. TestExecuteToolPreservesExitAcrossClosedInputSink exercises the failure end-to-end with a non-empty stdin (verified successfulRunDependencies feeds strings.NewReader("input"), so Write is really invoked), TestCopyToolInputTreatsLinuxPeerCloseAsCompletion pins the errno set, and TestCopyToolInputPreservesSourceReadFailure/TestCopyToolInputPreservesUnexpectedSinkFailure pin both preserved-failure classes. The contract docs (docs/wsl-process-contract.md, docs/wsl.md) describe the same source-fatal/sink-tolerated boundary.

One note on the classification breadth: normalizing ECONNRESET on the sink does not mask a mid-run transport failure, because stdin and output share the same attach connection — a dead connection surfaces independently through the output-copy error path, while a clean container exit with a closed sink correctly defers to the wait result. The boundary is sound.

The earlier SIGTSTP observation is unchanged and remains non-blocking: TSTP is still forwarded to the container while the shim itself cannot suspend, which mirrors docker attach semantics; the alternative strands a retained running container.

Fresh findings (complete re-review of the current head)

None. The current revision was reviewed in full — runner orchestration, frontend identity proofs, plan/env/mount validation, terminal handling, the new input classifier, attach/create/remove proof-bound operations, RuntimeImageForToolAt, the main.go dispatch boundary, install reporting, docs, and the new tests — and no actionable issues were found.

Summary

The round-six change is exactly as described and verified in code: the stdin forward path now applies the same completion-race treatment the resize/signal forwards already had, without weakening the deliberate fail-closed behavior for genuine source truncation. The broader composition still holds under a fresh pass — attach-before-start, retained-until-wait identity, wait-authoritative 404/409 arbitration, exact 0..255 exit propagation, advisory terminal sizing, two-stream TTY selection matching the Windows contract, proof-bound mounts/volumes/labels/socket checks, and conservative transport-ambiguous cleanup with joined diagnostics and signals held intercepted through the whole cleanup window. The activation gate remains intact: requireHostFrontend still rejects WSL2Native before the dispatch branch (main.go:78-87), so the wired runtime cannot be exercised in production, and the docs accurately describe the wired-but-gated state including the retained-container orphan caveat. CI is green except the two Windows build jobs still pending at review time; the Linux test job that covers the new code passed. Go toolchain is unavailable in my environment, so findings are from source inspection of 42473ab. No blocking, important, or nit-level issues found.

@AviBackToBlack
AviBackToBlack merged commit e625ab6 into main Oct 4, 2026
15 checks passed
@AviBackToBlack
AviBackToBlack deleted the codex/wsl-tool-runtime branch October 4, 2026 09:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants