fix(runner): preserve verified runtime across descendants
This commit is contained in:
parent
492ba3a46b
commit
e9998cbfdb
|
|
@ -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!(
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue