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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/codex-runtime/tests/runtime_binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ async fn hello_on_live_socket() {
match parsed.result {
Some(RunnerOpResult::Hello { protocol, .. }) => {
assert_eq!(protocol, WIRE_PROTOCOL);
assert_eq!(protocol, 8);
assert_eq!(protocol, 9);
}
other => panic!("unexpected hello {other:?}"),
}
Expand Down
16 changes: 16 additions & 0 deletions crates/runner/src/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,22 @@ pub fn default_exec_timeout_ms() -> u64 {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunnerExecResult {
pub process_id: ProcessId,
/// The root process the Runner created (P1R-C02). A PTY root is not named.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<RunnerProcessRoot>,
/// When the Runner times the process out, in milliseconds since the Unix epoch
/// (P1R-C02). The Runner's own timer decides; this only reports it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline_at_ms: Option<u64>,
}

/// A process's root as the Runner created it (P1R-C02): its PID and, where the host reports
/// it, the start identity that tells it apart from a later process with the same PID.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RunnerProcessRoot {
pub pid: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub started: Option<String>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
Expand Down
33 changes: 26 additions & 7 deletions crates/runner/src/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ use crate::admission::{
};
use crate::process::spawn_env;
use crate::registration::error_code;
use crate::{RunnerCwd, RunnerExecRequest};
use crate::{RunnerCwd, RunnerExecRequest, RunnerExecResult, RunnerProcessRoot};

/// How long a launch waits for the helper's transcript by default.
pub const DEFAULT_READY_BOUND: Duration = Duration::from_secs(10);
Expand Down Expand Up @@ -245,6 +245,13 @@ pub struct ManagedLaunch {
/// The scope's root as DevGuard bound it: the helper's PID, which the executable kept.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub scope_root_pid: Option<u32>,
/// The helper's process as the Runner created it, which the executable replaces
/// (P1R-C02). A PTY root is not named.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub root: Option<RunnerProcessRoot>,
/// When the Runner times the process out, in milliseconds since the Unix epoch (P1R-C02).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deadline_at_ms: Option<u64>,
}

/// How a governed execution's launch ended for its request.
Expand Down Expand Up @@ -389,11 +396,11 @@ impl PreparedExecution {
.runner
.spawn_managed(&self.ws, request.clone(), slot, invocation, hooks)
.await;
if spawned.is_err() {
let Ok(spawned) = spawned else {
// No helper exists, and none will be created for this grant.
let attempt = self.abandon(Abandon::SpawnFailed).await;
return self.not_launched(PreparationOutcome::HelperNotCreated, attempt);
}
};
phases.push(LaunchPhase::HelperCreated);
let (sender, ended) = tokio::sync::oneshot::channel();
tokio::task::spawn_blocking(move || {
Expand All @@ -420,7 +427,9 @@ impl PreparedExecution {
}
}
});
return self.launched(phases, LaunchDispatch::Unknown).await;
return self
.launched(phases, LaunchDispatch::Unknown, &spawned)
.await;
}
};
match end {
Expand All @@ -432,7 +441,8 @@ impl PreparedExecution {
LaunchPhase::PayloadExecAttempted,
LaunchPhase::PayloadRunning,
]);
self.launched(phases, LaunchDispatch::Confirmed).await
self.launched(phases, LaunchDispatch::Confirmed, &spawned)
.await
}
TranscriptEnd::Uncertain => {
// Whether the executable started is not known. Once the process has ended this
Expand All @@ -442,7 +452,8 @@ impl PreparedExecution {
tokio::spawn(async move {
settle.after_exit().await;
});
self.launched(phases, LaunchDispatch::Unknown).await
self.launched(phases, LaunchDispatch::Unknown, &spawned)
.await
}
TranscriptEnd::ExecFailed { errno } => {
// The helper claimed the grant before READY, so its scope settles it; the
Expand Down Expand Up @@ -489,11 +500,13 @@ impl PreparedExecution {
LaunchReport::NotLaunched(self.report(outcome, attempt))
}

/// The launched report, with what DevGuard's record shows of the run.
/// The launched report, with what DevGuard's record shows of the run and what the spawn
/// reported of its process.
async fn launched(
&mut self,
phases: Vec<LaunchPhase>,
dispatch: LaunchDispatch,
spawned: &RunnerExecResult,
) -> LaunchReport {
let mut account = self.account;
let mut scope_root_pid = None;
Expand Down Expand Up @@ -523,6 +536,8 @@ impl PreparedExecution {
phases,
account: Some(account),
scope_root_pid,
root: spawned.root.clone(),
deadline_at_ms: spawned.deadline_at_ms,
})
}

Expand Down Expand Up @@ -865,6 +880,10 @@ report() {{ eval "printf '%s\n' \"\$1\" >&$report"; }}
);
assert_eq!(launch.process_id.0, process);
assert_eq!(launch.scope_root_pid, Some(4242));
// The Runner names the helper's process, which the command replaced, and when its
// timeout ends (P1R-C02); the pinned Codex PTY does not name its child.
assert_eq!(launch.root.is_some(), !tty, "{launch:?}");
assert!(launch.deadline_at_ms.is_some(), "{launch:?}");
assert_eq!(
launch.account.unwrap().applied,
Applied::Launched {
Expand Down
4 changes: 2 additions & 2 deletions crates/runner/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,8 +124,8 @@ mod wire;
pub use api::{
default_exec_timeout_ms, runner_local_exec_env, RunnerApplyPatchRequest,
RunnerApplyPatchResult, RunnerCwd, RunnerError, RunnerExecEnv, RunnerExecPolicy,
RunnerExecRequest, RunnerExecResult, RunnerProcessStatus, RunnerReadProcess, RunnerReadResult,
RunnerResizeResult, RunnerWriteStdin, DEFAULT_TIMEOUT_MS, MAX_OUTPUT_BYTES,
RunnerExecRequest, RunnerExecResult, RunnerProcessRoot, RunnerProcessStatus, RunnerReadProcess,
RunnerReadResult, RunnerResizeResult, RunnerWriteStdin, DEFAULT_TIMEOUT_MS, MAX_OUTPUT_BYTES,
};
pub use codespace_domain::{DEFAULT_FIND_LIMIT, DEFAULT_READ_LIMIT};
pub use descriptors::exclude_unrelated;
Expand Down
107 changes: 107 additions & 0 deletions crates/runner/src/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,54 @@ fn boot_id() -> Option<String> {
None
}

/// The start identity of process `pid` (P1R-C02): with the PID it names that one process, and
/// tells it apart from a later process that reuses the PID. Read while the process cannot have
/// been reaped yet, as a child that only its own reaper waits for, it is that process's. `None`
/// where the host does not report it.
#[cfg(target_os = "linux")]
pub(crate) fn process_start_identity(pid: u32) -> Option<String> {
// `stat`'s 22nd field: the start time in clock ticks since boot. The command name before it
// is in parentheses and may itself hold spaces and parentheses.
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let fields = &stat[stat.rfind(')')? + 1..];
let ticks: u64 = fields.split_whitespace().nth(19)?.parse().ok()?;
Some(format!("linux-start-ticks:{ticks}"))
}

/// The start identity of process `pid` (P1R-C02): with the PID it names that one process, and
/// tells it apart from a later process that reuses the PID. Read while the process cannot have
/// been reaped yet, as a child that only its own reaper waits for, it is that process's. `None`
/// where the host does not report it.
#[cfg(target_os = "macos")]
pub(crate) fn process_start_identity(pid: u32) -> Option<String> {
// SAFETY: an all-zero `proc_bsdinfo` is a valid value of this plain C structure.
let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
let size = std::mem::size_of::<libc::proc_bsdinfo>() as libc::c_int;
// SAFETY: proc_pidinfo writes at most `size` bytes into `info`. A non-zero argument asks
// for an exited process that is not reaped yet too.
let written = unsafe {
libc::proc_pidinfo(
libc::c_int::try_from(pid).ok()?,
libc::PROC_PIDTBSDINFO,
1,
(&mut info as *mut libc::proc_bsdinfo).cast(),
size,
)
};
if written != size || info.pbi_pid != pid {
return None;
}
Some(format!(
"macos-start:{}.{:06}",
info.pbi_start_tvsec, info.pbi_start_tvusec
))
}

#[cfg(not(any(target_os = "linux", target_os = "macos")))]
pub(crate) fn process_start_identity(_pid: u32) -> Option<String> {
None
}

/// 128 random bits in hex. A host without `/dev/urandom` gets the time and the process ID
/// instead: the nonce tells Runner processes apart, it guards nothing.
fn nonce() -> String {
Expand Down Expand Up @@ -136,6 +184,65 @@ mod tests {
assert_eq!(RunnerIdentity::this_process(), identity);
}

/// Whether the child `pid` has exited and is not reaped yet.
fn exited_unreaped(pid: u32) -> bool {
// SAFETY: an all-zero `siginfo_t` is valid, and zero `si_pid` is how `waitid` with
// `WNOHANG` reports that no child has exited.
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
// SAFETY: `info` is a valid `siginfo_t`; `WNOWAIT` leaves the child waitable.
let result = unsafe {
libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
)
};
// SAFETY: `waitid` filled `info`, or left the zero it was given.
result == 0 && unsafe { info.si_pid() } != 0
}

/// A process's start identity is the same for as long as the process holds its PID, until
/// it is reaped: a root whose identity the Runner reads before its reaper runs is never
/// confused with a later process that reuses the PID (P1R-C02).
#[test]
fn a_start_identity_names_one_process_until_it_is_reaped() {
let own = process_start_identity(std::process::id());
if cfg!(not(any(target_os = "linux", target_os = "macos"))) {
assert_eq!(own, None);
return;
}
let own = own.expect("this process's start identity");
let mut child = std::process::Command::new("sleep")
.arg("30")
.spawn()
.unwrap();
let pid = child.id();
let started = process_start_identity(pid).expect("a running child's start identity");
assert_ne!(started, own);
assert_eq!(process_start_identity(pid).as_ref(), Some(&started));
child.kill().unwrap();
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
while !exited_unreaped(pid) {
assert!(
std::time::Instant::now() < deadline,
"the child did not exit"
);
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert_eq!(
process_start_identity(pid).as_ref(),
Some(&started),
"an exited child that is not reaped yet"
);
child.wait().unwrap();
assert_ne!(
process_start_identity(pid).as_ref(),
Some(&started),
"a reaped child"
);
}

#[test]
fn lifecycles_cross_the_wire_by_name() {
assert_eq!(
Expand Down
Loading
Loading