Skip to content

No async-native streaming: a live-stream consumer must choose between epitelesis and its own executor #23

Description

@forkwright

Finding

epitelesis has two ways to run a process and neither serves an async consumer that needs to read output as it is produced:

  • spawn() (crates/epitelesis/src/async_impl.rs:30) is the entire async surface — a 68-line file. It awaits to completion and returns one aggregated Output after the process exits. There is no incremental access.
  • .streaming()ManagedChild gives per-line access, but is driven by a dedicated OS-thread poll() loop rather than tokio's reactor. take_stdout() (managed.rs:79) hands back a blocking handle.

So an async caller wanting live output must either await to completion (losing the liveness that was the point) or read a blocking handle from async code, which stalls the executor. The only remaining option is to hand-build a blocking-to-async bridge in the consumer — which is the plumbing this crate exists to own.

Evidence

Measured against aletheia's energeia crate, the concrete consumer this blocks:

  • crates/energeia/src/cc/client.rs:165 (spawn_session) and :200 (resume_session) pipe stdout and consume it as a live NDJSON event stream (session/stream.rs, async BufReader::read_line) inside a fully-async call graph (session/events.rs::process_events).
  • Migrating those sites to spawn() drops incremental delivery outright. Migrating them to .streaming() requires energeia to write a thread-to-reactor bridge — duplicating, in the consumer, exactly what a wiring PR was supposed to delete from it.

A second-order consequence makes it sharper: .streaming() disables all bounded capture. command.rs:287-305 rejects any non-default CapturePolicy in streaming mode and managed.rs documents that the caller owns all backpressure. So the fail-closed overflow machinery, CaptureCompleteness, and the fair no-reader-threads pump (supervisor.rs:604-627) are inert on exactly the path a streaming consumer must take. A consumer that adopts streaming gets the typed lifecycle but loses the capture safety, which inverts the crate's own value proposition for that use case.

Why this matters

This is the difference between a library with one consumer and a library with several. The fleet standard (RUST.md § Command execution) mandates that production command execution route through epitelesis, and the enforcing lint's own fix text names epitelesis::spawn as the async answer. For a live-stream consumer that answer is currently unavailable, so the standard cannot be satisfied without loss — and the honest outcome is that the consumer keeps its hand-rolled implementation, which is the state the standard exists to end.

It also blocks a real, already-scoped piece of work: aletheia#4719 wants process supervision, structured errors on the existing FailureClass taxonomy, and process-group cancellation for the claude CLI transport. Every part of that is served by this crate except the streaming shape its two main call sites need.

Desired correction

An async-native streaming path that composes with the existing typed machinery rather than sitting beside it — an async ManagedChild (or equivalent) exposing tokio-compatible stdout/stderr handles, while retaining Deadline, process-group cancellation, LifecycleEvidence, and the typed terminal-cause taxonomy.

The design constraint that makes this non-trivial, stated so it is not discovered late: bounded capture and caller-driven streaming currently exclude each other. An async streaming path that simply inherits .streaming()'s "caller owns all backpressure" reproduces the gap above. Whether the capture policy can apply to a streamed consumer — bounding total bytes while still delivering incrementally — is the actual design question, and it should be answered deliberately rather than by defaulting to unbounded.

Done when:

  • An async consumer can read output incrementally from a tokio task without a dedicated bridge thread of its own, demonstrated by a test that asserts a line is observable before the process exits.
  • That path carries the same typed terminal causes and LifecycleEvidence as run/spawn, asserted by a test rather than by documentation.
  • The capture-policy interaction is explicit: either bounded capture composes with streaming (with a test showing the bound enforced on a streamed consumer), or the exclusion is documented at the API boundary with its reason, so a caller cannot adopt streaming and silently lose the bound.

Not in scope

setsid escape. tests/run.rs:410 (escaped_session_surfaces_truthful_cleanup_incomplete) establishes the current, correct behaviour — a child that escapes the process group is reported as CleanupIncomplete rather than silently missed, and the test kills the escapee itself in teardown because the crate's signal does not reach it. That is honest instrumentation and a separate question from this one; it is filed here only so a reader does not assume streaming work addresses it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions