Closes review #003 (prepublish review for v0.1.0). - P8: StdinSink::poll_shutdown parks an inflight reserve+send on a full channel (waker registered) — a stdin blast followed by EOF delivers the EOF instead of stranding it - P9: five poisoned-lock .expect() sites -> unwrap_or_else(into_inner) - P10: three thread-spawn .expect() sites -> TtyError::AllocFailed - P5: tty:open scope gate runs before carriage/cmd/backend-lookup checks (no backend-name enumeration differential for unscoped ids) - P11: recv_stdout terminates on the zero-length drained sentinel; the sentinel is no longer yielded as an item (doc was already the contract); stderr has no sentinel (doc noted) - P2: exclude AGENTS.md + docs/plans/, drop dead Cargo.lock and docs/research/ entries (package list: 42 files, 659.2KiB) - P3: AGENTS.md phase status (all five landed), ADR range 001..009 (+ alktty-native ADR-009 in the mapping), alkcall guidance corrected to v0.4.x / pin "0.4.0"; architecture README ADR-009 row + landed-phase status - P15: backend.rs doc typo; redundant tokio-stream dev-dep removed; NegotiationError::Io arm logs; set_identity failure logs; input_pump.abort() at session end; TtySessionError::Open carries the accept_bi StreamError (no io::Error flattening); borrowing deserialize in open_via_channels (no params.clone()); error_response_bytes guards an "error" key in fields; trivial inline comments promoted/removed; plan-doc test counts + doc front-matter refreshed; session tests that raced session teardown under the abort change use a GatedBackend (exit held until released) Verification: cargo test 104 lib / --all-features 147; clippy (all-targets + wasm32) -D warnings; fmt; wasm check; doc 0 warnings; publish dry-run OK.
146 lines
5.9 KiB
Markdown
146 lines
5.9 KiB
Markdown
# alktty
|
||
|
||
Terminal session protocol for the `alk/tty` ALPN: a JSON negotiation
|
||
frame followed by raw chunks (`[stream_type: u8][length: u32
|
||
be][payload]`), a backend-agnostic `TtyBackend` trait, a
|
||
backend-agnostic producer adapter, and a typed consumer client.
|
||
|
||
A producer/consumer protocol crate on top of
|
||
[alkcall](https://crates.io/crates/alkcall) channels — no networking, no
|
||
transport dependencies. Downstream crates (a `alknet-docker` or
|
||
`alknet-ssh` backend, a sandboxed TS/Python adapter) compose on top of
|
||
it: implement [`TtyBackend`](https://docs.rs/alktty/latest/alktty/trait.TtyBackend.html)
|
||
for your execution environment, and the adapter handles the wire.
|
||
|
||
## Quick start
|
||
|
||
### Producer — register backends and run the adapter
|
||
|
||
```rust
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
|
||
// `LocalTtyBackend` needs the `local` feature; swap in any `TtyBackend`
|
||
// implementation (docker, ssh, ...) compiled for your target.
|
||
let mut backends: HashMap<String, Arc<dyn alktty::TtyBackend>> = HashMap::new();
|
||
backends.insert("local".into(), Arc::new(alktty::LocalTtyBackend::new()));
|
||
|
||
let tty_adapter = alktty::TtyAdapter::new(backends);
|
||
// tty_adapter implements alkcall::core::ProtocolHandler on the `alk/tty`
|
||
// ALPN — hand it to your transport accept loop:
|
||
// tty_adapter.handle(connection, &auth).await
|
||
```
|
||
|
||
### Producer — via `alk/channels` instead of a dedicated ALPN
|
||
|
||
```rust
|
||
use std::collections::HashMap;
|
||
use std::sync::Arc;
|
||
|
||
let backends: HashMap<String, Arc<dyn alktty::TtyBackend>> =
|
||
std::iter::once(("local".to_string(), Arc::new(alktty::LocalTtyBackend::new()) as _))
|
||
.collect();
|
||
|
||
// In your channels acceptor's `install_channel_zero` hook (per-connection):
|
||
// alktty::register_openable(&channel_core, Arc::new(backends), None, &mut registry, auth)
|
||
// Consumers open `channels/tty/sub`; the open op's params ARE the
|
||
// negotiation (ADR-009) — no second frame on the channel stream.
|
||
```
|
||
|
||
### Consumer — open a session over the direct ALPN
|
||
|
||
```rust
|
||
use alkcall::core::Connection;
|
||
use alktty::{NegotiateRequest, TtySession};
|
||
|
||
let connection: Connection = /* dial the transport, negotiate `alk/tty` */;
|
||
let session = TtySession::connect_direct(
|
||
connection,
|
||
NegotiateRequest {
|
||
carriage: "raw".into(),
|
||
backend: "local".into(),
|
||
tty: None,
|
||
cmd: vec!["bash".into()],
|
||
cwd: None,
|
||
env: Default::default(),
|
||
backend_params: Default::default(),
|
||
},
|
||
).await?;
|
||
|
||
session.resize(120, 40, 0, 0).await?;
|
||
session.send_stdin("ls -l\n".into()).await?;
|
||
session.close_stdin().await?;
|
||
|
||
let mut stdout = session.recv_stdout().await;
|
||
while let Some(bytes) = futures::StreamExt::next(&mut stdout).await {
|
||
// The stream ends on the zero-length stdout sentinel ("drained") —
|
||
// it is never yielded as an item.
|
||
// ... render bytes ...
|
||
}
|
||
let code = session.wait().await?;
|
||
```
|
||
|
||
### Consumer — open a session via `alk/channels`
|
||
|
||
```rust
|
||
use alkcall::channels::client::ChannelClient;
|
||
use alktty::TtySession;
|
||
|
||
let client: ChannelClient = /* from_connection on an `alk/channels` connection */;
|
||
let session = TtySession::open_via_channels(
|
||
&client,
|
||
serde_json::json!({
|
||
"carriage": "raw",
|
||
"backend": "local",
|
||
"cmd": ["bash"],
|
||
"tty": {"term": "xterm-256color", "cols": 120, "rows": 40,
|
||
"pixel_width": 0, "pixel_height": 0}
|
||
}),
|
||
).await?;
|
||
```
|
||
|
||
## Architecture
|
||
|
||
Two roles, symmetric per the alk convention (avoid "server"/"client":
|
||
both sides of a connection can be either):
|
||
|
||
| Role | Direct `alk/tty` | Via `alk/channels` |
|
||
|------|------------------|--------------------|
|
||
| **Producer** (runs backends, emits terminal bytes) | [`TtyAdapter`](https://docs.rs/alktty/latest/alktty/adapter/struct.TtyAdapter.html) as a `ProtocolHandler` — negotiation frame → access control → backend allocation → three-pump session driver | [`register_openable`](https://docs.rs/alktty/latest/alktty/channels/fn.register_openable.html) — the registry's ACL gates the open op; the handler spawns the same session driver pre-negotiated |
|
||
| **Consumer** (types input, renders output) | [`TtySession::connect_direct`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.connect_direct) — writes the negotiation frame, exposes typed stdin/stdout/stderr/control/exit methods | [`TtySession::open_via_channels`](https://docs.rs/alktty/latest/alktty/struct.TtySession.html#method.open_via_channels) — the open op's params are the negotiation |
|
||
|
||
One connection hosts many sessions (one per bidi stream on the direct
|
||
path, one per channel on the channels path); sessions are independent.
|
||
|
||
The wire format (ADR-001) is two carriages: a 4-byte
|
||
length-prefixed JSON negotiation frame, then raw chunks —
|
||
`[stream_type: u8][length: u32 be][payload]` with five stream types
|
||
(`STREAM_STDIN`=0 … `STREAM_CTRL_OUT`=4; see
|
||
[`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)).
|
||
Zero-length data chunks are sentinels (stdin EOF from the client,
|
||
stdout drained from the server); the exit code rides as the final
|
||
`ctrl_out` control chunk (ADR-005). The codec is
|
||
[`alktty::wire`](https://docs.rs/alktty/latest/alktty/wire/index.html)'s
|
||
`ChunkReader`/`ChunkWriter`; the machine-readable contract is the BAST
|
||
document at `docs/architecture/tty-bast.md`.
|
||
|
||
### WASM target
|
||
|
||
The default crate (no features) compiles to `wasm32-unknown-unknown` —
|
||
this is what makes the downstream TS/Python adapter story work: a
|
||
wasm-compiled alktty is the protocol layer for a sandboxed adapter. The
|
||
`local` feature (the [`LocalTtyBackend`](https://docs.rs/alktty/latest/alktty/struct.LocalTtyBackend.html)
|
||
reference implementation over `portable_pty` + `tokio::process`) needs a
|
||
real OS and is non-wasm by design.
|
||
|
||
## Documentation
|
||
|
||
- [Architecture docs](docs/architecture/README.md) — the authoritative
|
||
spec: ADRs (001–009), the wire format, the backend trait contract,
|
||
and the local backend's cancel-cleanup semantics.
|
||
- [API docs](https://docs.rs/alktty) — full crate documentation on
|
||
docs.rs.
|
||
|
||
## License
|
||
|
||
MIT OR Apache-2.0 |