Windows pipeline spawn is a creation-time boundary
Windows pipeline launch needs a custom CreateProcessW layer: helper protocol
handles cross by explicit handle list, and pipeline children enter their Job
Object before user code can run. The current std::process::Command paths
collect and reap well, but they express two spawn facts too late or too globally.
For ral’s sandbox story, process creation is an authority boundary; on Windows it
must be made as explicit as the Unix pre_exec boundary.
This ADR records only the invariant-restoring fix, not an operational mitigation.
It applies to the Windows arms named by
pipeline-execution: helper protocol handle
passing (core/src/runtime/pipeline/protocol/windows.rs) and pipeline Job Object
placement (core/src/process/signal/windows.rs).
Context
The Unix launcher has two pre-user-code hooks that Windows’ safe Command API
does not give us in the same form.
- File descriptor inheritance is per child. Unix can clear
FD_CLOEXEConly in the child-sidepre_execwindow, afterforkand beforeexec, so an unrelated concurrent spawn does not see the helper’s private channels. - Process-group placement precedes user code. Unix
setpgidruns inpre_exec, before the launched program can fork or exec its own descendants.
The Windows pipeline path currently approximates those properties after the fact.
- Helper channels are made parent-inheritable.
passmarks a gate, report, value, or helper job handle inheritable in the parent, writes its numeric value into the child’s environment, then relies on the helper to clear inheritance after start. During that parent-side window, any unrelated concurrent Windows spawn with inheritable handles enabled can inherit the channel and keep it open. The symptom is not just a leak: a stray report/value/gate handle can keep EOF from arriving and turn collection into a wait on a process that does not own the protocol. - Pipeline Job Object membership is post-spawn.
spawn_with_pgidcreates the process, thennew_leaderorjoinassigns it to the pipeline Job Object. A direct external stage that forks before assignment can leave descendants outside the job, so abort-timeTerminateJobObjectis no longer a tree kill. Helper stages are usually protected by their gate, but the invariant cannot depend on routing hostile direct externals through a helper.
Both holes belong to launch, not to collection. The collector can only wait on and kill the process tree it was actually handed.
Decision
Build one Windows spawn layer for pipeline children, using raw CreateProcessW
and an extended startup attribute list where that is the precise primitive.
- Protocol handles cross by explicit handle list. A helper child receives
only the handles named for that launch: gate, report, optional value channels,
and any helper-owned job/control handle. The parent no longer flips a global
inherit bit on those handles before spawn. The intended Windows primitive is
STARTUPINFOEXWwithPROC_THREAD_ATTRIBUTE_HANDLE_LIST; the environment may still carry numeric handle values, but those numbers name handles that were deliberately admitted to this child and no other. If the Win32 ABI requires the listed handles to be inheritable at the instant of creation, the launcher owns that temporary launch record under a process-wide launch mutex: set-inherit,CreateProcessW, then clear-inherit is one critical section. Ral-owned Windows spawns that inherit handles must use explicit lists, never ambientbInheritHandlesinheritance; the mutex is still kept as belt-and-braces against dependency or missed std spawns in the same process. - Job Object placement happens at creation time. A direct or helper pipeline
child is a member of the pipeline Job Object before its program can run. Prefer
PROC_THREAD_ATTRIBUTE_JOB_LISTwhere available. Otherwise create suspended, assign the process to the already-known job, then resume the primary thread. For a new leader, create the Job Object before launch; for a joiner, look up the leader’s job before launch. std::process::Commandremains above the boundary. Callers may still build argv, cwd, env, stdio, and command image facts with the existing abstractions, but the final Windows launch step is owned by the custom layer because that is where handle admission and job membership become indivisible.- The sandbox and pipeline code should share the low-level Windows launch machinery. The reusable core is not policy: it is UTF-16 command-line construction, inheritable-stdio setup, extended attribute-list lifetime management, suspended-process resume/close cleanup, and error rendering. Policy stays at the caller: helper protocols choose handles; pipeline groups choose jobs; sandbox projections choose capability shape.
Consequences
- A report/value/gate channel cannot be held open by an unrelated concurrent spawn. EOF on the protocol again means “every admitted owner is done or dropped,” not “some other child may have inherited a copy.”
- A direct external stage cannot early-fork outside the pipeline Job Object. Abort, cancellation, and collection recover the tree ral meant to launch.
- Helper routing remains a semantic choice, not a safety crutch. Direct external stages are safe as direct externals; helper stages are chosen for evaluator, value-edge, redirect, audit, or terminal reasons, not because Windows launch is otherwise porous.
- The Windows and Unix stories line up at the invariant level: the child begins life with exactly the authority and group membership the parent decided.
See also
pipeline-execution, sandbox-external-children, child-eval-unification, value-edge-locality, bundled-tools-as-exec-images, runtime, and io-process.
Full implementation plan
The clean implementation is one launch value and one interpretation point. A child’s creation-time authority must be present in the value handed to the OS, not reconstructed from side effects before and after spawn. Keep the type surface small; make the invariant large.
1. Dependencies and non-dependencies
Use the crates already suited to the job.
windows-sys = "0.61"stays the Win32 FFI crate. Add features only if the compiler asks for them; the currentcoreWindows feature set already includesWin32_Foundation,Win32_Security,Win32_Storage_FileSystem,Win32_System_Console,Win32_System_IO,Win32_System_JobObjects,Win32_System_Memory,Win32_System_Pipes, andWin32_System_Threading.os_pipe = "1"stays the anonymous-pipe crate for the protocol and stdio carriers. On Windows, convert throughstd::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}at the boundary.tempfile = "3.27"is already a dev dependency and is enough for Windows tests that need temporary.bat/.cmdfiles or helper executables.- Do not use
shlex = "2.0"here.shlexis Unix shell lexical syntax. WindowsCreateProcessWreceives one command-line string, parsed later by the target runtime or bycmd.exe; shlex quoting is the wrong language. - Do not add
winapibesidewindows-sys, and do not add the high-levelwindowscrate unless typed COM/HSTRING APIs become necessary. They do not forCreateProcessW, Job Objects, handle lists, or pipes. - Do not depend on a third-party Windows quoting crate unless it is audited
against Rust std’s current tests. The safer default is a small local
windows_argsmodule derived from Rust std’slibrary/std/src/sys/args/windows.rs, with attribution and copied test cases.
Crate search did not turn up a dependency that owns this exact boundary.
process-wrap is a good Command wrapper for process groups / Windows Job
Objects, but it still composes around std::process::Command and returns a
wrapped child, so it cannot express an explicit handle-list CreateProcessW
launch. rappct is useful prior art for Windows AppContainer / LPAC launch with
STARTUPINFOEX, but its policy is AppContainer security capabilities, not ral’s
pipeline handle admission and pgid-shaped Job Object registry. winsplit splits
a command line into argv; ral needs the inverse operation plus batch-file
hardening. tokio-process-tools is async subprocess orchestration, not a raw
creation-time authority layer.
2. Introduce one launch value
Add core/src/process/launch.rs with one owned Launch struct. Reuse existing
types rather than minting a taxonomy:
- image and argv from
runtime::command::vet::{ExecImage, SpawnPlan}; - cwd and environment edits from the existing
build_command/apply_envfacts; - stdio as ordinary
std::process::Stdio/ pipe endpoints until the backend lowers them; - group intent as the existing
PgidPolicy; - one
Vec<RawHandle>or small newtype for Windows-only admitted helper handles; - one
u32creation-flags field for Windows, carrying at leastCREATE_NEW_PROCESS_GROUPwhen the policy requires it.
The first pass may implement Launch::to_command() on Unix and on Windows,
still calling Command::spawn. That pass should not change behaviour; it only
makes launch a value every caller can see.
3. Make ChildHandle the returned process
Stable Rust exposes no public constructor for std::process::Child from a raw
Windows PROCESS_INFORMATION, so raw CreateProcessW cannot honestly return
Child. Generalise crate::process::ChildHandle instead:
- Unix variant: wraps
std::process::Childas today. - Windows variant: owns the process handle, pid, optional
ChildStdoutandChildStderrhandles, and any thread handle that must be closed after resume. - Public methods stay the current small set:
id,kill,take_stdout,take_stderr,wait_handling_stop,try_wait_handling_stop, andreap.
Then migrate RunningChild::assemble_with_owner, PipelineGroup::spawn,
spawn_with_pgid, transport child storage, and the Windows group registry to
accept or return ChildHandle. The runtime above this line should not learn
PROCESS_INFORMATION.
4. Put helper handles in Launch
Change core/src/runtime/pipeline/protocol/windows.rs::pass so it no longer
sets HANDLE_FLAG_INHERIT directly. It should:
- write the numeric handle value into the helper environment exactly as today;
- push the raw handle into
Launch.admitted_handles; - leave ownership of the channel endpoint with
FrameGate/HelperProtocoluntil spawn succeeds.
The helper-side code can keep parsing JOB_HANDLE_ENV, REPORT_HANDLE_ENV,
VALUE_IN_HANDLE_ENV, and VALUE_OUT_HANDLE_ENV. Only the parent-side proof
changes: a helper handle crosses only through the launch value.
5. Add the Windows launch mutex
PROC_THREAD_ATTRIBUTE_HANDLE_LIST is an allow-list, but Windows still requires
listed handles to be inheritable and bInheritHandles=TRUE at creation. That
creates a real process-wide race against any concurrent spawn in the same
process.
Add a Windows-only static LAUNCH_LOCK: Mutex<()> in process::launch::windows
and hold it around:
- setting
HANDLE_FLAG_INHERITon every stdio and admitted handle that must cross; - calling
CreateProcessW; - clearing the inherit bit on those handles.
Every ral-owned Windows spawn should eventually pass through this lock. The lock also protects against missed std spawns or dependency spawns in the same process. It is cheap: process launch is already slow, and the critical section is small.
6. Build the Windows interpreter
Implement process::launch::windows::spawn(Launch) -> io::Result<(ChildHandle, Option<Pgid>)> using windows-sys.
The backend owns:
- UTF-16 application path, command line, cwd, and environment block;
STARTUPINFOEXWsetup and teardown;InitializeProcThreadAttributeList,UpdateProcThreadAttribute, andDeleteProcThreadAttributeList;PROC_THREAD_ATTRIBUTE_HANDLE_LIST;PROC_THREAD_ATTRIBUTE_JOB_LISTwhen available;- fallback
CREATE_SUSPENDED→AssignProcessToJobObject→ResumeThread; CREATE_UNICODE_ENVIRONMENT,EXTENDED_STARTUPINFO_PRESENT, andCREATE_NEW_PROCESS_GROUPpreservation;- RAII for process, thread, job, duplicated-process, and temporary attribute handles.
When a handle-list attribute is present, include every inherited handle in it: stdio handles 0-2 as well as helper protocol handles. The attribute is an allow-list; forgotten stdio means dead stdio.
7. Treat command-line quoting as security
The raw Windows launcher must not invent quoting.
Implement a local windows_args module by porting Rust std’s Windows argument
construction:
- ordinary argv quoting equivalent to std’s
append_arg/make_command_line; - batch-file command construction equivalent to std’s
make_bat_command_line, including the hardening associated with CVE-2024-24576; - tests copied from Rust std’s Windows process/args tests, plus ral cases for spaces, trailing backslashes, embedded quotes, empty args, and non-ASCII.
If that port is not done in the first implementation parcel, the raw Windows
path must refuse .bat and .cmd images with a clear error rather than launch
them unsafely. shlex is not an acceptable fallback.
8. Prepare and register Job Objects
Split core/src/process/signal/windows.rs group management into two phases.
- Prepare before launch. For
NewLeader/NewSession, create the Job Object, install kill-on-close, associate the completion port, and pass the job toLaunch. ForJoin, look up or duplicate the leader’s job handle and pass it toLaunch. - Register after launch. Once
CreateProcessWsucceeds and the child is already in the job, register pid, duplicated process handle, leader handle, and completion-port state inGROUPS.
Delete the warning path that assigns a running child and continues on failure. If a child cannot enter the requested Job Object before start, launch fails. This is intentional even under CI/container hosts that place ral inside a restrictive Job Object. The diagnostic should ask:
could not place pipeline child in its Job Object before start; is ral already running in a restrictive Windows Job Object?
9. Migrate call sites through the choke point
Move in small compiling parcels.
- Convert standalone external launch in
core/src/runtime/command/process.rsto build and spawnLaunch, still usingCommand::spawninternally. - Convert
core/src/runtime/pipeline/group.rsso pipeline stages also spawn throughLaunch. - Generalise
ChildHandleand updateRunningChild, transport, and Windows group registry to stop exposingstd::process::Child. - Switch the Windows backend to raw
CreateProcessW. - Change Windows protocol
passto push admitted handles intoLaunch. - Remove post-spawn
AssignProcessToJobObjectfrom ordinary pipeline launch.
At every step, preserve the public rule of spawn_with_pgid: if it returns, the
child already has the requested group membership and admitted handles.
10. Test the laws
Add unit tests for:
- Windows argv and
.bat/.cmdquoting; - environment-block sorting and NUL rejection;
- handle-list construction including stdio;
- launch-lock cleanup after spawn failure;
- suspended-create cleanup when job assignment fails.
Add Windows integration tests for:
- helper report/value/gate EOF under concurrent ral-owned spawns;
- a direct external that forks immediately still dies when the pipeline Job Object is terminated;
- inherited stdin/stdout/stderr still work when a handle list is present;
- restrictive outer Job Object failure produces the deliberate diagnostic;
- Ctrl-Break fan-out still works, proving
CREATE_NEW_PROCESS_GROUPsurvived.
Non-Windows CI should still exercise Launch by rendering it into the existing
Unix Command path.
11. Remove obsolete text and restamp the wiki
Once the code lands, delete comments that describe the handle-inheritance race or post-spawn job escape as live behaviour. Update pipeline-execution and restamp the affected runtime / io-process map pages in the same commit.
Failure must be loud and specific throughout. If Windows cannot create the requested handle list, say “could not launch pipeline helper with an explicit handle list”. If it cannot place a child in its Job Object before start, say “could not place pipeline child in its Job Object before start”. Those are not performance degradations; they are sandbox-boundary failures.