tasks(tty): decompose alknet-tty + alknet-tty-local into 15 implementation tasks
Break the tty spec set (docs/architecture/crates/tty/) into atomic,
dependency-ordered tasks across two crates:
alknet-tty (lean core, depends on alknet-core only — ADR-057):
- crate-init, wire-codec, control-messages, negotiation, backend-trait,
adapter, review-tty
alknet-tty-local (sibling, portable_pty + libc — ADR-054):
- crate-init, pty-mode, pipe-mode, backend-impl, review-tty-local
integration:
- local-feature-reexport, integration-test, review-tty-final
Three review checkpoints at critical points: after the core crate (before
the local backend builds on the one-way-door TtyBackend trait — ADR-053),
after the local backend (validating REQ-TTY-01/02 and ADR-056), and a final
merge-readiness review. The high-risk tasks (backend-trait, adapter,
pty-mode) are flagged; the ADR-056 kill-on-Drop guard the POC lacked is
called out in both pty-mode and pipe-mode.
Validated with taskgraph: 115 tasks, no cycles, 10-generation parallel
structure with 3-way parallelism after crate-init and 2-way after the
backend trait.
This commit is contained in:
1 parent
dbac972aaa
commit
8d20f89902
15 files changed
+2409
No files matched your search
@@ -0,0 +1,114 @@
|
||||
---
|
||||
id: tty-local/backend-impl
|
||||
name: Implement LocalTtyBackend (TtyBackend) branching on terminal Some/None
|
||||
status: pending
|
||||
depends_on: [tty-local/pty-mode, tty-local/pipe-mode]
|
||||
scope: narrow
|
||||
risk: low
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement `LocalTtyBackend` in `src/backend.rs`: the `TtyBackend` implementation
|
||||
that branches on `TtyParams.terminal` to dispatch to PTY mode (task
|
||||
`tty-local/pty-mode`) or pipe mode (task `tty-local/pipe-mode`).
|
||||
|
||||
### LocalTtyBackend
|
||||
|
||||
```rust
|
||||
pub struct LocalTtyBackend;
|
||||
|
||||
impl LocalTtyBackend {
|
||||
pub fn new() -> Self;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl TtyBackend for LocalTtyBackend {
|
||||
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError> {
|
||||
match ¶ms.terminal {
|
||||
Some(terminal) => pty::allocate_pty(
|
||||
terminal.clone(),
|
||||
params.cmd.clone(),
|
||||
params.cwd.clone(),
|
||||
params.env.clone(),
|
||||
).await,
|
||||
None => pipe::allocate_pipe(
|
||||
params.cmd.clone(),
|
||||
params.cwd.clone(),
|
||||
params.env.clone(),
|
||||
).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> {
|
||||
None // local backend creates its own resource (process); no pre-existing resource
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`LocalTtyBackend` takes no constructor dependencies (unlike
|
||||
`DockerTtyBackend` which wraps a `bollard::Docker` client). The
|
||||
`portable_pty` system is process-global. The assembly layer constructs one
|
||||
`LocalTtyBackend` and registers it as `"local"`.
|
||||
|
||||
### Validation
|
||||
|
||||
`allocate()` validates `params.cmd` is non-empty (the adapter already checks
|
||||
this at negotiation, but the backend should fail gracefully if called
|
||||
directly with an empty cmd — return `TtyError::AllocFailed`). The
|
||||
`backend_params` map is ignored by the local backend (it has no
|
||||
backend-specific selector fields); if a caller passes unexpected
|
||||
`backend_params`, they are silently ignored (the local backend doesn't
|
||||
define a typed params struct).
|
||||
|
||||
### Default trait methods
|
||||
|
||||
`resource_id()` returns `None` (the local backend creates its own resource —
|
||||
a process — so there's no pre-existing resource for the ownership check). The
|
||||
default impl on `TtyBackend` already returns `None`, so this can be omitted
|
||||
unless an explicit impl is preferred for clarity.
|
||||
|
||||
### Tests
|
||||
|
||||
- **PTY dispatch**: `allocate` with `terminal: Some` → returns a `TtyHandle`
|
||||
with `stderr: None` and a `PtyControl`.
|
||||
- **Pipe dispatch**: `allocate` with `terminal: None` → returns a `TtyHandle`
|
||||
with `stderr: Some` and a `PipeControl`.
|
||||
- **Empty cmd**: `allocate` with empty `cmd` → `TtyError::AllocFailed`.
|
||||
- **resource_id**: returns `None`.
|
||||
- These can be lightweight (the heavy tests are in `pty-mode` and `pipe-mode`);
|
||||
this task verifies the dispatch wiring.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `LocalTtyBackend` struct with `new()` constructor (no deps)
|
||||
- [ ] `impl TtyBackend for LocalTtyBackend` with `allocate()` branching on `params.terminal`
|
||||
- [ ] `terminal: Some` → dispatches to `pty::allocate_pty`
|
||||
- [ ] `terminal: None` → dispatches to `pipe::allocate_pipe`
|
||||
- [ ] `resource_id()` returns `None` (or uses the default)
|
||||
- [ ] Empty `cmd` → `TtyError::AllocFailed`
|
||||
- [ ] `LocalTtyBackend` re-exported from `lib.rs` (`pub use backend::LocalTtyBackend`)
|
||||
- [ ] Unit tests: PTY dispatch, pipe dispatch, empty cmd, resource_id
|
||||
- [ ] `cargo test -p alknet-tty-local` succeeds
|
||||
- [ ] `cargo clippy -p alknet-tty-local` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/tty/tty-local.md — §"PTY Mode", §"Pipe Mode", §"LocalTtyBackend takes no constructor dependencies"
|
||||
- docs/architecture/crates/tty/tty-backend.md — `TtyBackend` trait, `resource_id()`
|
||||
- docs/architecture/decisions/054-local-tty-backend-sibling-crate.md — ADR-054 (per-session PTY vs pipe choice)
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the wiring task — the heavy lifting is in `pty-mode` and
|
||||
> `pipe-mode`. The branch on `TtyParams.terminal` is the per-session choice
|
||||
> (ADR-054): one `LocalTtyBackend` serves both terminal and runner use cases.
|
||||
> The local backend ignores `backend_params` (no backend-specific selector
|
||||
> fields); a docker/SSH backend would deserialize its own typed params from
|
||||
> it.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
@@ -0,0 +1,116 @@
|
||||
---
|
||||
id: tty-local/crate-init
|
||||
name: Initialize alknet-tty-local crate with Cargo.toml, dependencies, and module skeleton
|
||||
status: pending
|
||||
depends_on: [tty/backend-trait]
|
||||
scope: moderate
|
||||
risk: low
|
||||
impact: project
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Initialize the `alknet-tty-local` sibling crate (ADR-054). This crate
|
||||
implements `TtyBackend` for `LocalTtyBackend` via `portable_pty` (PTY mode)
|
||||
and `std::process::Command` (pipe/runner mode). It depends on `alknet-tty`
|
||||
for the trait and types; the heavy `portable_pty` dependency lives here, not
|
||||
in the core crate.
|
||||
|
||||
### Crate setup
|
||||
|
||||
Create `crates/alknet-tty-local/` with:
|
||||
|
||||
- `Cargo.toml` — package metadata, dependencies
|
||||
- `src/lib.rs` — crate root with module declarations and re-exports
|
||||
- Module skeleton files for:
|
||||
- `src/pty.rs` — PTY mode: three-thread bridge, `PtyControl`, REQ-TTY-02
|
||||
signal forwarding, ADR-056 kill guard
|
||||
- `src/pipe.rs` — pipe mode: `tokio::process`, `PipeControl`, ADR-056 kill
|
||||
guard
|
||||
- `src/backend.rs` — `LocalTtyBackend` implementing `TtyBackend`, branching
|
||||
on `TtyParams.terminal`
|
||||
|
||||
### Dependencies
|
||||
|
||||
Per the architecture spec (tty-local.md §"Dependencies"):
|
||||
|
||||
| Crate | Purpose |
|
||||
|-------|---------|
|
||||
| `alknet-tty` | `TtyBackend` trait, `TtyHandle`, `TtyControl`, `TtyControlHandle`, `TtyParams`, `TerminalParams`, `TtyError`, `ControlMessage`, `signal_from_name` (workspace path) |
|
||||
| `portable_pty` | PTY allocation — Unix `openpty` + Windows ConPTY (the heavy dep) |
|
||||
| `libc` | Signal forwarding — REQ-TTY-02, Unix only |
|
||||
| `tokio` 1 (full) | mpsc, oneshot, `AsyncRead`/`AsyncWrite` for the pipe case |
|
||||
| `bytes` 1 | `Bytes` for stdout/stderr streams |
|
||||
| `futures-core` | `Stream` trait for `TtyHandle.stdout`/`stderr` |
|
||||
| `tracing` 0.1 | Structured logging |
|
||||
| `thiserror` 2 | Error conversion (if needed beyond `TtyError`) |
|
||||
|
||||
`alknet-tty-local` does NOT depend on `alknet-core` directly (it accesses core
|
||||
types via `alknet-tty`'s re-exports if needed). `portable_pty` and `libc` are
|
||||
the heavy deps that motivate the sibling-crate split (ADR-054).
|
||||
|
||||
### Feature flags
|
||||
|
||||
No feature flags — the crate is unconditional. The `local` feature gate lives
|
||||
on `alknet-tty` (the consumer enables it to pull this crate in).
|
||||
|
||||
### Workspace Cargo.toml
|
||||
|
||||
Add `crates/alknet-tty-local` to the workspace `members` list in the root
|
||||
`Cargo.toml`.
|
||||
|
||||
### Module skeleton
|
||||
|
||||
```rust
|
||||
// src/lib.rs
|
||||
//! alknet-tty-local: Local TTY backend for alknet-tty.
|
||||
//!
|
||||
//! `LocalTtyBackend` implements `alknet_tty::TtyBackend` via `portable_pty`
|
||||
//! (PTY mode, terminal semantics) and `std::process::Command` (pipe mode,
|
||||
//! the runner case). The blocking→async bridge for PTY mode uses three
|
||||
//! dedicated std threads feeding tokio mpsc/oneshot channels (REQ-TTY-01).
|
||||
//! Signal forwarding targets the foreground process group (REQ-TTY-02).
|
||||
|
||||
pub mod pty;
|
||||
pub mod pipe;
|
||||
pub mod backend;
|
||||
|
||||
pub use backend::LocalTtyBackend;
|
||||
```
|
||||
|
||||
Each module file gets a doc comment and `// TODO: implement` marker.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `crates/alknet-tty-local/Cargo.toml` exists with all dependencies
|
||||
- [ ] `crates/alknet-tty-local/src/lib.rs` exists with module declarations and `pub use backend::LocalTtyBackend`
|
||||
- [ ] Module skeleton files exist: `pty.rs`, `pipe.rs`, `backend.rs`
|
||||
- [ ] Root `Cargo.toml` `members` list includes `crates/alknet-tty-local`
|
||||
- [ ] `cargo check -p alknet-tty-local` succeeds
|
||||
- [ ] `cargo clippy -p alknet-tty-local` succeeds with no warnings
|
||||
- [ ] Dual licensing: `MIT OR Apache-2.0` (workspace-inherited)
|
||||
- [ ] `alknet-tty` dependency uses workspace path (`path = "../alknet-tty"`)
|
||||
- [ ] `portable_pty` and `libc` dependencies present
|
||||
- [ ] No `alknet-core` direct dependency (accessed via `alknet-tty` if needed)
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/tty/tty-local.md — the authoritative spec for this crate
|
||||
- docs/architecture/decisions/054-local-tty-backend-sibling-crate.md — ADR-054 (sibling crate placement)
|
||||
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053 (the trait this crate implements)
|
||||
- /workspace/alknet-tty-poc/src/local_pty.rs — the reference PTY implementation
|
||||
|
||||
## Notes
|
||||
|
||||
> This crate can be initialized in parallel with the remaining core crate
|
||||
> tasks (`tty/control-messages`, `tty/negotiation`, `tty/adapter`) since it
|
||||
> only depends on `tty/backend-trait` for the trait and types. The
|
||||
> `portable_pty` dependency is the heavy dep that motivates the sibling-crate
|
||||
> split — a docker-only deployment doesn't pull it in. The POC's
|
||||
> `local_pty.rs` is the reference for the PTY mode; the pipe mode is simpler
|
||||
> (tokio's `Child` is natively async).
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
@@ -0,0 +1,185 @@
|
||||
---
|
||||
id: tty-local/pipe-mode
|
||||
name: Implement pipe mode (tokio::process, PipeControl, ADR-056 kill guard)
|
||||
status: pending
|
||||
depends_on: [tty-local/crate-init]
|
||||
scope: moderate
|
||||
risk: medium
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the pipe mode in `src/pipe.rs`: the runner case (`terminal: None`,
|
||||
no PTY). Spawn the command with `tokio::process::Command` (or
|
||||
`std::process::Command` with `Stdio::piped()`) for stdin/stdout/stderr, return
|
||||
a `TtyHandle` with separate stdout and stderr (stderr is `Some`) and a
|
||||
`PipeControl` whose `resize()` is a no-op (no PTY) and `signal()` calls
|
||||
`libc::kill(pid, sig)` on the child's pid.
|
||||
|
||||
The async bridge is simpler than the PTY case — tokio's `Child` provides
|
||||
`AsyncRead` for stdout/stderr and `AsyncWrite` for stdin directly (no
|
||||
std-thread bridge needed).
|
||||
|
||||
### Pipe mode (`terminal: None`)
|
||||
|
||||
`allocate_pipe` spawns the command with `Stdio::piped()` for stdin, stdout,
|
||||
and stderr. `TtyHandle.stderr` is `Some` (separate streams). The `exit_code`
|
||||
future is `Child::wait()` (async on tokio's `Child`).
|
||||
|
||||
### Types
|
||||
|
||||
```rust
|
||||
pub fn allocate_pipe(
|
||||
cmd: Vec<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
env: HashMap<String, String>,
|
||||
) -> Result<TtyHandle, TtyError>;
|
||||
```
|
||||
|
||||
- `TtyHandle.stdin` — `Child::stdin` (`ChildStdin` implements `AsyncWrite`),
|
||||
boxed as `Box<dyn AsyncWrite + Send + Unpin>`.
|
||||
- `TtyHandle.stdout` — `Child::stdout` wrapped as a `Stream<Item = Bytes>`.
|
||||
Use `tokio_util::io::ReaderStream` or a manual `AsyncRead`→`Stream` adapter
|
||||
to convert `ChildStdout` (`AsyncRead`) into `Pin<Box<dyn Stream<Item = Bytes> + Send>>`.
|
||||
- `TtyHandle.stderr` — `Some`, same wrapping as stdout.
|
||||
- `TtyHandle.exit_code` — a `Future` wrapping `Child::wait()` PLUS a kill
|
||||
guard (ADR-056, see below).
|
||||
- `TtyHandle.control` — `Some(TtyControlHandle::new(Arc::new(PipeControl)))`.
|
||||
|
||||
### PipeControl (implements TtyControl)
|
||||
|
||||
```rust
|
||||
pub struct PipeControl {
|
||||
pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl TtyControl for PipeControl {
|
||||
fn resize(&self, _cols: u16, _rows: u16, _pw: u16, _ph: u16) {
|
||||
// No-op — no PTY in pipe mode.
|
||||
}
|
||||
fn signal(&self, name: &str) {
|
||||
// libc::kill(pid, sig) on the child's pid (NOT the process group —
|
||||
// pipe mode has no session leader / controlling tty).
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`resize()` is a no-op (no PTY — resize doesn't apply). `signal()` calls
|
||||
`libc::kill(pid, sig)` on the child's pid. Signal forwarding to the process
|
||||
group is **not applicable** in pipe mode (there's no session leader /
|
||||
controlling tty); `kill(pid, sig)` reaches the direct child only. If the child
|
||||
has spawned its own children, they won't receive the signal — this is a known
|
||||
limitation of the runner case (a runner that needs process-group signal
|
||||
delivery uses the PTY case, not the pipe case). Document this in the
|
||||
`PipeControl::signal` doc comment.
|
||||
|
||||
Use `alknet_tty::signal_from_name` for the name→number mapping. Unknown names
|
||||
fall back to `Child::start_kill()` (SIGKILL) — there's no `ChildKiller` in
|
||||
pipe mode; `tokio::process::Child::start_kill()` is the kill path. On non-Unix,
|
||||
`signal()` calls `start_kill()` directly (no `libc::kill`).
|
||||
|
||||
### Cancel-Cleanup (ADR-056)
|
||||
|
||||
The `exit_code` future's `Drop`-on-cancel MUST kill the child. Implement a
|
||||
`PipeExitFuture` wrapping the `Child::wait()` future plus a kill guard holding
|
||||
the `tokio::process::Child` handle:
|
||||
|
||||
```rust
|
||||
struct PipeExitFuture {
|
||||
wait: Pin<Box<dyn Future<Output = Result<ExitStatus, io::Error>> + Send>>,
|
||||
child: Option<tokio::process::Child>, // None after resolve (disarmed)
|
||||
}
|
||||
|
||||
impl Future for PipeExitFuture {
|
||||
// poll delegates to wait; on Ready, take child (disarm), map ExitStatus to i32
|
||||
}
|
||||
|
||||
impl Drop for PipeExitFuture {
|
||||
fn drop(&mut self) {
|
||||
if let Some(mut child) = self.child.take() {
|
||||
let _ = child.start_kill(); // best-effort; child may already be exiting
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
On cancel: `Drop` calls `start_kill()` (SIGKILL); the child exits; the `wait`
|
||||
future reaps it. On happy path: the future resolves, the guard is disarmed,
|
||||
the subsequent `Drop` is a no-op.
|
||||
|
||||
**Critical**: a bare `Child::wait()` future without the kill guard violates
|
||||
the ADR-056 contract and will orphan processes on cancel. This task MUST add
|
||||
the guard. Note: `tokio::process::Child` must be created with
|
||||
`kill_on_drop(true)` OR the explicit guard — the guard is the spec-compliant
|
||||
mechanism; `kill_on_drop` alone is insufficient because the `Child` is moved
|
||||
into the future and the future's `Drop` is the cancel path, not the `Child`'s.
|
||||
Use the explicit guard.
|
||||
|
||||
### The Threading/Deadlock Caveat (DP-4)
|
||||
|
||||
`std::process::Command` with piped stdio can deadlock if stdin writes block
|
||||
while stdout/stderr buffers fill — the classic pipe-buffer deadlock. The fix
|
||||
is concurrent reads on stdout/stderr alongside stdin writes, which is exactly
|
||||
what the adapter's three-pump driver does (task `tty/adapter`). No design
|
||||
decision needed; the spec notes it as a known constraint with a known
|
||||
(POC-validated) solution.
|
||||
|
||||
### Tests
|
||||
|
||||
- **Happy path**: spawn `echo hello` in pipe mode, read stdout stream to EOF,
|
||||
await exit_code → 0, assert stderr is empty.
|
||||
- **Stdin round-trip**: spawn `cat`, write via `AsyncWrite` stdin, read back
|
||||
via stdout, close stdin, await exit 0.
|
||||
- **Separate stderr**: spawn a command that writes to stderr (e.g.,
|
||||
`sh -c "echo err >&2"`), assert stderr stream receives the bytes, stdout
|
||||
is empty.
|
||||
- **Signal (Unix)**: spawn `sleep 60`, send `signal("TERM")`, assert the child
|
||||
exits with signal-terminated status.
|
||||
- **Cancel cleanup (ADR-056)**: spawn `sleep 60`, drop the `TtyHandle` without
|
||||
awaiting exit_code, assert the child is killed (no orphan).
|
||||
- **Resize no-op**: call `PipeControl::resize`, assert no error (it's a no-op).
|
||||
- **Unknown signal name**: send `signal("NOSUCH")`, assert it falls back to
|
||||
`start_kill()` (SIGKILL).
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `allocate_pipe` function spawns via `tokio::process::Command` with `Stdio::piped()`
|
||||
- [ ] `TtyHandle.stdin` is `ChildStdin` boxed as `Box<dyn AsyncWrite + Send + Unpin>`
|
||||
- [ ] `TtyHandle.stdout` is `ChildStdout` wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`
|
||||
- [ ] `TtyHandle.stderr` is `Some` (same wrapping as stdout)
|
||||
- [ ] `TtyHandle.exit_code` is `PipeExitFuture` (wait + kill guard), `BoxFuture<Result<i32, TtyError>>`
|
||||
- [ ] `TtyHandle.control` is `Some(TtyControlHandle::new(Arc::new(PipeControl)))`
|
||||
- [ ] `PipeControl` implements `alknet_tty::TtyControl`
|
||||
- [ ] `PipeControl::resize` is a no-op
|
||||
- [ ] `PipeControl::signal` uses `libc::kill(pid, sig)` (Unix) / `start_kill()` (non-Unix); NOT process-group targeting
|
||||
- [ ] `PipeControl::signal` uses `alknet_tty::signal_from_name`; unknown names fall back to `start_kill()`
|
||||
- [ ] `PipeExitFuture::Drop` calls `Child::start_kill()` when dropped without resolving (ADR-056)
|
||||
- [ ] `PipeExitFuture` disarms the guard on resolve (no-op Drop on happy path)
|
||||
- [ ] Doc comment on `PipeControl::signal` notes the no-process-group limitation
|
||||
- [ ] Integration tests: happy path, stdin round-trip, separate stderr, signal, cancel cleanup, resize no-op, unknown signal
|
||||
- [ ] `cargo test -p alknet-tty-local` succeeds
|
||||
- [ ] `cargo clippy -p alknet-tty-local` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/tty/tty-local.md — §"Pipe Mode (`terminal: None`)", §"Cancel-Cleanup (ADR-056)", §"The Threading/Deadlock Caveat"
|
||||
- docs/architecture/crates/tty/tty-backend.md — `TtyHandle`, `TtyControl` (the types this produces)
|
||||
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056 (kill-on-Drop)
|
||||
- docs/architecture/decisions/054-local-tty-backend-sibling-crate.md — ADR-054 (pipe mode = runner case)
|
||||
|
||||
## Notes
|
||||
|
||||
> Pipe mode is simpler than PTY mode (tokio's `Child` is natively async, no
|
||||
> std-thread bridge). The two subtleties: (1) the `AsyncRead`→`Stream<Item=Bytes>`
|
||||
> conversion for stdout/stderr (use `tokio_util::io::ReaderStream` or a manual
|
||||
> adapter), and (2) the ADR-056 kill guard on the `exit_code` future. Do NOT
|
||||
> rely on `tokio::process::Child::kill_on_drop(true)` alone — the explicit
|
||||
> guard in the future's `Drop` is the spec-compliant mechanism. Signal
|
||||
> forwarding in pipe mode targets the direct child only (no process group);
|
||||
> document this limitation.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
@@ -0,0 +1,232 @@
|
||||
---
|
||||
id: tty-local/pty-mode
|
||||
name: Implement PTY mode (three-thread bridge, PtyControl, REQ-TTY-02, ADR-056 kill guard)
|
||||
status: pending
|
||||
depends_on: [tty-local/crate-init]
|
||||
scope: broad
|
||||
risk: high
|
||||
impact: component
|
||||
level: implementation
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Implement the PTY mode in `src/pty.rs`: allocate a real PTY via
|
||||
`portable_pty::native_pty_system().openpty()`, spawn the command into the
|
||||
slave side, and return a `TtyHandle` with merged stdout (stderr is `None` —
|
||||
kernel PTY property) and a real `TtyControl` (resize via `MasterPty::resize`,
|
||||
signal via `libc::kill(-pgid, sig)`).
|
||||
|
||||
This is the reference implementation of REQ-TTY-01 (backends need not be
|
||||
natively async) and carries REQ-TTY-02 (signal forwarding to the process
|
||||
group). It is a port + generalization of the POC's
|
||||
`/workspace/alknet-tty-poc/src/local_pty.rs`, adapted to produce a `TtyHandle`
|
||||
(the trait's handle type) instead of the POC's `LocalPty` struct.
|
||||
|
||||
### The blocking→async bridge (REQ-TTY-01)
|
||||
|
||||
`portable_pty` is a blocking `std::io` API. `MasterPty::try_clone_reader()`
|
||||
returns `Box<dyn std::io::Read + Send>`; `take_writer()` returns
|
||||
`Box<dyn std::io::Write + Send>`; `Child::wait()` blocks. Bridge to async via
|
||||
**three dedicated std threads** feeding tokio mpsc/oneshot channels:
|
||||
|
||||
1. **Reader thread** — blocking reads from `MasterPty::try_clone_reader()` →
|
||||
`mpsc::Sender<Bytes>`. Reads into an 8 KiB buffer, copies each chunk to
|
||||
`Bytes`, `blocking_send`s to the mpsc. On EOF (master reader returns EOF
|
||||
when the slave closes — child exited, PTY buffer drained), sends a
|
||||
zero-length `Bytes` sentinel and exits. The async-facing `TtyHandle.stdout`
|
||||
is the `mpsc::Receiver<Bytes>` wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`.
|
||||
2. **Writer thread** — drains an `mpsc::Receiver<StdinCmd>` → blocking writes
|
||||
to `MasterPty::take_writer()`. `StdinCmd::Bytes(bytes)` writes and flushes;
|
||||
`StdinCmd::Eof` drops the writer (EOF to slave stdin) and exits. The
|
||||
async-facing `TtyHandle.stdin` is the `mpsc::Sender<StdinCmd>` wrapped as
|
||||
`Box<dyn AsyncWrite + Send + Unpin>` (an `AsyncWrite` impl that wraps each
|
||||
`write` as a `StdinCmd::Bytes` and `flush` as a no-op).
|
||||
3. **Waiter thread** — blocking `Child::wait()` → `oneshot::Sender<i32>` with
|
||||
the exit code. The async-facing `TtyHandle.exit_code` is a `Future` wrapping
|
||||
this `oneshot::Receiver<i32>` PLUS a kill guard holding the
|
||||
`portable_pty::ChildKiller` (see ADR-056 below).
|
||||
|
||||
`TtyHandle.stderr` is `None` (PTY backends merge stdout/stderr).
|
||||
|
||||
### StdinCmd
|
||||
|
||||
```rust
|
||||
pub enum StdinCmd {
|
||||
Bytes(Vec<u8>), // write these bytes to the master writer
|
||||
Eof, // close the master writer (EOF to the slave's stdin)
|
||||
}
|
||||
```
|
||||
|
||||
### AsyncWrite wrapper for stdin
|
||||
|
||||
Implement an `AsyncWrite` impl over `mpsc::Sender<StdinCmd>`: `poll_write`
|
||||
sends `StdinCmd::Bytes(bytes.to_vec())` (awaiting the send via `poll`), `poll_flush`
|
||||
is a no-op (the writer thread flushes), `poll_close` sends `StdinCmd::Eof`. This
|
||||
wraps the tokio mpsc sender as the `Box<dyn AsyncWrite + Send + Unpin>` the
|
||||
`TtyHandle.stdin` field requires.
|
||||
|
||||
### PtyControl (implements TtyControl)
|
||||
|
||||
```rust
|
||||
pub struct PtyControl {
|
||||
master: Arc<Mutex<Box<dyn MasterPty + Send>>>,
|
||||
killer: Arc<Mutex<Box<dyn portable_pty::ChildKiller + Send + Sync>>>,
|
||||
pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl TtyControl for PtyControl {
|
||||
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16);
|
||||
fn signal(&self, name: &str);
|
||||
}
|
||||
```
|
||||
|
||||
- `resize()` locks the master and calls `MasterPty::resize(PtySize)` —
|
||||
non-blocking (issues an `ioctl`).
|
||||
- `signal()` — see REQ-TTY-02 below.
|
||||
|
||||
### REQ-TTY-02: Signal Forwarding Must Target the Process Group
|
||||
|
||||
`libc::kill(pid, sig)` on the spawned child's pid alone is **insufficient** for
|
||||
terminal semantics: a shell under a PTY will have spawned children (a
|
||||
`find | grep` pipeline, a `make` with sub-makes), and those children won't
|
||||
receive the signal. A real terminal forwards Ctrl-C to the **foreground
|
||||
process group**.
|
||||
|
||||
`portable_pty` makes the child a session leader (when `controlling_tty = true`,
|
||||
the default — `CommandBuilder::set_controlling_tty(true)`), so the child's pid
|
||||
*is* its process-group id, and `libc::kill(-pid, sig)` (the negative pid)
|
||||
reaches the whole group. The POC's `PtyControl::signal` uses exactly this —
|
||||
`kill(-pgid, sig)` with a fallback to `kill(pid, sig)` if the group signal
|
||||
fails (e.g., the child already exited).
|
||||
|
||||
The spec records (tty-local.md §"REQ-TTY-02"):
|
||||
1. The local backend MUST forward signals to the child's process group, not
|
||||
just the child pid. Using `kill(-pgid, sig)` when the child is a session
|
||||
leader.
|
||||
2. The local backend MUST spawn the child as a session leader with a
|
||||
controlling tty (`CommandBuilder::set_controlling_tty(true)` — the default).
|
||||
3. The `TtyControl::signal` contract is "best-effort delivery to the
|
||||
foreground process group." Unknown signal names fall back to the backend's
|
||||
default kill (`portable_pty`'s `ChildKiller::kill` sends SIGHUP); known
|
||||
names map to `libc` signal numbers via `alknet_tty::signal_from_name` and
|
||||
are sent to the group.
|
||||
|
||||
Use `alknet_tty::signal_from_name` (from `alknet-tty`'s `control` module) for
|
||||
the name→number mapping. On non-Unix, fall back to `ChildKiller::kill`.
|
||||
|
||||
### Cancel-Cleanup (ADR-056)
|
||||
|
||||
The `exit_code` future's `Drop`-on-cancel MUST kill the child. Implement a
|
||||
`LocalExitFuture` wrapping the `oneshot::Receiver<i32>` plus a kill guard:
|
||||
|
||||
```rust
|
||||
struct LocalExitFuture {
|
||||
rx: oneshot::Receiver<i32>,
|
||||
killer: Option<portable_pty::ChildKiller>, // None after resolve (disarmed)
|
||||
}
|
||||
|
||||
impl Future for LocalExitFuture {
|
||||
// poll delegates to rx; on Ready, take killer (disarm)
|
||||
}
|
||||
|
||||
impl Drop for LocalExitFuture {
|
||||
fn drop(&mut self) {
|
||||
if let Some(killer) = self.killer.take() {
|
||||
let _ = killer.kill(SIGHUP); // best-effort; child may already be exiting
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
On cancel: `Drop` kills the child (SIGHUP); the child exits; the waiter
|
||||
thread's `wait()` reaps it and exits (its `oneshot::send` fails silently —
|
||||
the receiver was dropped with the future, which is expected); the
|
||||
reader/writer threads exit on channel close. The child is reaped (no zombie)
|
||||
by the waiter thread's `wait()` returning after the kill.
|
||||
|
||||
On happy path: the future resolves, the guard is disarmed (`Option::take()`
|
||||
in `poll`'s `Ready` branch), and the subsequent `Drop` is a no-op. The
|
||||
contract is "kill on cancel; no-op on resolve."
|
||||
|
||||
**Critical**: the POC's `LocalPty::exit_code` was a bare `oneshot::Receiver<i32>`
|
||||
with no kill guard. An implementer who copies the POC's shape without the
|
||||
guard violates the contract and will orphan processes on cancel. This task
|
||||
MUST add the guard.
|
||||
|
||||
### allocate_pty function
|
||||
|
||||
```rust
|
||||
pub fn allocate_pty(
|
||||
terminal: TerminalParams,
|
||||
cmd: Vec<String>,
|
||||
cwd: Option<PathBuf>,
|
||||
env: HashMap<String, String>,
|
||||
) -> Result<TtyHandle, TtyError>;
|
||||
```
|
||||
|
||||
This is called by `LocalTtyBackend::allocate()` (task `tty-local/backend-impl`)
|
||||
when `TtyParams.terminal` is `Some`. It returns a fully-wired `TtyHandle`.
|
||||
|
||||
### Tests
|
||||
|
||||
- **Happy path**: spawn `echo hello` into a PTY, read stdout until the
|
||||
zero-length sentinel, await exit_code → 0.
|
||||
- **Stdin round-trip**: spawn `cat` into a PTY, write bytes via the
|
||||
`AsyncWrite` stdin, read them back via stdout, send `Eof`, await exit 0.
|
||||
- **Resize**: call `PtyControl::resize`, assert no error (the ioctl succeeds).
|
||||
- **Signal (Unix)**: spawn `sleep 60`, send `signal("INT")`, assert the child
|
||||
exits with a signal-terminated status (negative exit code or 130 for SIGINT).
|
||||
Test process-group targeting: spawn `bash -c "sleep 60"`, send INT, assert
|
||||
the `sleep` child also receives it (the group signal).
|
||||
- **Cancel cleanup (ADR-056)**: spawn `sleep 60`, drop the `TtyHandle` (and
|
||||
thus the `exit_code` future) without awaiting it, assert the child is
|
||||
killed (reaped by the waiter thread; no orphan). Use a short delay then
|
||||
check the process is gone.
|
||||
- **Unknown signal name**: send `signal("NOSUCH")`, assert it falls back to
|
||||
`ChildKiller::kill` (SIGHUP); the child exits.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `allocate_pty` function spawns via `portable_pty::native_pty_system().openpty()`
|
||||
- [ ] `CommandBuilder::set_controlling_tty(true)` (the default; session leader)
|
||||
- [ ] Three std threads (reader, writer, waiter) feeding tokio mpsc/oneshot
|
||||
- [ ] `TtyHandle.stdout` is the reader mpsc wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`
|
||||
- [ ] `TtyHandle.stdin` is the writer mpsc wrapped as `Box<dyn AsyncWrite + Send + Unpin>`
|
||||
- [ ] `TtyHandle.stderr` is `None` (PTY merges stdout/stderr)
|
||||
- [ ] `TtyHandle.exit_code` is `LocalExitFuture` (oneshot + kill guard), `BoxFuture<Result<i32, TtyError>>`
|
||||
- [ ] `TtyHandle.control` is `Some(TtyControlHandle::new(Arc::new(PtyControl)))`
|
||||
- [ ] `PtyControl` implements `alknet_tty::TtyControl` (resize, signal)
|
||||
- [ ] `PtyControl::signal` uses `kill(-pgid, sig)` with `kill(pid, sig)` fallback (Unix); `ChildKiller::kill` (non-Unix)
|
||||
- [ ] `PtyControl::signal` uses `alknet_tty::signal_from_name` for name→number
|
||||
- [ ] Unknown signal names fall back to `ChildKiller::kill` (SIGHUP)
|
||||
- [ ] `LocalExitFuture::Drop` calls `ChildKiller::kill(SIGHUP)` when dropped without resolving (ADR-056)
|
||||
- [ ] `LocalExitFuture` disarms the guard on resolve (no-op Drop on happy path)
|
||||
- [ ] Reader thread sends zero-length `Bytes` sentinel on EOF
|
||||
- [ ] Writer thread drops the writer on `StdinCmd::Eof`
|
||||
- [ ] Integration tests: happy path, stdin round-trip, resize, signal (process group), cancel cleanup, unknown signal
|
||||
- [ ] `cargo test -p alknet-tty-local` succeeds
|
||||
- [ ] `cargo clippy -p alknet-tty-local` succeeds with no warnings
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/tty/tty-local.md — §"PTY Mode", §"REQ-TTY-02", §"Cancel-Cleanup (ADR-056)"
|
||||
- docs/architecture/crates/tty/tty-backend.md — `TtyHandle`, `TtyControl` (the types this produces)
|
||||
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053 (REQ-TTY-01)
|
||||
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056 (kill-on-Drop)
|
||||
- /workspace/alknet-tty-poc/src/local_pty.rs — the reference implementation (port + add kill guard)
|
||||
- /workspace/alknet-tty-poc/tests/signal.rs — the SIGINT-forwarding integration test (validates REQ-TTY-02)
|
||||
|
||||
## Notes
|
||||
|
||||
> This is the highest-risk task in the tty-local crate: the three-thread
|
||||
> bridge, the process-group signal targeting, and the ADR-056 kill guard are
|
||||
> all subtle. The POC is the reference but its `exit_code` was a bare
|
||||
> `oneshot::Receiver<i32>` — the kill guard is NEW and required. Do not copy
|
||||
> the POC's `exit_code` shape without the guard. The `AsyncWrite` wrapper over
|
||||
> the mpsc sender is also new (the POC exposed the sender directly); the
|
||||
> `TtyHandle.stdin` field requires `Box<dyn AsyncWrite + Send + Unpin>`.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
@@ -0,0 +1,128 @@
|
||||
---
|
||||
id: tty-local/review-tty-local
|
||||
name: Review alknet-tty-local for REQ-TTY-01/02 and ADR-056 contract conformance
|
||||
status: pending
|
||||
depends_on: [tty-local/backend-impl]
|
||||
scope: moderate
|
||||
risk: low
|
||||
impact: phase
|
||||
level: review
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
Review the `alknet-tty-local` implementation for spec conformance, with
|
||||
particular attention to the three load-bearing requirements: REQ-TTY-01
|
||||
(backends need not be natively async), REQ-TTY-02 (signal forwarding to the
|
||||
process group), and ADR-056 (kill-on-Drop cancel-cleanup). This is the
|
||||
quality checkpoint at the end of the local backend phase, before the
|
||||
integration tests and the feature re-export wire the two crates together.
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **PTY mode conformance** (tty-local.md §"PTY Mode"):
|
||||
- `allocate_pty` uses `portable_pty::native_pty_system().openpty()`
|
||||
- `CommandBuilder::set_controlling_tty(true)` (the default; session leader)
|
||||
- Three std threads (reader, writer, waiter) feeding tokio mpsc/oneshot
|
||||
- `TtyHandle.stdout` is the reader mpsc as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`
|
||||
- `TtyHandle.stdin` is the writer mpsc as `Box<dyn AsyncWrite + Send + Unpin>`
|
||||
- `TtyHandle.stderr` is `None` (PTY merges stdout/stderr)
|
||||
- `TtyHandle.exit_code` is `LocalExitFuture` (oneshot + kill guard)
|
||||
- `TtyHandle.control` is `Some(TtyControlHandle::new(Arc::new(PtyControl)))`
|
||||
- Reader thread sends zero-length `Bytes` sentinel on EOF
|
||||
- Writer thread drops the writer on `StdinCmd::Eof`
|
||||
- `AsyncWrite` wrapper over the mpsc sender (poll_write, poll_flush, poll_close)
|
||||
|
||||
2. **REQ-TTY-02: Signal forwarding to the process group** (tty-local.md §"REQ-TTY-02"):
|
||||
- `PtyControl::signal` uses `kill(-pgid, sig)` (negative pid = process group)
|
||||
- Fallback to `kill(pid, sig)` if the group signal fails
|
||||
- `portable_pty` child is a session leader (`controlling_tty = true`)
|
||||
- `signal_from_name` from `alknet-tty` for the 9 supported names
|
||||
- Unknown names fall back to `ChildKiller::kill` (SIGHUP)
|
||||
- Contract is "best-effort delivery to the foreground process group"
|
||||
- Integration test validates process-group targeting (a child of the shell receives the signal)
|
||||
|
||||
3. **Pipe mode conformance** (tty-local.md §"Pipe Mode"):
|
||||
- `allocate_pipe` uses `tokio::process::Command` with `Stdio::piped()`
|
||||
- `TtyHandle.stdin` is `ChildStdin` as `Box<dyn AsyncWrite + Send + Unpin>`
|
||||
- `TtyHandle.stdout`/`stderr` wrapped as `Pin<Box<dyn Stream<Item = Bytes> + Send>>`
|
||||
- `TtyHandle.stderr` is `Some` (separate streams)
|
||||
- `TtyHandle.exit_code` is `PipeExitFuture` (wait + kill guard)
|
||||
- `TtyHandle.control` is `Some(TtyControlHandle::new(Arc::new(PipeControl)))`
|
||||
- `PipeControl::resize` is a no-op
|
||||
- `PipeControl::signal` uses `kill(pid, sig)` (NOT process group; documented limitation)
|
||||
- Unknown signal names fall back to `start_kill()` (SIGKILL)
|
||||
|
||||
4. **ADR-056: kill-on-Drop cancel-cleanup** (tty-local.md §"Cancel-Cleanup"):
|
||||
- `LocalExitFuture::Drop` calls `ChildKiller::kill(SIGHUP)` when dropped without resolving
|
||||
- `LocalExitFuture` disarms the guard on resolve (no-op Drop on happy path)
|
||||
- `PipeExitFuture::Drop` calls `Child::start_kill()` when dropped without resolving
|
||||
- `PipeExitFuture` disarms the guard on resolve
|
||||
- Neither future is a bare `oneshot::Receiver` / bare `Child::wait()` (the POC's shape — MUST have the guard)
|
||||
- Integration test: drop the `TtyHandle` mid-session, assert the child is killed (no orphan)
|
||||
|
||||
5. **LocalTtyBackend conformance** (tty-local.md, tty-backend.md):
|
||||
- `LocalTtyBackend::new()` takes no deps
|
||||
- `allocate()` branches on `params.terminal` (Some → PTY, None → pipe)
|
||||
- `resource_id()` returns `None`
|
||||
- Empty `cmd` → `TtyError::AllocFailed`
|
||||
- `backend_params` ignored (no backend-specific selector fields)
|
||||
|
||||
6. **Dependency constraints**:
|
||||
- `portable_pty` and `libc` deps present (the heavy deps, here not in alknet-tty)
|
||||
- `alknet-tty` dependency (workspace path) for the trait and types
|
||||
- No `alknet-core` direct dependency (accessed via alknet-tty if needed)
|
||||
- No `bollard`/`russh` (those are future backend crates)
|
||||
|
||||
7. **Pattern consistency**:
|
||||
- `TtyControl` impls use `alknet_tty::TtyControl` trait
|
||||
- `TtyControlHandle::new(Arc::new(...))` wrapping (OQ-43 pattern)
|
||||
- `TtyError` for errors (not `anyhow` — the POC used `anyhow`; the crate uses `TtyError`)
|
||||
- `tracing` for structured logging
|
||||
- `#[cfg(unix)]` gates on `libc::kill` paths
|
||||
|
||||
8. **Test coverage**:
|
||||
- PTY: happy path, stdin round-trip, resize, signal (process group), cancel cleanup, unknown signal
|
||||
- Pipe: happy path, stdin round-trip, separate stderr, signal, cancel cleanup, resize no-op, unknown signal
|
||||
- Backend: PTY dispatch, pipe dispatch, empty cmd, resource_id
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] PTY mode matches tty-local.md §"PTY Mode" (three-thread bridge, PtyControl)
|
||||
- [ ] REQ-TTY-02 satisfied: `kill(-pgid, sig)` with fallback, session leader, process-group test passes
|
||||
- [ ] Pipe mode matches tty-local.md §"Pipe Mode" (tokio::process, PipeControl, stderr Some)
|
||||
- [ ] ADR-056 satisfied: both `LocalExitFuture` and `PipeExitFuture` have kill-on-Drop guards
|
||||
- [ ] Neither exit future is a bare `oneshot::Receiver` / bare `Child::wait()` (the POC's shape)
|
||||
- [ ] `LocalTtyBackend` branches on `terminal`, `resource_id` returns `None`
|
||||
- [ ] `portable_pty`/`libc` deps present; no `bollard`/`russh`/`alknet-core` direct dep
|
||||
- [ ] `TtyControl` impls use the `alknet_tty::TtyControl` trait + `TtyControlHandle::new` wrapping
|
||||
- [ ] `#[cfg(unix)]` gates on `libc::kill` paths
|
||||
- [ ] `cargo fmt --check -p alknet-tty-local` passes
|
||||
- [ ] `cargo clippy -p alknet-tty-local` passes with no warnings
|
||||
- [ ] All tests pass (PTY + pipe + backend)
|
||||
- [ ] Cancel-cleanup integration tests confirm no orphaned processes
|
||||
|
||||
## References
|
||||
|
||||
- docs/architecture/crates/tty/tty-local.md — the authoritative spec
|
||||
- docs/architecture/crates/tty/tty-backend.md — the trait this implements
|
||||
- docs/architecture/decisions/053-ttybackend-trait-and-ttyhandle.md — ADR-053 (REQ-TTY-01)
|
||||
- docs/architecture/decisions/054-local-tty-backend-sibling-crate.md — ADR-054
|
||||
- docs/architecture/decisions/056-backend-cleanup-on-session-cancel.md — ADR-056
|
||||
- /workspace/alknet-tty-poc/src/local_pty.rs — the reference (note: POC lacks the kill guard)
|
||||
- /workspace/alknet-tty-poc/tests/signal.rs — the SIGINT-forwarding test (REQ-TTY-02)
|
||||
|
||||
## Notes
|
||||
|
||||
> This review focuses on the three load-bearing requirements. The ADR-056
|
||||
> kill guard is the most likely deviation — the POC's `exit_code` was a bare
|
||||
> `oneshot::Receiver<i32>` without a guard, and an implementer who copies the
|
||||
> POC's shape verbatim violates the contract. Verify both `LocalExitFuture`
|
||||
> and `PipeExitFuture` have the guard and the cancel-cleanup tests pass (no
|
||||
> orphaned processes). REQ-TTY-02's process-group targeting is the other
|
||||
> subtle requirement — verify the integration test spawns a shell with a
|
||||
> child and confirms the child receives the signal.
|
||||
|
||||
## Summary
|
||||
|
||||
> To be filled on completion
|
||||
Reference in new issue
Block a user