Add a bounded, killable run with stdin to Executor (#129) - #130
Conversation
roxyd has to drive REview's core-update recovery through REview's own subcommands: one JSON request on stdin, one JSON reply on stdout, under a timeout, as root or as the REview service account. It must not grow a second, private way of running privileged commands beside Executor, so the capability lives here. run_with_input feeds the input, reads stdout and stderr each up to a limit, and kills the command at a deadline, on every transport. The bounds are enforced with poll(2) over non-blocking pipes rather than a reader thread per pipe, since a thread blocked in read cannot be cancelled once a surviving descendant holds a pipe open. A process this one may not signal - a command sudo started as root, or one on the far side of an SSH connection - is killed from the inside by a supervising shell, on the SIGTERM sudo relays and on a deadline of its own. Its cancellation is a SIGKILL to a bare sleep rather than a SIGTERM to a subshell, because dash drops a signal that lands before the subshell installs its trap. The errors get a type of their own so ExecutorError, which bootler matches exhaustively, is unchanged, and the trait method has a refusing default so the executors dependents implement in their tests still compile. Closes #129
macOS's sleep refuses a duration past i32::MAX seconds and exits at once. The supervisor read that as the deadline never arriving, so a very long timeout lost the remote backstop, and the cancelling kill went to a pid already reaped and free for reuse. Clamp the sleep to i32::MAX seconds, which is no bound in practice. Also name the recording executor among the test-support fixtures in the README and AGENTS.md. Part of #129
A command that exits without reading an input larger than a pipe buffer exercises the path where feeding it meets a closed pipe, which must still yield the command's own exit on every transport. The trait's default body is what keeps dependents' executors compiling, so its refusal is pinned too. Part of #129
The supervisor names its shell and `env` absolutely so it depends on no `PATH`, but its deadline `sleep` was still looked up through one. A `PATH` that cannot resolve it makes `sleep` exit at once: the supervisor then loses the deadline that ends a command on the far side of an SSH connection, and its cancelling `kill` goes to a pid already reaped. The supervisor test now runs under a `PATH` holding no utility and fails without this change. Part of #129
The stderr accounting discounts the supervisor's timeout marker once it has arrived in full, but only a half-arrived marker was under test, so a regression would have charged the marker's 19 bytes against max_stderr on every timed-out supervised run. Part of #129
|
[Reviewer Round 1] Verdict: Request changes. The overall approach covers the three executors and has substantial failure-path tests, but two error cases remain incorrect.
The declared deviations are reasonable and accurately described: the separate error type preserves existing exhaustive matches, the |
|
[Review Verdict Round 1: NOT_APPROVED] |
A command that printed the supervisor's fixed timeout marker and then exited normally was reported as timed out, and those bytes were not counted against its stderr limit. Each supervisor now draws a random nonce for its marker, so only that run's own deadline matches it. The stderr limit also applied before a transport failure was classified, so under a small limit a sudo refusal or an SSH connection diagnostic came back as an output-limit breach. Every supervisor now prints the start sentinel, including the SSH operator's. Stderr written before the sentinel is the transport's: it is held to a fixed cap instead of max_stderr and is left out of the returned output, and the failure classifies exactly as run classifies it. Part of #129
|
[Author Round 1] I fixed both review items in 1. The fixed timeout marker could be mistaken for the command's output — Fixed.
2.
Other changes:
|
|
[Reviewer Round 2] [Reviewer Round 2] — Request changes. The two Round 1 items are resolved. The per-run nonce prevents ordinary command output from being mistaken for the timeout marker, and stderr written before the start sentinel is classified as transport output. The updated tests cover both fixes. The declared deviations remain reasonable, and the PR body has the required One new limit-handling issue remains:
|
|
[Review Verdict Round 2: NOT_APPROVED] |
A trailing stderr fragment that might still grow into the supervisor's timeout marker or the SSH exit-status line was left out of the count indefinitely. A command could write one such byte past its limit, keep running, and be reported as timed out instead of being killed for the breach. Such bytes are now held as pending. When counting them would pass the limit, they get a one-second grace to become framing, which is written whole and followed by the stream closing; after that they count and the run ends as an output-limit breach. Part of #129
|
[Author Round 2] Round 2 had one review item and it is fixed. The fix is in 1. Stderr bytes that might become a marker were never counted — Fixed.
Regression tests:
Other changes:
|
|
[Reviewer Round 3] [Reviewer Round 3] — Approve. The Round 2 item is resolved. Pending stderr that could be framing now gets a one-second deadline while pipes are open and after they close; if it remains over the limit, the run returns I found no further issues in the updated diff. The declared deviations remain accurate and justified, and the PR body carries |
|
[Review Verdict Round 3: APPROVED] |
Suggested squash commitTitle Body |
Summary
Adds
Executor::run_with_input, which runs a command as anIdentitywith a request on stdin, a byte limit on each of stdout and stderr, and a timeout that kills the command. roxyd needs it to drive REview's core-update recovery subcommands without adding a separate private way to run privileged commands.LocalExecutor,SshExecutorandInDaemonExecutorall implement it, and so does thetest-supportrecording executor.Final names
Method:
Executor::run_with_input(&self, identity: Identity, command: &str, args: &[&str], input: &[u8], limits: RunLimits) -> Result<CommandOutput, RunWithInputError>Limits:
RunLimits { max_stdout: usize, max_stderr: usize, timeout: Duration }. It has noDefault, because the bounds are the caller's decision.Error:
RunWithInputError, with these variants:InvalidCommand { command }OutputLimit { command, stream: OutputStream, limit }TimedOut { command, timeout }UnsupportedExecutor(ExecutorError), which carries spawn, transport and elevation failures exactly asrunreports them.It is a separate type rather than new
ExecutorErrorvariants. That way, existing exhaustive matches onExecutorErrorkeep compiling, and callers of other methods never see variants that only this method can produce.Streams:
OutputStream::{Stdout, Stderr}Test support:
executor::test_support::RecordingExecutor, withRecordedCall::RunWithInputandScriptedRun::{Output, OutputLimit, TimedOut, Error}.The trait gets a default body that returns
Unsupported, so existing outside implementations ofExecutorstill compile. Every executor in this crate overrides it.Semantics
commandmust be an absolute path with no=in it. Anything else is refused withInvalidCommandbefore anything is spawned or the identity is looked at.=is refused because the command is started throughenv -i, which would read it as a variable assignment.inputis written to the command's stdin, which is then closed. Empty input closes it at once, and the bytes pass through every transport unchanged. On an elevating transport withSudoAuth::Password, the password line goes ahead of the input andsudoconsumes it.PATHlookup.env_clear.sudoor SSH, a supervising/bin/shscript starts the command through/usr/bin/env -i. Whatever environmentsudoor the SSH session set up therefore never reaches it.OutputLimiterror. The limits apply only to the command's own bytes:sudorefusal, ansshconnection diagnostic). It is not counted againstmax_stderrand is not returned. That way, a refusal or a failed connection is still reported as theElevation,SudoRefusedorConnectionerror thatrunreturns, however small the limit. That stderr has a fixed 64 KiB cap of its own instead. Past the cap, the run is abandoned and classified as a transport failure.max_stderr, they get a one-second grace (FRAMING_GRACE) to turn out to be framing — real framing is written whole and the stream closes right behind it. When the grace runs out with the command still running, they count and the run ends asOutputLimit. A command that writes one byte that could open a marker and then hangs is therefore killed about a second after passing its limit, not left running until the timeout.timeoutis aTimedOuterror. Neither a breach nor a timeout ever returns aCommandOutput. When the supervisor's own deadline ends a run, it prints a marker that is unique to that run: a fixed prefix plus a 128-bit nonce fromaws_lc_rs::rand::SystemRandom. A command that prints a marker-shaped string and exits is therefore still reported as having exited.CommandOutput, as withrun.runuses, inresolve_throughon each executor. Only the script thatsudoruns changes, sosudo's flags and descent are identical.InDaemonExecutorruns root without a prefix, descends to a service account withsudo -uwithout ever prompting, and refusesIdentity::Operator.Kill and no-survivor behaviour
The engine is in
src/executor/bounded.rs. A single thread handles all three pipes throughpoll(2)with non-blocking I/O. It doesn't use one thread per pipe because a thread blocked inreadcan't be cancelled when a descendant keeps a pipe open. The child is started in its own process group.SIGKILLand the child is reaped.sudo(local root and service, in-daemon service):sudois sentSIGTERMand relays it to the supervisor. The supervisor then sendsSIGKILLto the command's whole process group from the inside, even if the command ignoresSIGTERM. After a grace period,sudo's own group is killed as a backstop. The supervisor also enforces its own deadline, asleepoftimeoutrounded up to a whole second.sshprocess group is killed at the deadline. No signal reaches the remote side over the connection, so the remote supervisor ends the command at its own deadline, and this call doesn't wait for that. This is the documented limitation. On a breach, the remote command is ended the same way: oncesshis gone its pipes close, and the supervisor's deadline ends it if writing to them doesn't.setsid) can't be reached. A supervised command killed by a signal reports exit code128 + signalrather than none, and starts withSIGINTandSIGQUITignored. All of this is written in the method's rustdoc.Dependencies
No new crate is added. The existing
rustixdependency gets itseventfeature enabled, forpoll. The timeout marker's nonce comes fromaws-lc-rs, which the crate already uses. The standard library has no way to wait for a pipe to become ready and no read timeout on a pipe, and both are needed to enforce the limits and deadline from one thread. The reason is also recorded in a comment inCargo.toml.Unchanged
run,run_root_checked,Identity,ServiceAccountandPrincipalare unchanged, andServiceAccountgets no runtime constructor.CHANGELOG.mdgets no entry because deploy-core has not been released.AGENTS.mdandREADME.mdnow list the recording executor among thetest-supportfixtures.Tests
The tests run every identity/transport pair against stub
sudoandsshprograms, with the supervisor really running on each path. They check:=are refused before spawning;CommandOutput, including when the command leaves its input unread;SIGTERMalong with itsSIGTERM-ignoring child, and both processes are confirmed gone;SIGTERMeach kill the command without any outside help;sudo's argument prefix matchesrun's on every transport;_or a newline) and then hangs is killed asOutputLimitbefore the timeout, and no process survives;runreports them, even with a stderr limit of zero;Closes #129
Deviations from the issue
Result<CommandOutput, ExecutorError>, and the new cases were expected asExecutorErrorvariants. The method instead returnsResult<CommandOutput, RunWithInputError>. That is a newthiserrorenum withInvalidCommand,OutputLimit,TimedOutandUnsupported, and it wraps every existing failure inExecutor(ExecutorError). New variants onExecutorErrorwould break exhaustive matches inbootlerand roxyd, which the "no existing public signature changes" criterion rules out. Those variants would also sit on every other method's error even though only this method can produce them. Callers can still match each case separately.commandbe an absolute path. The implementation also refuses an absolute path containing=, with the sameInvalidCommanderror and before anything is spawned. On every transport that goes throughsudoor SSH, the command starts as/usr/bin/env -i <command> …, andenvtreats an argument containing=as a variable assignment. That would silently run something else or fail oddly, so the check refuses it up front on every transport.Executormethod with no mention of a default. The method has a default body that returnsRunWithInputError::Unsupported. A required method would break everyExecutorimplementation outside this crate, which the "no existing public signature changes" constraint rules out. Every executor this crate ships overrides it, including thetest-supportRecordingExecutor.CommandOutput"as withrun". Where a supervising shell runs the command (undersudo, and on every SSH identity), a command killed by a signal reports128 + signalas its exit code instead ofNone. It also starts withSIGINTandSIGQUITignored. The shell has to run the command in the background so itsSIGTERMtrap can fire while it waits, and a background job in a shell without job control inherits those two signals as ignored. The shell's exit status is all that crossessudoor SSH. Both differences are documented on the method.Test plan
cargo fmt -- --check --config group_imports=StdExternalCratecargo clippy --all-targets -- -D warningscargo clippy --all-targets --features test-support -- -D warningscargo testcargo test --features test-supportRUSTDOCFLAGS="-D warnings" cargo doc --no-deps --document-private-items --features test-supportubuntu-24.04-armandmacos-lateststdin_arrives_exactly_on_every_pair: an empty input, a 65,536-byte input covering every byte value, and a small JSON request each come back unchanged on all eight identity/transport pairsa_password_line_is_consumed_before_the_input_on_the_elevating_transports: withSudoAuth::Password, the local and SSH transports feed the password line tosudoand the 65,536-byte input still reaches the command intactthe_command_sees_an_empty_environment_on_every_pair:/usr/bin/envprints nothing on every paira_command_that_is_not_an_absolute_path_is_refused_before_spawning:touch,./touchand/usr/bin/touch=xare refused withInvalidCommandand nothing runs;InDaemonExecutorrefuses a relative command before it rejectsIdentity::Operatora_nonzero_exit_is_a_command_output_on_every_pair: exit 7 comes back as aCommandOutputholding only the command's own stdout and stderra_command_that_leaves_its_input_unread_still_reports_its_exit_on_every_pair: a command that exits without reading 256 KiB of input still reports its exit code and outputeach_stream_may_reach_its_limit_but_not_pass_it_on_every_pair: stdout and stderr pass at exactly their limit, and one byte over isOutputLimitnaming the right stream and limita_breach_kills_a_command_that_ignores_sigterm_on_every_pair: a stdout flood from a command that ignoresSIGTERMends asOutputLimitbefore the timeout, and both the command and itsSIGTERM-ignoring child are gonea_timeout_kills_a_command_that_ignores_sigterm_on_every_pair: the same command ends asTimedOutwith the configured timeout, and no process survivesa_command_that_closes_its_streams_is_still_held_to_the_timeout: a command that closes all three pipes is still killed at the deadlinean_unbounded_timeout_runs_the_command_to_completion_on_every_pair:Duration::MAXfinishes normally, with no immediate timeout from an oversizedsleepthe_supervisor_kills_the_command_at_its_own_deadline: with no outside signal and no usablePATH, the supervisor script prints its timeout marker and kills the whole groupa_relayed_sigterm_makes_the_supervisor_kill_the_command: aSIGTERMto the supervisor alone kills a command that ignores itidentities_resolve_through_sudo_exactly_as_run_resolves_them:sudo's arguments ahead of the shell matchrun's for local root and service, SSH root and service, and the in-daemon service descent; local operator, SSH operator and in-daemon root run with nosudoa_command_that_prints_a_timeout_marker_still_exits_on_every_pair: a command prints the old fixed marker and a well-formed marker with another run's nonce, then exits. It comes back as aCommandOutputat exactly its stderr limit, and asOutputLimitone byte undera_byte_over_the_limit_that_could_open_a_marker_kills_a_running_command_on_every_pair: withmax_stderr0, a command that ignoresSIGTERMwrites_or a newline to stderr and then hangs. Every pair ends it asOutputLimiton stderr before the timeout, and the command and its child are goneelevation_and_transport_failures_are_reported_as_run_reports_them: roomy limits and zero limits both give the sameExecutorErrorvariants thatrunreturns, for each of these: a password-wantingsudo(local and over SSH), a rejected password, a sudoers denial on the in-daemon descent, an unreachable SSH host (operator and root), andIdentity::Operatorinside the daemona_transport_that_floods_stderr_before_the_command_starts_is_refused: asudowrites 1 MiB of stderr without granting and then hangs. The run is abandoned before its timeout and reported asSudoRefusedan_executor_that_does_not_implement_it_refuses_as_unsupported: the trait's default body returnsUnsupportedwithout callingrunexecutor::bounded:executor::test_support:RecordingExecutorrecords the identity, command, arguments, stdin bytes and limits of each call, returns each scripted outcome (output, limit breach, timeout, error), and refuses a relative command as the real executors do