fix(runner): preserve verified runtime across descendants

This commit is contained in:
Dotta 2026-09-02 20:50:25 -05:00
parent 492ba3a46b
commit e9998cbfdb
3 changed files with 90 additions and 22 deletions

View File

@ -290,9 +290,12 @@ pub fn run_durable_runner<E: CommandExecutor>(
.durable_state()
.filter(|_| !disconnected)
{
executor.shutdown()?;
state.lifecycle = durable_lifecycle.to_owned();
store.save(&state)?;
persist_lifecycle_before_shutdown(
&mut state,
&store,
&mut executor,
durable_lifecycle,
)?;
return Ok(());
}
if disconnected {
@ -378,9 +381,12 @@ pub fn run_durable_runner<E: CommandExecutor>(
break;
}
if let Some(durable_lifecycle) = lifecycle.durable_state() {
executor.shutdown()?;
state.lifecycle = durable_lifecycle.to_owned();
store.save(&state)?;
persist_lifecycle_before_shutdown(
&mut state,
&store,
&mut executor,
durable_lifecycle,
)?;
return Ok(());
}
}
@ -396,10 +402,13 @@ pub fn run_durable_runner<E: CommandExecutor>(
"revoke must advance the authenticated revocation epoch",
));
}
state.lifecycle = "revoked".to_owned();
state.record_diagnostic("connection capability was revoked");
executor.shutdown()?;
store.save(&state)?;
persist_lifecycle_before_shutdown(
&mut state,
&store,
&mut executor,
"revoked",
)?;
return Ok(());
}
Some("ping") => {
@ -431,6 +440,20 @@ pub fn run_durable_runner<E: CommandExecutor>(
}
}
fn persist_lifecycle_before_shutdown<E: CommandExecutor>(
state: &mut DurableState,
store: &DurableStateStore,
executor: &mut E,
lifecycle: &str,
) -> Result<(), DurableRunnerError> {
// A terminal command result is already durable before this boundary. Save
// its matching lifecycle before fallible provider cleanup so recovery can
// never replay a ready runner after the command itself became terminal.
state.lifecycle = lifecycle.to_owned();
store.save(state)?;
executor.shutdown()
}
fn poll_executor_events<E: CommandExecutor>(
state: &mut DurableState,
store: &DurableStateStore,
@ -593,6 +616,8 @@ mod tests {
calls: usize,
}
struct ShutdownFailingExecutor;
struct RetainingEventExecutor {
events: VecDeque<PolledEvent>,
fail_acknowledgement: bool,
@ -614,6 +639,18 @@ mod tests {
}
}
impl CommandExecutor for ShutdownFailingExecutor {
fn execute(&mut self, _command: &Command) -> Result<CommandExecution, DurableRunnerError> {
Ok(CommandExecution::result(json!({"status": "completed"})))
}
fn shutdown(&mut self) -> Result<(), DurableRunnerError> {
Err(DurableRunnerError::invalid(
"simulated terminal cleanup failure",
))
}
}
impl CommandExecutor for RetainingEventExecutor {
fn execute(&mut self, _command: &Command) -> Result<CommandExecution, DurableRunnerError> {
Ok(CommandExecution::result(json!({"status": "completed"})))
@ -676,6 +713,28 @@ mod tests {
}
}
#[test]
fn terminal_lifecycle_is_durable_before_fallible_cleanup() {
let directory = std::env::temp_dir().join(format!(
"paperclip-runner-terminal-before-cleanup-{}",
std::process::id()
));
let _ = fs::remove_dir_all(&directory);
let config = config(directory.clone());
let store = DurableStateStore::new(&directory).unwrap();
let (mut state, _) = store.load_or_create(&config).unwrap();
let mut executor = ShutdownFailingExecutor;
let error = persist_lifecycle_before_shutdown(&mut state, &store, &mut executor, "stopped")
.expect_err("cleanup failure remains observable");
let (recovered, existed) = store.load_or_create(&config).unwrap();
assert!(error.to_string().contains("terminal cleanup failure"));
assert!(existed);
assert_eq!(recovered.lifecycle, "stopped");
fs::remove_dir_all(directory).unwrap();
}
#[test]
fn event_batch_keeps_accepted_prefix_and_unacknowledged_suffix() {
let directory = std::env::temp_dir().join(format!(

View File

@ -6,7 +6,7 @@ import {
} from "./verified-runtime-executable.js";
describe("verified runtime executable", () => {
it("anchors an inherited Linux descriptor at its current owner", () => {
it("projects an inherited Linux descriptor through the live process image", () => {
expect(
verifiedRuntimeExecutable(
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/self/fd/17" },
@ -14,18 +14,29 @@ describe("verified runtime executable", () => {
4321,
"/usr/bin/node",
),
).toBe("/proc/4321/fd/17");
).toBe("/proc/self/exe");
});
it("preserves an already anchored descendant runtime", () => {
it("preserves the live process image for verified descendants", () => {
expect(
verifiedRuntimeExecutable(
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/self/exe" },
"linux",
8765,
"/usr/bin/node",
),
).toBe("/proc/self/exe");
});
it("rejects ancestor descriptor paths at the verified boundary", () => {
expect(() =>
verifiedRuntimeExecutable(
{ [VERIFIED_RUNTIME_EXECUTABLE_ENV]: "/proc/4321/fd/17" },
"linux",
8765,
"/usr/bin/node",
),
).toBe("/proc/4321/fd/17");
).toThrow("descriptor is invalid");
});
it("rejects mutable Linux paths at the verified boundary", () => {

View File

@ -6,25 +6,23 @@ export const VERIFIED_RUNTIME_EXECUTABLE_ENV =
/**
* Recover the runner-authenticated executable inherited by a descriptor-loaded
* sidecar. Linux children cannot use process.execPath here: Node resolves the
* sealed image to a deleted memfd alias. Anchor the descriptor at the current
* owner process before launching a descendant, whose own `/proc/self` would
* otherwise name the wrong descriptor table.
* sealed image to a deleted memfd alias. Once the inherited descriptor has
* authenticated the current image, `/proc/self/exe` keeps that exact live
* image available to every fork/exec generation without granting a descendant
* access to an ancestor's descriptor table.
*/
export function verifiedRuntimeExecutable(
environment: NodeJS.ProcessEnv = process.env,
platform: NodeJS.Platform = process.platform,
currentPid: number = process.pid,
_currentPid: number = process.pid,
fallback: string = process.execPath,
): string {
const configured = environment[VERIFIED_RUNTIME_EXECUTABLE_ENV];
if (configured === undefined) return fallback;
if (platform === "linux") {
const match = /^\/proc\/self\/fd\/([0-9]+)$/.exec(configured);
if (match) return `/proc/${currentPid}/fd/${match[1]}`;
if (/^\/proc\/[1-9][0-9]*\/fd\/[0-9]+$/.test(configured)) {
return configured;
}
if (/^\/proc\/self\/fd\/[0-9]+$/.test(configured)) return "/proc/self/exe";
if (configured === "/proc/self/exe") return configured;
throw new Error("Verified runtime executable descriptor is invalid");
}