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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 58 additions & 11 deletions docs/isolation-session/state-aware-rust.md
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,13 @@ State-aware exec (and other phases) use OS-level cancellation in v1:
- The SDK kills the `wxc-exec` process via process termination.
- The agent process's pipes EOF, the relay threads exit.
- The OS-side service's per-process timer (set from
`process.timeout`) reaps the agent if the runner does not.
`process.timeout`) reaps the agent if the runner does not. On the
streaming path that timer is armed with a **margin** past the caller's
deadline, so it acts purely as a watchdog: the service kills with an
ordinary exit code (the host suite pins it as exit code 1), so if it
fired first a genuine timeout would be indistinguishable from a normal
exit and could not be reported as one. The run-to-completion path arms
it unchanged β€” it has no timeout channel to report through.
- The runner's existing 3-tier shutdown (`CloseStandardInput` β†’
`SendCtrlClose` β†’ `Terminate`) handles the timeout case from inside the
agent process before returning.
Expand All @@ -461,23 +467,64 @@ without waiting and returns a terminator that calls
`IsoSessionProcess::Terminate()`, alongside the real pipe handles and a
waiter that blocks on exit.

That terminator reports **nothing**: `ExecHandle.terminator` is an
infallible `FnOnce()`, so the backend requests termination and discards
whether the platform even accepted it. The underlying
`StartedProcess::terminate` does distinguish an accepted request from a
rejected one β€” it is the handle type that has nowhere to carry the
answer. Teardown is also not bounded: the streaming adapter's `Drop`
joins the waiter unconditionally, and nothing in that path caps the
wait. A process that survives the kill can park that waiter by either of
That terminator now reports whether the platform **accepted** the kill:
`ExecHandle.terminator` returns a `Result`, and the answer
`StartedProcess::terminate` already computed reaches the caller through
`SandboxProcess::kill`. What it still cannot say is whether the process
actually died β€” that would need the bounded post-kill wait's result,
which the handle type does not carry.

The waiter likewise distinguishes a timeout from an exit, returning
`ExecOutcome::TimedOut` when the deadline elapsed while the process was
running. Neither available signal proves that on its own: `WaitForExit`
answers `-1` on timeout and `ExitCode()` reads `STILL_ACTIVE` (259), and
both are legal exit codes for untrusted code. Their conjunction
establishes whether the process was still running when it was sampled,
which is what the ladder decision needs.

A spent deadline is **sticky**. The two reads are not atomic, so a
process can exit in the window between them; that still reports
`TimedOut`, because the sentinel proves the deadline elapsed while the
process ran and a later-observed exit code does not un-spend it.
Reporting that code would hide a missed deadline from a caller who asked
for one. The sibling WSLc backend draws the same line, tracking
`deadline_elapsed` separately from `timed_out`. The one irreducible case
is `-1`/`-1`, where the sentinel and the exit code collide and nothing
distinguishes them, so it is read as the exit.

`TimedOut` promises the process is **gone**, not that MXC killed it, and
it reaches only the foreground process: `IsoSessionProcess` exposes
`Terminate` and `ExitCode` and no tree primitive, so a descendant the
workload backgrounded outlives a reported timeout on either path and is
reclaimed when the session is stopped and deprovisioned.

An exited process is never routed through the shutdown ladder, which reads only `ExitCode()`
and so cannot tell a `259` exit from a live process. The adapter maps
`TimedOut` onto `ErrorKind::TimedOut`, which is what
`mxc_sdk::Sandbox::wait` reads as `WaitOutcome::TimedOut`. That outcome
is reachable only for an in-process consumer: the executor has no
timeout field in `ScriptResponse` to report one through, so the executor
arm keeps reporting `Exited`.

Teardown is bounded only insofar as the kill is: the streaming adapter's
`Drop` joins the waiter when the kill was accepted, and abandons the
thread when it was refused rather than blocking forever in a `Drop` the
caller cannot opt out of. A process that survives an *accepted* kill can
still park that waiter by either of
two routes β€” in its leading `WaitForExit` (INFINITE when the caller
supplied no timeout), or, when a timeout was supplied, in the graceful
ladder's tier 3, which is `Terminate` followed by an INFINITE
`WaitForExit(0)`. Neither is certain to stall: tier 1 ends a child that
exits on stdin EOF once the caller has dropped its own duplicate of the
write end, and tier 3's `Terminate` is a fresh attempt that may land
where the first did not. The narrow claim is that nothing bounds the
join if the process does survive. Making the terminator fallible end to
end, and bounding that join, is future work.
join if the process does survive. The terminator is now fallible end to
end, so a *refused* kill is reported and the join is skipped; bounding
the join after an *accepted* kill that never took effect is future work,
and needs the backend to confirm the process actually died. A **confirmed
exit** retires the terminator entirely: once the waiter has reported an
outcome there is nothing to kill, so `kill` succeeds without running the
terminator and an earlier refusal no longer applies.

## Known issues

Expand Down
32 changes: 28 additions & 4 deletions docs/state-aware-lifecycle/mxc-state-aware-sandbox-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,15 @@ pub trait StatefulSandboxBackend {
/// stderr may therefore arrive merged into stdout, leaving
/// `ExecHandle::stderr` null. A backend that probes the host to decide how
/// to wire stdio must confine that probe to the `Executor` case.
///
/// A backend that cannot serve `Library` at all β€” because it relays the
/// workload's output to the *host process's* own stdio rather than
/// returning streams β€” must refuse **before running anything**. The
/// workload is arbitrary and may not be idempotent, so a refusal issued
/// after the fact reports "unsupported" for something that has already
/// taken effect and whose output has already gone somewhere the caller
/// never asked for. `wxc_common::state_aware_backend::unsupported_library_exec`
/// is the shared refusal.
fn exec(
&mut self,
sandbox_id: &str,
Expand Down Expand Up @@ -1331,10 +1340,25 @@ pub struct ExecHandle {
/// Stdin pipe handle. Not consumed by the executor relay, which forwards
/// no input; the streaming path hands it to an in-process caller.
pub stdin: PipeHandle,
/// Function to wait for exit; returns the exit code.
pub waiter: Box<dyn FnOnce() -> Result<i32, MxcError> + Send>,
/// Function to terminate the process (called on AbortSignal).
pub terminator: Box<dyn FnOnce() + Send>,
/// Function to wait for exit; returns how the exec finished.
pub waiter: Box<dyn FnOnce() -> Result<ExecOutcome, MxcError> + Send>,
/// Function to terminate the process (called on AbortSignal). Fallible: a
/// platform that refuses the request must be able to say so, because a
/// caller that assumes a refused kill succeeded can block forever waiting
/// on a process that is still running.
pub terminator: Box<dyn FnOnce() -> Result<(), MxcError> + Send>,
}

/// How an exec finished, as distinct from why a wait failed. A timeout is an
/// outcome β€” the deadline was spent while the process ran, and the process is
/// no longer running β€” whereas `Err` means the exit could not be determined.
/// Deliberately not "the backend killed it": a workload that overruns its
/// deadline and then exits on its own has still missed it, and how far the
/// termination reaches is the backend's to state. Only `ExecConsumer::Library`
/// can observe `TimedOut`; the executor relay has no field to carry it.
pub enum ExecOutcome {
Exited(i32),
TimedOut,
}
```

Expand Down
Loading
Loading