Skip to content

feat: std/process start, children with live output streaming and kill#863

Merged
martian56 merged 2 commits into
mainfrom
feat/process-spawn
Jul 4, 2026
Merged

feat: std/process start, children with live output streaming and kill#863
martian56 merged 2 commits into
mainfrom
feat/process-spawn

Conversation

@martian56

@martian56 martian56 commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Closes #862.

std/process gains its streaming half: start a child, watch its output while it runs, and stop it. This is the runtime capability background shells in rook build on (martian56/rook#25), the same shape as the std/http streaming work.

Surface

fun start(program: String, args: List<String>) -> Result<Child, Error>

impl Child {
    fun read_stdout(self) -> String   // drained since the last read
    fun read_stderr(self) -> String
    fun poll(self) -> Option<Int>     // None while running
    fun running(self) -> Bool
    fun wait(self) -> Int
    fun kill(self) -> Bool
    fun free(self)
}

spawn is the goroutine keyword, hence start.

Design

  • Runtime: a second registry sharing the process id sequence (ids unique across both) holds the live Child, two output buffers, and the observed exit code. Plain OS reader threads pump each pipe into its buffer until end of stream; they never touch a GC object. Reads copy out before allocating, following the existing lock discipline.
  • Poll try-waits and caches the code (-2 over the FFI means running; the wrapper maps it to None; signal deaths and unknown ids stay -1). Kill treats an already-exited child as success. Free drops the entry without killing, documented. wait polls with a short sleep so other goroutines keep running.
  • Stdin is null: a started child sees end of input immediately and cannot steal the parent's terminal.
  • Same NUL-joined argv encoding and last-error model as run.

Testing

  • cargo test: 789 pass, 0 fail (fmt and clippy clean; the one clippy warning is pre-existing in the compiler lib).
  • New smoke test process_spawn_streams_and_kills drives compiled binaries end to end: the parent drains "early" while the child is provably still running, waits for exit code 3, sees the late output, kills a 20s-idling child, and takes the Err path on a nonexistent program. Deterministic expected stdout, same harness as the other process tests.
  • docs/v2/specs/std-process.md updated with the two capture models and the extended registry.

Summary by CodeRabbit

  • New Features
    • Added std/process::start(program, args) -> Result<Child, Error> for asynchronous subprocesses with a new Child handle.
    • Child supports incremental read_stdout/read_stderr, non-blocking poll/running, wait, kill, and free.
    • Introduced a streaming output model with bounded buffers (older output is dropped when capped).
    • Added new examples and an end-to-end smoke test covering streaming, killing, and spawn failure.
  • Bug Fixes
    • Improved process handling so output can be read while a child is still running and exit status can be checked without blocking.

Add the streaming half of std/process. `start(program, args)` spawns a
child without waiting (`spawn` is the goroutine keyword, hence start)
and returns a Child handle: read_stdout and read_stderr drain whatever
arrived since the last read, poll try-waits without blocking (running
is its Bool shorthand), wait blocks with a scheduler-friendly poll
loop, kill terminates the child, and free drops the handle without
killing. The child's stdin is null so it does not inherit the parent's
terminal.

Runtime side: a second registry sharing the process id sequence holds
the live process, two output buffers pumped by plain reader threads
outside the collector, and the exit code once observed (cached so poll
stays cheap). Poll reports -2 over the FFI while running; killing an
already-exited child reports success. Spawn failures use the existing
process last-error slot, and the argv crosses in the same NUL-joined
encoding as run.

The smoke test drives the whole surface end to end through compiled
binaries: output drained while the child is provably still running,
the exit code after wait, killing a long-idling child, and the
spawn-failure Err path. The std-process spec documents the new model.

Fixes #862
@martian56 martian56 self-assigned this Jul 4, 2026
@strix-security

strix-security Bot commented Jul 4, 2026

Copy link
Copy Markdown

Strix Security Review

1 open security finding on this PR:

Review summary

The latest commit’s process-lifecycle changes improve spawned-child cleanup, but the new raven_process_spawn_free behavior introduces Unbounded waiter thread creation when freeing running child processes. I reviewed the changed runtime and stdlib wrapper code paths and did not identify other security issues in the PR diff.

Updated for 1e4e318.


Reviewed by Strix
Re-run review · Configure security review settings

@coderabbitai

coderabbitai Bot commented Jul 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 2e01a09f-25e7-4595-ac1c-f62bdef9bd38

📥 Commits

Reviewing files that changed from the base of the PR and between 28d426b and 1e4e318.

📒 Files selected for processing (3)
  • docs/v2/specs/std-process.md
  • raven-runtime/src/lib.rs
  • stdlib/std/process.rv
🚧 Files skipped from review as they are similar to previous changes (2)
  • stdlib/std/process.rv
  • raven-runtime/src/lib.rs

📝 Walkthrough

Walkthrough

Adds a streaming subprocess API to std/process with start(program, args), a new Child handle, runtime reader threads for live stdout/stderr, polling and kill support, and new example and smoke-test coverage.

Changes

std/process spawn/streaming feature

Layer / File(s) Summary
Spec: start/Child streaming capture model
docs/v2/specs/std-process.md
Documents start, Child methods, the two capture models, stdin behavior for start, and the live registry plus poll sentinel semantics.
Runtime FFI: spawn registry and async ABI
raven-runtime/src/lib.rs
Adds the live spawn registry, shared output buffers, pump threads, capped incremental draining, and the new spawn/read/poll/kill/free FFI entrypoints.
Stdlib: start() and Child methods
stdlib/std/process.rv
Adds the new FFI bindings, Child wrapper methods, polling-based wait, and the public start(program, args) function.
Examples and smoke test for spawn behavior
examples/v2/spawn_child.rv, examples/v2/spawn_parent.rv, tests/codegen_smoke.rs
Adds child/parent example programs and an end-to-end smoke test that exercises streaming, kill, and spawn failure paths.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant StdProcess as std/process.rv
  participant Runtime as raven-runtime FFI
  participant PumpThreads as spawn_pump threads
  participant OSChild as child process

  StdProcess->>Runtime: raven_process_spawn(program, args)
  Runtime->>OSChild: spawn with null stdin
  Runtime->>PumpThreads: start stdout/stderr pumps
  PumpThreads->>OSChild: read stdout/stderr pipes
  PumpThreads->>Runtime: append to shared buffers
  StdProcess->>Runtime: raven_process_read_stdout(id)
  Runtime-->>StdProcess: drained bytes since last read
  StdProcess->>Runtime: raven_process_poll(id)
  Runtime-->>StdProcess: -2 while running, exit code after exit
  StdProcess->>Runtime: raven_process_kill(id)
  Runtime->>OSChild: terminate
  StdProcess->>Runtime: raven_process_spawn_free(id)
  Runtime-->>Runtime: remove registry entry
Loading

Possibly related PRs

  • martian56/raven#855: Both PRs modify stdlib/std/process.rv argument validation for embedded NUL bytes, with this PR extending that validation to the new start(...) path.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the new std/process child spawning, streaming output, and kill support.
Description check ✅ Passed The description covers the change, design, testing, and related issue, though it doesn't follow the template headings exactly.
Linked Issues check ✅ Passed The PR implements the spawn API, streaming reads, polling, running, wait, kill, free, and runtime behavior requested in #862.
Out of Scope Changes check ✅ Passed The changes stay focused on the spawn API, runtime support, docs, examples, and tests; no unrelated scope appears.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/process-spawn

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
raven-runtime/src/lib.rs (1)

2546-2553: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Unbounded buffer growth if output isn't drained.

spawn_pump appends indefinitely into Arc<Mutex<Vec<u8>>> with no size cap. For the stated background-shell use case, a child that produces large or continuous output before the caller reads it (or if the caller never reads stderr, say) can grow memory without bound.

Consider capping buffer size (e.g., drop oldest bytes past a limit, or track/report an overflow flag) to bound worst-case memory.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@raven-runtime/src/lib.rs` around lines 2546 - 2553, The stdout/stderr capture
in spawn_pump currently appends into unbounded Vec buffers, which can grow
without limit if the caller does not drain them. Update the child output
handling in the background-shell path to enforce a fixed maximum buffer size for
the Arc<Mutex<Vec<u8>>> collectors, and make spawn_pump or its callers drop the
oldest bytes or set an overflow indicator once the limit is reached. Ensure the
change is applied consistently to both stdout and stderr capture so memory usage
stays bounded.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@raven-runtime/src/lib.rs`:
- Around line 2599-2653: raven_process_spawn_free currently removes the registry
entry without ensuring an exited child is reaped, so update the spawn lifecycle
in raven_process_poll, raven_process_kill, and raven_process_spawn_free to reap
any child that has already exited before dropping ownership. Add a wait/reap
path when freeing an entry whose Child may have terminated, or transfer cleanup
to a background waiter so exited children are always collected before the
registry entry disappears.

---

Nitpick comments:
In `@raven-runtime/src/lib.rs`:
- Around line 2546-2553: The stdout/stderr capture in spawn_pump currently
appends into unbounded Vec buffers, which can grow without limit if the caller
does not drain them. Update the child output handling in the background-shell
path to enforce a fixed maximum buffer size for the Arc<Mutex<Vec<u8>>>
collectors, and make spawn_pump or its callers drop the oldest bytes or set an
overflow indicator once the limit is reached. Ensure the change is applied
consistently to both stdout and stderr capture so memory usage stays bounded.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: ce63b42e-6e72-480e-9f2b-e097123255a9

📥 Commits

Reviewing files that changed from the base of the PR and between d23dc08 and 28d426b.

📒 Files selected for processing (6)
  • docs/v2/specs/std-process.md
  • examples/v2/spawn_child.rv
  • examples/v2/spawn_parent.rv
  • raven-runtime/src/lib.rs
  • stdlib/std/process.rv
  • tests/codegen_smoke.rs

Comment thread raven-runtime/src/lib.rs
Address the review findings on the std/process start surface. Each
output buffer is now capped at 8 MB: a child that outruns its reader
keeps only the most recent bytes, so an undrained pipe cannot grow
memory without bound. And spawn_free now guarantees reaping: a child
whose exit was already observed was reaped by that poll, and any other
child is handed to a background waiter thread that collects it when it
exits, so a freed child never lingers as a zombie. The spec and the
Child.free doc note both behaviors, and a unit test pins the cap
keeping the tail.

@strix-security strix-security Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Strix flagged 2 new security findings below. See the pinned summary comment for the full PR status.

Comment thread raven-runtime/src/lib.rs
Comment on lines +2667 to +2674
let entry = spawn_registry().lock().unwrap().remove(&id);
if let Some(mut entry) = entry {
if entry.code.is_some() {
return;
}
std::thread::spawn(move || {
let _ = entry.child.wait();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Unbounded waiter thread creation when freeing running child processes

Severity: MEDIUM · CWE-400

The new streaming process API lets untrusted Raven code detach a still-running child via Child.free(). The runtime responds by spawning a dedicated OS thread that blocks until that child exits, so repeated start() + free() calls can accumulate unbounded threads and subprocesses and exhaust host resources.

Suggested change
let entry = spawn_registry().lock().unwrap().remove(&id);
if let Some(mut entry) = entry {
if entry.code.is_some() {
return;
}
std::thread::spawn(move || {
let _ = entry.child.wait();
});
let mut registry = spawn_registry().lock().unwrap();
let Some(mut entry) = registry.get_mut(&id) else {
return;
};
if entry.code.is_some() {
registry.remove(&id);
return;
}
// Refuse to detach a still-running child with a dedicated waiter thread:
// the caller must kill or wait first so one program cannot accumulate
// unbounded blocked OS threads by repeatedly calling free().
return;
Prompt to fix with AI
This is a security vulnerability found during a code review.

Vulnerability: Unbounded waiter thread creation when freeing running child processes
Severity: MEDIUM
CWE: CWE-400

The new streaming process API lets untrusted Raven code detach a still-running child via `Child.free()`. The runtime responds by spawning a dedicated OS thread that blocks until that child exits, so repeated `start()` + `free()` calls can accumulate unbounded threads and subprocesses and exhaust host resources.

Location: raven-runtime/src/lib.rs:2667-2674
Context: Freeing a live child spawns an unbounded blocking waiter thread
```
// Before:
    let entry = spawn_registry().lock().unwrap().remove(&id);
    if let Some(mut entry) = entry {
        if entry.code.is_some() {
            return;
        }
        std::thread::spawn(move || {
            let _ = entry.child.wait();
        });
// After:
    let mut registry = spawn_registry().lock().unwrap();
    let Some(mut entry) = registry.get_mut(&id) else {
        return;
    };
    if entry.code.is_some() {
        registry.remove(&id);
        return;
    }
    // Refuse to detach a still-running child with a dedicated waiter thread:
    // the caller must kill or wait first so one program cannot accumulate
    // unbounded blocked OS threads by repeatedly calling free().
    return;
```

Location: stdlib/std/process.rv:109-113
Context: Public wrapper exposes the detach-on-free behavior to untrusted Raven code
```
// Before:
    // Drop the runtime entry for this child. Does not kill a running child;
    // the runtime still reaps it when it exits.
    fun free(self) {
        raven_process_spawn_free(self.id)
    }
// After:
    // Drop the runtime entry for this child only after it has exited.
    // Call kill() or wait() first for a running child.
    fun free(self) {
        raven_process_spawn_free(self.id)
    }
```

How to fix:
Do not create one background waiter thread per freed running child. Keep detached children in a shared structure that can be reaped without per-child blocked threads, or change `free()` so it only succeeds for already-exited children (or explicitly kills before freeing) to prevent attacker-controlled thread accumulation.

Please fix this vulnerability. If you propose a fix, make it concise and minimal.

React 👍 / 👎 to tune Strix for this repo. A repo collaborator (or the PR author) can resolve this thread to dismiss the finding.

Comment thread stdlib/std/process.rv
Comment on lines +109 to +113
// Drop the runtime entry for this child. Does not kill a running child;
// the runtime still reaps it when it exits.
fun free(self) {
raven_process_spawn_free(self.id)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Public wrapper exposes the detach-on-free behavior to untrusted Raven code

Suggested change
// Drop the runtime entry for this child. Does not kill a running child;
// the runtime still reaps it when it exits.
fun free(self) {
raven_process_spawn_free(self.id)
}
// Drop the runtime entry for this child only after it has exited.
// Call kill() or wait() first for a running child.
fun free(self) {
raven_process_spawn_free(self.id)
}

@martian56 martian56 merged commit 6933746 into main Jul 4, 2026
7 checks passed
@martian56 martian56 deleted the feat/process-spawn branch July 4, 2026 08:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

std/process: spawn children with live output streaming and kill

1 participant