# RFD 021: Printer Live Redirection

- **Status**: Discussion
- **Category**: Design
- **Authors**: Jean Mertz <git@jeanmertz.com>
- **Date**: 2025-07-19

## Summary

This RFD adds runtime output redirection to `jp_printer`. A `Printer` can switch
its underlying writers at any point during execution via a new `SwapWriters`
command, without replacing the `Printer` instance or disrupting renderer state.

## Motivation

The `Printer` is constructed with fixed `out`/`err` writers that are moved into
a background worker thread. Once created, there is no way to change where output
goes. All renderers (`ChatResponseRenderer`, `ToolRenderer`,
`StructuredRenderer`, `JsonEmitter`) share a single `Arc<Printer>`, so the
output destination is locked in for the lifetime of the process.

This blocks use cases that need to redirect output at runtime - detaching a
conversation from the terminal, redirecting to a file mid-stream, or capturing
output from a specific phase during testing. Without live redirection, the only
options are replacing the `Printer` instance (which means either rebuilding all
renderers or introducing `ArcSwap` indirection) or maintaining a parallel output
path outside the printer (which duplicates rendering logic).

Live redirection is also useful for testing: swap to a memory buffer mid-test to
capture output from a specific phase without capturing setup noise.

> [!TIP]
> [RFD 027] uses `Printer::swap_writers()` as a key building block in its
> client-server output model, redirecting output between the server's in-memory
> buffer and an attached client's terminal at runtime.

## Design

### Command::SwapWriters

Add a new command variant to the `Printer` worker's command channel:

```rust
enum Command {
    Print(PrintTask),
    Flush(mpsc::Sender<()>),
    FlushInstant(mpsc::Sender<()>),
    SwapWriters {
        out: Box<dyn io::Write + Send>,
        err: Box<dyn io::Write + Send>,
        done: mpsc::Sender<()>,
    },
    Shutdown,
}
```

The worker processes `SwapWriters` in FIFO order with other commands. All
`Print` commands enqueued before `SwapWriters` write to the old destination. All
`Print` commands enqueued after write to the new one. The swap is a
deterministic point in the command stream.

Worker implementation:

```rust
Command::SwapWriters { out, err, done } => {
    let _ = self.out.flush();
    let _ = self.err.flush();
    self.out = out;
    self.err = err;
    let _ = done.send(());
}
```

### Public API

```rust
impl Printer {
    /// Replace the output writers, blocking until the swap completes.
    ///
    /// All print commands enqueued before this call write to the old
    /// destination. All commands enqueued after write to the new one.
    /// Call `flush()` or `flush_instant()` before swapping to ensure
    /// pending output reaches the old destination first.
    pub fn swap_writers(
        &self,
        out: impl io::Write + Send + 'static,
        err: impl io::Write + Send + 'static,
    ) {
        let (tx, rx) = mpsc::channel();
        self.send(Command::SwapWriters {
            out: Box::new(out),
            err: Box::new(err),
            done: tx,
        });
        let _ = rx.recv();
    }
}
```

The method blocks until the worker has performed the swap. After it returns, the
caller knows the new writers are active.

### Worker Type Change

The `Worker` struct currently uses generic type parameters for its writers:

```rust
struct Worker<O, E> {
    out: O,
    err: E,
    // ...
}
```

To support runtime replacement, these change to trait objects:

```rust
struct Worker {
    out: Box<dyn io::Write + Send>,
    err: Box<dyn io::Write + Send>,
    // ...
}
```

This loses monomorphization, but the writers are behind a thread boundary —
there is no inlining benefit to preserve. The cost is one vtable dispatch per
`write()` call, which is negligible compared to the I/O itself.

`Printer::new()` boxes the writers at construction:

```rust
pub fn new(
    out: impl io::Write + Send + 'static,
    err: impl io::Write + Send + 'static,
    format: OutputFormat,
) -> Self {
    // ...
    thread::spawn(move || {
        let mut worker = Worker {
            out: Box::new(out),
            err: Box::new(err),
            // ...
        };
        worker.run();
    });
    // ...
}
```

The existing `Printer::terminal()`, `Printer::sink()`, and `Printer::memory()`
constructors continue to work unchanged — they call `Printer::new()` which
handles the boxing.

### Interaction with FlushInstant

A common pattern for time-sensitive swaps is `flush_instant()` followed by
`swap_writers()`:

```rust
printer.flush_instant();
printer.swap_writers(new_out, new_err);
```

`flush_instant()` cancels typewriter delays, drains pending commands
immediately, and blocks until complete. This minimizes the time between the
caller's intent to swap and the swap taking effect. Any `Print` commands
enqueued between the two calls race with the worker - in practice this window is
negligible since both calls happen on the same thread in sequence.

### Existing API Unchanged

The public `Printer` API does not change beyond the addition of `swap_writers`.
All existing methods (`print`, `println`, `eprint`, `flush`, `flush_instant`,
`shutdown`) work exactly as before. The `Printer::memory()` constructor also
remains unchanged — it serves a different purpose (capturing all output for
testing from construction time).

## Drawbacks

**Type erasure for all printers.** Changing `Worker<O, E>` to `Worker { out:
Box<dyn Write>, ... }` applies to all `Printer` instances, not just ones that
use `swap_writers()`. Printers that never swap still pay for the vtable
dispatch. The cost is negligible (one indirect call per write, dwarfed by actual
I/O) but it is a universal change.

**Blocking call.** `swap_writers()` blocks until the worker processes the
command. If the worker is mid-typewriter on a long task, the caller waits.
Callers who need a faster swap should call `flush_instant()` first.

## Alternatives

### SwitchableWriter (writer-level swap)

Wrap the inner writers in `Arc<Mutex<Box<dyn Write>>>`. The swap happens below
the worker, at the writer level. The next `write()` call goes to the new
destination regardless of the command queue.

This has one advantage: the swap is immediate, even for commands already in the
queue. But it introduces a mutex acquisition on every `write()` call (not just
during swaps), and it creates ordering confusion — commands enqueued before the
swap may write to the new destination if the worker hasn't processed them yet.
The caller must manually `flush()` before swapping to avoid this, which is a
footgun.

The command-based approach (`SwapWriters`) has correct ordering by construction.
Every command before the swap goes to the old destination, every command after
goes to the new one.

If the small window between `flush_instant()` and `SwapWriters` processing turns
out to be a problem in practice, `SwitchableWriter` can be revisited as the
immediate-swap alternative.

### Replace `Arc<Printer>` with `Arc<ArcSwap<Printer>>`

Replace the entire `Printer` atomically. All holders see the new printer
immediately.

Rejected because it replaces the entire printer (including the background worker
thread), not just the output destination. This loses the worker's queue, pending
typewriter state, and any in-flight writes. It also requires every call site to
load from the `ArcSwap`, adding overhead to every print.

## Non-Goals

- **Per-renderer redirection.** All renderers share a single `Printer`. This RFD
  does not add the ability to redirect individual renderers to different
  destinations.

- **Changing `OutputFormat` at runtime.** The format (text, pretty, JSON) is set
  at construction and does not change. Switching from pretty to plain mid-stream
  would require renderer resets.

## Implementation Plan

### Phase 1: Worker Type Change

Change `Worker<O, E>` to use `Box<dyn io::Write + Send>` for `out` and `err`.
Update `Printer::new()` to box the writers. All existing constructors and tests
continue to work.

Can be merged independently. No behavioral change.

### Phase 2: SwapWriters Command

Add `Command::SwapWriters` and `Printer::swap_writers()`. Add tests:

- Create a printer with memory writers, swap to different memory writers, verify
  each buffer received the correct output.
- Verify ordering: print before swap goes to old, print after goes to new.
- Verify `flush_instant()` + `swap_writers()` works as expected.

Can be merged independently.

## References

- `jp_printer::Printer` (`crates/jp_printer/src/printer.rs`) — the current
  printer implementation.
- `jp_printer::Printer::memory()` (`crates/jp_printer/src/printer.rs`) —
  existing memory-backed printer pattern using `SharedBuffer`.

[RFD 027]: 027-client-server-query-architecture.md
