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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,13 @@ after its public API and format compatibility policies are established.
witness, reproducible Gear-table recipe, scalar and streaming FastCDC laws,
source mutations, and exact boundary corpus; the three superseded Python
programs have been removed.
- Golden File Worldline and protocol-conformance `b3sum` witnesses now share
one external-digest process boundary. Its deadline begins before process
spawn and stdin transfer, stdin is streamed without a combined preimage
allocation, stdout and stderr have independent limits, and every timeout or
collection failure kills and reaps the process group while retaining typed
failure context. Child reaping and stalled-reader retirement use fixed
per-step cleanup grace periods instead of blocking without limit.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
- Fuzz policy admission, target reconciliation, bounded campaign execution,
minimization deadlines, retained-corpus admission, and workflow contract
tests now run through the repository's Rust `xtask`; the superseded Python
Expand Down
9 changes: 7 additions & 2 deletions docs/adr/0007-terminal-signal-process-group-guard.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,20 @@ per-stream capture limits.

An observed terminal signal becomes a typed `ProcessError::Interrupted`
refusal. The normal failure path then sends `SIGKILL` to the dedicated child
process group, kills and reaps the child, and joins captured-output readers
before returning. Captured processes collect and join both readers before the
process group, kills and reaps the child, and retires captured-output readers
before returning. Captured processes collect both reader results before the
successful child wait consumes the process-group leader's waitable identity.
Every reader failure that can invoke group cleanup therefore occurs while that
identity is still owned, and no cleanup path addresses a numeric process-group
ID after the child has been reaped. Output-limit and late-interrupt refusals
found after a successful wait require no cleanup because the child and readers
have already terminated.

[ADR-0008](0008-deadline-bounded-reader-retirement.md) governs failed-operation
reader retirement. It permits deadline-bounded retirement and explicit
detachment when a reader cannot prove completion after process cleanup; it does
not permit an unbounded join.

The terminal signal is not sent directly to the child group; one cleanup
authority avoids races between signal delivery and mandatory process-group
termination.
Expand Down
89 changes: 89 additions & 0 deletions docs/adr/0008-deadline-bounded-reader-retirement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
# ADR-0008: Deadline-Bounded Reader Retirement

- Status: Accepted
- Date: 2026-07-28
- Owners: Keep repository verification
- Related issue: #59
- Depends on: ADR-0007

## Context

Repository verification tasks run external tools synchronously and drain
standard output and standard error on dedicated reader threads. Concurrent
draining prevents a child from blocking when either pipe fills, and per-stream
capture limits prevent unbounded retained output.

A failed child does not guarantee that a reader thread can be joined. A
descendant may retain an inherited pipe, an injected reader may stop making
progress, or an operating-system read may remain blocked after the primary
operation has failed. An unbounded join would turn a typed timeout,
interruption, or collection refusal into an unbounded caller hang.

Stable Rust does not provide a safe operation that cancels an arbitrary blocked
thread. Keep forbids unsafe code, and asynchronous process I/O would introduce a
runtime and a second process-lifecycle model without a demonstrated consumer
need.

## Decision

Failed captured-process operations perform cleanup in this order:

1. Drop the parent's child-standard-input handle.
2. Terminate the child's dedicated process group.
3. Kill the direct child as an idempotent fallback.
4. Poll direct-child reaping for one fixed per-step cleanup grace.
5. Give each reader worker one fixed per-step cleanup grace to publish its
bounded result.

Reader retirement is successful when the caller already received the worker's
only result or the result arrives within the cleanup grace. If a worker remains
blocked, its join handle is detached. This bounded retirement is permitted only
on an already-failed operation; it can never convert a failure into success.

The returned error retains the primary failure as its source. A reader
retirement timeout is attached as an additional typed failure, so bounded
retirement does not hide either the cause of the operation failure or the
incomplete cleanup observation.

Process-group termination and direct-child reaping precede reader retirement.
For real child pipes, terminating every process that could retain the pipe is
the operation that makes reader completion reachable. The retirement grace is
not a durability claim and does not prove that an arbitrary injected reader has
stopped; it bounds how long repository verification waits for that evidence.

## Alternatives considered

- Joining every reader without a deadline was rejected because one inherited or
stalled pipe could hang repository verification forever.
- Detaching every reader immediately was rejected because it would discard
available read failures and make ordinary cleanup nondeterministic.
- Cancelling blocked threads through platform-specific or unsafe APIs was
rejected because Rust cannot make arbitrary cancellation memory-safe and
Keep forbids unsafe code.
- Moving subprocess capture to an asynchronous runtime was rejected because the
synchronous core already has explicit deadlines and no consumer requires an
async process boundary.
- Reading the streams sequentially was rejected because either child pipe can
fill while the other stream is being drained.

## Consequences

Every failed captured-process operation returns after bounded cleanup steps.
The caller receives the primary typed failure plus any observed cleanup
failure. Successful operations still require both bounded reader results and
the direct child's exit status.

A detached reader thread may remain alive until its underlying read completes
or the process exits. It owns only its reader, bounded output accumulator, and
single-result sender. It owns no Keep data, repository lock, mutation
authority, or caller reference. This residual lifetime is explicit uncertainty,
not a claim that cleanup completed.

The two-second cleanup grace is a per-step bound. It is separate from the
operation deadline and may be consumed once for child reaping and once for each
reader. Tests use zero-duration injected retirement and reap boundaries; they
do not classify behavior from scheduler timing.

This decision changes no Keep library API, content identity, durable format, or
recovery protocol. It governs only private repository-process orchestration in
`xtask`.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,3 +59,4 @@ encryption, concurrency, or public-API surface it governs.
- [ADR-0005: Durable segment store protocol](0005-durable-segment-store-protocol.md)
- [ADR-0006: Descriptor-bound child working directory](0006-descriptor-bound-child-working-directory.md)
- [ADR-0007: Terminal signal process-group guard](0007-terminal-signal-process-group-guard.md)
- [ADR-0008: Deadline-bounded reader retirement](0008-deadline-bounded-reader-retirement.md)
10 changes: 9 additions & 1 deletion docs/conformance/golden-file-worldline.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,15 @@ test-only oracle is not a production streaming API and provides no:

The model computes identity through Keep's public `BlobId` API, while the
checked-in vectors were generated independently with `b3sum`. Agreement is
therefore not a self-authored golden.
therefore not a self-authored golden. The repository checker streams each
canonical preimage through the same deadline-bounded external-digest adapter
used by protocol conformance. The deadline clock starts before synchronous
spawn. After spawn returns, its remaining time bounds stdin transfer and output
collection; stdout and stderr have independent byte limits; and timeout or
collection failure kills the complete child process group. Child reaping and
reader retirement use fixed per-step cleanup grace periods and remain typed if
teardown cannot be proved. Digest-specific preimage construction remains owned
by this checker rather than the process adapter.

## Exact M1 acceptance boundary

Expand Down
11 changes: 7 additions & 4 deletions docs/dependencies/blake3-1.8.5.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,13 @@ types or hashing wrappers. This separation independently checks preimage
framing, length encoding, canonical text and binary encodings, mutation
semantics, and committed witness bytes. For every identity and content mutation,
the repository checker also streams the canonical preimage through external
`b3sum`; a mismatch with the in-process result is a refusal. The checked-in
vectors and runtime cross-check therefore cover the algorithm boundary without
claiming that the Rust path independently implements the BLAKE3 compression
function.
`b3sum`; a mismatch with the in-process result is a refusal. Golden File
Worldline and protocol conformance share one deadline-bounded process adapter,
but retain separate preimage construction. The
[Golden File Worldline reference model](../conformance/golden-file-worldline.md#reference-model)
records the process-adapter contract. The checked-in vectors and runtime
cross-check therefore cover the algorithm boundary without claiming that the
Rust path independently implements the BLAKE3 compression function.

The manifest disables default features and enables exactly:

Expand Down
17 changes: 10 additions & 7 deletions docs/dependencies/cap-std-and-cap-fs-ext-4.0.2.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,12 @@ These packages are absent from Keep's published library graph, public API,
content identities, durable formats, and production behavior. No
dependency-owned type crosses out of the private repository-task adapter.

The bounded subprocess adapter uses Rustix's safe process API to send
`SIGKILL` to a dedicated child process group after a subprocess deadline or
collection failure. This prevents descendants that inherited an output pipe
from surviving the failed repository task.
The bounded subprocess adapter uses Rustix's safe filesystem API to mark child
stdin nonblocking before deadline-bounded input transfer. It uses Rustix's safe
process API to send `SIGKILL` to a dedicated child process group after a
subprocess deadline or collection failure. This prevents a non-reading child
from blocking its parent indefinitely and prevents descendants that inherited
an output pipe from surviving the failed repository task.
The
[signal-hook dependency admission](signal-hook-0.4.4.md)
records the terminal-signal guard that routes interruption through the same
Expand Down Expand Up @@ -61,7 +63,7 @@ work, and require unsafe code that Keep otherwise forbids.
## Features and resolved graph

All three direct dependencies disable default features. Keep enables only
`cap-fs-ext`'s `std` feature and Rustix's `process` and `std` features;
`cap-fs-ext`'s `std` feature and Rustix's `fs`, `process`, and `std` features;
`cap-std` has no enabled feature. All declarations are optional and are
activated solely by `repository-tasks`.

Expand Down Expand Up @@ -104,8 +106,9 @@ dependencies.

## Failure and recovery boundaries

An open, metadata, read, descriptor-duplication, child-directory setup, or
child-spawn failure is a typed refusal. The task never repairs, rewrites, or
An open, metadata, read, descriptor-duplication, descriptor-flag,
child-directory setup, child-spawn, stdin-write, output-collection, deadline,
or cleanup failure is a typed refusal. The task never repairs, rewrites, or
substitutes repository data. Retained handles exist only for one verification
process and carry no durability or recovery semantics.

Expand Down
4 changes: 2 additions & 2 deletions xtask/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ cap-std = { version = "=4.0.2", default-features = false, optional = true }
md-5 = { version = "=0.11.0", default-features = false, optional = true }
# Dedicated unsafe boundary sets exact child working directories by descriptor.
repository-process-spawn = { path = "../repository-process-spawn", optional = true }
# Safe POSIX process-group signaling bounds failed repository-tool subprocesses.
rustix = { version = "=1.1.4", default-features = false, features = ["process", "std"], optional = true }
# Safe POSIX descriptor flags and process-group signaling bound repository tools.
rustix = { version = "=1.1.4", default-features = false, features = ["fs", "process", "std"], optional = true }
# Serde drives duplicate-refusing repository JSON admission; no types escape xtask.
serde = { version = "=1.0.229", default-features = false, features = ["std"], optional = true }
# Typed JSON admission checks the committed documentation-tool lock graph.
Expand Down
3 changes: 2 additions & 1 deletion xtask/src/bounded_process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ mod capture_limit;
mod cleanup;
mod deadline;
mod error;
mod input;
mod interrupt;
mod process_group;
mod reader;
Expand All @@ -14,7 +15,7 @@ use std::process::Command;
use std::time::Duration;

use capture::wait_for_child;
pub(crate) use capture::{capture, capture_with, capture_with_limits};
pub(crate) use capture::{capture, capture_with, capture_with_input_limits, capture_with_limits};
pub(crate) use capture_limit::CaptureLimits;
use deadline::ProcessDeadline;
pub(crate) use error::ProcessError;
Expand Down
64 changes: 51 additions & 13 deletions xtask/src/bounded_process/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ use std::process::{Child, Command, ExitStatus, Stdio};
use std::thread;
use std::time::Duration;

use super::cleanup::{cleanup_process, join_after_cleanup, join_readers};
use super::cleanup::{cleanup_process, retire_after_cleanup, retire_readers};
use super::input::write_input;
use super::{
CaptureLimits, InterruptGuard, ProcessDeadline, ProcessError, ProcessOutput, ReaderWorker,
};
Expand All @@ -18,8 +19,9 @@ const PROCESS_POLL_INTERVAL: Duration = Duration::from_millis(10);
///
/// Each stream is drained concurrently and retains at most one mebibyte. The
/// optional deadline covers child execution and reader collection. Failures
/// terminate the child's dedicated process group, join both readers, and retain
/// the primary and cleanup errors in [`ProcessError`].
/// terminate the child's dedicated process group, retire both readers within a
/// fixed per-step cleanup grace, and retain primary and cleanup errors in
/// [`ProcessError`].
pub(crate) fn capture(
program: &'static str,
command: &mut Command,
Expand All @@ -44,7 +46,7 @@ pub(crate) fn capture_with(
/// Runs one captured child with exact independent stream limits.
///
/// The deadline covers child execution and both reader workers. Every failure
/// terminates the dedicated process group and joins the workers before return.
/// terminates the dedicated process group and bounds worker retirement.
pub(crate) fn capture_with_limits(
program: &'static str,
command: &mut Command,
Expand All @@ -57,6 +59,27 @@ pub(crate) fn capture_with_limits(
CapturedProcess::start(program, command, spawn, interrupts, limits)?.finish(program, &deadline)
}

/// Runs one captured child with bounded streaming input and exact stream limits.
///
/// The deadline clock starts before synchronous spawn. After spawn returns, its
/// remaining time bounds every nonblocking stdin write, both output readers,
/// and child execution. Failed-operation teardown uses a fixed per-step cleanup
/// grace for child reaping and reader retirement. Input slices are streamed
/// directly without constructing a combined preimage allocation.
pub(crate) fn capture_with_input_limits(
program: &'static str,
command: &mut Command,
input: &[&[u8]],
deadline: Option<Duration>,
limits: CaptureLimits,
) -> Result<ProcessOutput, ProcessError> {
let deadline = ProcessDeadline::new(program, deadline)?;
let interrupts = InterruptGuard::begin(program)?;
command.stdin(Stdio::piped());
CapturedProcess::start(program, command, Command::spawn, interrupts, limits)?
.finish_with_input(program, input, &deadline)
}

struct CapturedProcess {
child: Child,
interrupts: InterruptGuard,
Expand Down Expand Up @@ -104,7 +127,7 @@ impl CapturedProcess {
Ok(reader) => reader,
Err(error) => {
let error = cleanup_process(&mut child, error);
return Err(join_after_cleanup(stdout, error));
return Err(retire_after_cleanup(stdout, error));
}
};
Ok(Self {
Expand All @@ -129,13 +152,8 @@ impl CapturedProcess {
Ok(output) => output,
Err(error) => return Err(self.cleanup_readers(error)),
};
if let Err(error) = self.stdout.join() {
let error = cleanup_process(&mut self.child, error);
return Err(join_after_cleanup(self.stderr, error));
}
if let Err(error) = self.stderr.join() {
return Err(cleanup_process(&mut self.child, error));
}
drop(self.stdout);
drop(self.stderr);
let status = wait_for_child(program, &mut self.child, deadline, &self.interrupts)?;
refuse_exceeded(program, "stdout", self.limits.stdout_bytes(), &stdout).and_then(|()| {
refuse_exceeded(program, "stderr", self.limits.stderr_bytes(), &stderr)
Expand All @@ -151,10 +169,30 @@ impl CapturedProcess {
})
}

fn finish_with_input(
mut self,
program: &'static str,
input: &[&[u8]],
deadline: &ProcessDeadline,
) -> Result<ProcessOutput, ProcessError> {
let Some(mut stdin) = self.child.stdin.take() else {
let error = ProcessError::MissingStream {
program,
stream: "stdin",
};
return Err(self.cleanup_readers(error));
};
if let Err(error) = write_input(program, &mut stdin, input, deadline, &self.interrupts) {
return Err(self.cleanup_readers(error));
}
drop(stdin);
self.finish(program, deadline)
}

fn cleanup_readers(self, error: ProcessError) -> ProcessError {
let mut child = self.child;
let error = cleanup_process(&mut child, error);
join_readers(self.stdout, self.stderr, error)
retire_readers(self.stdout, self.stderr, error)
}
}

Expand Down
Loading