From b5834d338550df8560711925b1e0e6a219f740bc Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Tue, 7 Jul 2026 13:54:36 +0000 Subject: [PATCH 1/2] feat(tty): implement TtyBackend trait, TtyHandle, TtyControl, TtyParams, TtyError (ADR-053) Implements the one-way-door backend inversion point in crates/alknet-tty/src/backend.rs: - TtyBackend trait (#[async_trait]): allocate() + resource_id() default None - TtyError enum (#[non_exhaustive]): AllocFailed, WaitFailed, Io, Backend - TtyParams + TerminalParams (allocation request shapes) - TtyHandle: stdin (AsyncWrite), stdout/stderr (Stream), exit_code (BoxFuture>), control (Option) - TtyControl trait (object-safe, NOT Clone) + TtyControlHandle newtype (#[derive(Clone)] wrapping Arc) - From for TtyParams and From for TerminalParams (adapter uses these; no hand-rolled mapping) - MockBackend (in-memory mpsc pipes + oneshot exit + MockControl) for compile-check and adapter tests - Unit tests: TtyControlHandle clone+delegation, MockBackend allocate/exit, resource_id default, From mapping (pty + pipe modes) TtyBackend trait doc carries REQ-TTY-01 (backends need not be natively async); TtyHandle.exit_code doc carries the ADR-056 kill-on-Drop contract. negotiation.rs carries minimal NegotiateRequest/TerminalParamsWire placeholders (field set per tty-wire.md) so the From conversion compiles before the negotiation task lands; that task replaces them with the full framing machinery. --- Cargo.lock | 1 + crates/alknet-tty/Cargo.toml | 1 + crates/alknet-tty/src/backend.rs | 508 ++++++++++++++++++++++++++- crates/alknet-tty/src/negotiation.rs | 61 +++- 4 files changed, 569 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 49d6d82..390f73d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ dependencies = [ "alknet-core", "async-trait", "bytes", + "futures", "futures-core", "serde", "serde_json", diff --git a/crates/alknet-tty/Cargo.toml b/crates/alknet-tty/Cargo.toml index 208a342..df66dcb 100644 --- a/crates/alknet-tty/Cargo.toml +++ b/crates/alknet-tty/Cargo.toml @@ -19,6 +19,7 @@ local = [] alknet-core = { path = "../alknet-core" } tokio = { version = "1", features = ["full"] } bytes = "1" +futures = "0.3" futures-core = "0.3" tokio-stream = "0.1" serde = { version = "1", features = ["derive"] } diff --git a/crates/alknet-tty/src/backend.rs b/crates/alknet-tty/src/backend.rs index fee2219..2b1eebc 100644 --- a/crates/alknet-tty/src/backend.rs +++ b/crates/alknet-tty/src/backend.rs @@ -1,4 +1,510 @@ //! Backend trait and handle shapes: `TtyBackend`, `TtyHandle`, `TtyControl`, //! `TtyControlHandle`, `TtyParams`, `TerminalParams`, and `TtyError`. +//! +//! This is the inversion point (ADR-053) between the wire-format adapter +//! (`crate::adapter`) and the backend crates (`alknet-tty-local`, future +//! `alknet-docker`, `alknet-ssh`). alknet-tty defines the trait; the +//! backends implement it. The trait shape is a one-way door — changing it +//! after backends exist is a rewrite across crates. The adapter holds a +//! `HashMap>` keyed by the negotiation frame's +//! `backend` string and pumps the `TtyHandle` fields bidirectionally; +//! backends produce handles, they do not write to the wire. -// TODO: implement +use std::collections::HashMap; +use std::future::Future; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use async_trait::async_trait; +use bytes::Bytes; +use futures_core::Stream; +use tokio::sync::{mpsc, oneshot}; +use tokio_stream::wrappers::ReceiverStream; + +use crate::negotiation::{NegotiateRequest, TerminalParamsWire}; + +/// A boxed, sendable future used for the [`TtyHandle::exit_code`] field. +/// +/// Equivalent to `futures::future::BoxFuture<'static, T>`; defined here as +/// `Pin>` to avoid pulling in the `futures` crate +/// (the adapter runtime is tokio; `futures_core` provides only the +/// `Stream` trait, which is all this crate needs). +pub type BoxFuture = Pin + Send + 'static>>; + +/// The error type for [`TtyBackend::allocate`] and the +/// [`TtyHandle::exit_code`] future. +/// +/// `#[non_exhaustive]` so new variants are additive (two-way-door extension +/// within the one-way trait shape — ADR-053). +#[non_exhaustive] +#[derive(Debug, thiserror::Error)] +pub enum TtyError { + /// The PTY couldn't be allocated, the docker exec failed to start, the + /// SSH channel request was rejected. Returned by `allocate()`; the + /// adapter sends `{"error":"allocate_failed",...}` and closes. + #[error("allocate failed: {message}")] + AllocFailed { message: String }, + /// The backend couldn't reap the child / determine the exit code. + /// Returned by the `exit_code` future; the adapter sends + /// `{"type":"exit","code":-1}` (ADR-055 §4). + #[error("wait failed: {message}")] + WaitFailed { message: String }, + /// An I/O error from a backend's stream/handle. + #[error("io: {0}")] + Io(#[from] std::io::Error), + /// A backend-specific error not covered by the above (e.g., a bollard + /// API error, a russh protocol error). + #[error("backend-specific: {message}")] + Backend { message: String }, +} + +/// Terminal dimensions and mode hints for a PTY allocation +/// (`TtyParams::terminal: Some`). +/// +/// `modes` is reserved (OQ-44); backends MUST ignore its content in v1. +#[derive(Debug, Clone)] +pub struct TerminalParams { + /// `TERM` environment value (e.g., `"xterm-256color"`); `None` = + /// backend default. + pub term: Option, + pub cols: u16, + pub rows: u16, + pub pixel_width: u16, + pub pixel_height: u16, + /// Reserved — OQ-44; backends MUST ignore the content in v1. + pub modes: serde_json::Value, +} + +/// The allocation request the adapter passes to [`TtyBackend::allocate`]. +/// +/// `terminal: None` is pipe/runner mode (no PTY, separate stdout/stderr — +/// ADR-054). `terminal: Some` is PTY mode (stdout/stderr merged into +/// `stdout` by the kernel PTY, real terminal semantics). +/// +/// `backend_params` is an opaque JSON object the adapter passes verbatim; +/// each backend deserializes its own strongly-typed params struct from it. +/// alknet-tty has zero knowledge of any backend's params shape. See ADR-053 +/// §"Backend params are opaque." +#[derive(Debug, Clone)] +pub struct TtyParams { + /// Terminal parameters. `None` = pipe mode (no PTY — ADR-054). `Some` = + /// allocate a PTY with these dimensions. + pub terminal: Option, + /// Command vector (argv[0] + args). Non-empty. + pub cmd: Vec, + /// Working directory (`None` = inherit/default). + pub cwd: Option, + /// Environment variables (empty = inherit). + pub env: HashMap, + /// Backend-specific selector fields from the negotiation frame, + /// unparsed. The adapter passes the JSON object through verbatim; the + /// backend deserializes its own strongly-typed params struct from it. + /// alknet-tty has zero knowledge of any backend's params shape. + pub backend_params: serde_json::Map, +} + +impl From for TerminalParams { + fn from(w: TerminalParamsWire) -> Self { + Self { + term: w.term, + cols: w.cols, + rows: w.rows, + pixel_width: w.pixel_width, + pixel_height: w.pixel_height, + modes: w.modes, + } + } +} + +/// Map a wire negotiation frame to the allocation request the backend +/// consumes. The adapter calls this after parsing the negotiation +/// carriage; `backend_params` is passed through verbatim (alknet-tty has +/// zero knowledge of any backend's params shape — ADR-053). Lives here so +/// the adapter does not hand-roll the mapping. +impl From for TtyParams { + fn from(req: NegotiateRequest) -> Self { + Self { + terminal: req.tty.map(TerminalParams::from), + cmd: req.cmd, + cwd: req.cwd, + env: req.env, + backend_params: req.backend_params, + } + } +} + +/// What a backend's `allocate()` produces. The adapter pumps these fields +/// bidirectionally against the wire format (ADR-052). +pub struct TtyHandle { + /// Stdin writer — bytes the adapter pumps from client stdin chunks. + /// `tokio::io::AsyncWrite` (the tokio flavor, not `futures::io` — they + /// are incompatible traits; the tokio stack is the adapter's runtime). + pub stdin: Box, + /// Stdout stream — bytes the adapter pumps to client stdout chunks. + /// Ends when the backend's stdout reaches EOF. + pub stdout: Pin + Send>>, + /// Stderr stream — `None` for PTY backends (stdout/stderr merged into + /// `stdout` by the kernel PTY). `Some` for pipe backends (separate + /// streams). + pub stderr: Option + Send>>>, + /// Exit code — a `Future` the adapter awaits. Resolves when the + /// process/container/SSH exec exits. The adapter sends the result as + /// the `{"type":"exit","code":N}` control chunk (ADR-055) and closes + /// the stream. This is a `BoxFuture`, not a method on `TtyHandle`, so + /// the adapter can `select` between exit and stream-close without + /// coupling to the other fields (REQ-TTY-01). + /// + /// # ADR-056 — kill-on-Drop contract + /// + /// Dropping this future without driving it to completion MUST kill the + /// session target (the child process, the docker exec, the SSH + /// channel's process). The kill is best-effort (a no-op if the target + /// already exited) but MUST be attempted even when the target is + /// blocked in a state that ignores stdin EOF (a daemon, a process in + /// uninterruptible sleep, a container whose process ignores channel + /// close). The adapter triggers this by dropping the `TtyHandle` on + /// session cancel (connection drop, stream reset, task panic); the + /// backend wires the kill into this future's `Drop`-on-cancel guard + /// (e.g. the local backend holds a `portable_pty::ChildKiller`; docker + /// holds the container id + bollard client; SSH holds the russh + /// channel). A backend that returns a bare `oneshot::Receiver` + /// (or any future without a kill-on-`Drop` guard) as `exit_code` + /// violates the contract and will orphan processes on cancel. The + /// `Drop` MUST be a no-op when the future resolved normally (the + /// adapter awaited it to completion). See ADR-056 and `tty-local.md` + /// §"Cancel-Cleanup" for the local backend's mechanism. + pub exit_code: BoxFuture>, + /// Control handle (resize, signal) — `Clone` so the adapter can hand + /// it to the spawned control-chunk dispatcher. `None` only when the + /// backend genuinely has no control path. See OQ-43. + pub control: Option, +} + +/// Control path for a live terminal session: resize and signal forwarding. +/// +/// Object-safe (`Send + Sync`, no `Clone` — `Clone` is not object-safe). +/// The `Clone`-ability lives on the [`TtyControlHandle`] newtype, which +/// holds the trait object behind an `Arc`. A backend produces its own +/// control type via `TtyControlHandle::new(Arc::new(MyControl))` without +/// the adapter knowing the concrete shape (OQ-43). +pub trait TtyControl: Send + Sync { + /// Resize the terminal. Maps to SSH `window-change`, docker exec + /// resize, or `ioctl(TIOCSWINSZ)` on a local PTY. No-op for pipe + /// backends without a PTY (the adapter still calls it; the backend + /// ignores). + fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16); + + /// Forward a signal by name. Best-effort delivery to the foreground + /// process group (see `tty-local.md` REQ-TTY-02). Unknown names fall + /// back to the backend's default kill. + fn signal(&self, name: &str); +} + +/// The `Clone`-able handle to a backend's control path. The +/// [`TtyControl`] trait is NOT `Clone` (`Clone` is not object-safe — +/// `fn clone(&self) -> Self` returns `Self`, which forbids `dyn` dispatch); +/// the `Clone`-ability lives on this concrete newtype, which holds the +/// trait object behind an `Arc`. The adapter clones the `Arc` to hand a +/// handle to the spawned control-chunk dispatcher. A backend produces its +/// own control type via `TtyControlHandle::new(Arc::new(MyControl))` +/// without the adapter knowing the concrete shape. See OQ-43. +#[derive(Clone)] +pub struct TtyControlHandle(Arc); + +impl TtyControlHandle { + /// Wrap a backend's control implementation. The backend typically + /// calls `TtyControlHandle::new(Arc::new(MyControl))` inside its + /// `allocate()`. + pub fn new(control: Arc) -> Self { + Self(control) + } + + /// Resize the terminal. Delegates to the inner [`TtyControl`]. + pub fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) { + self.0.resize(cols, rows, pixel_width, pixel_height); + } + + /// Forward a signal by name. Delegates to the inner [`TtyControl`]. + pub fn signal(&self, name: &str) { + self.0.signal(name); + } +} + +/// The backend inversion point (ADR-053). alknet-tty defines the trait; +/// the backend crates (`alknet-tty-local`, future `alknet-docker`, +/// `alknet-ssh`) implement it. The adapter holds a +/// `HashMap>` keyed by the negotiation frame's +/// `backend` string and dispatches by that key. +/// +/// # REQ-TTY-01 — backends need not be natively async +/// +/// The adapter-facing types this trait returns (`AsyncWrite`, +/// `Stream`, `BoxFuture`, `TtyControl`) are the **adapter's +/// contract**. A backend may expose blocking handles internally (e.g. +/// `portable_pty`'s blocking `std::io::{Read, Write}` + `Child::wait()`) +/// and bridge them to these async-facing types via dedicated std threads +/// or `tokio::task::spawn_blocking` feeding tokio mpsc/oneshot channels. +/// This bridging pattern is a **documented, supported implementation +/// strategy**, not a workaround. The local backend (`alknet-tty-local`) is +/// the reference implementation: it spawns reader/writer/waiter threads +/// that feed `mpsc::Receiver` (stdout), an `AsyncWrite` adapter +/// over `mpsc::Sender` (stdin), and a `oneshot::Receiver` +/// wrapped in a kill-guard future (exit). The adapter consumes the bridged +/// async-facing types and is unaware of the threading. +/// +/// # ADR-056 — kill-on-Drop contract +/// +/// The [`TtyHandle::exit_code`] future returned by `allocate()` MUST kill +/// the session target when dropped without being driven to completion. See +/// the doc comment on [`TtyHandle::exit_code`] for the full contract. +#[async_trait] +pub trait TtyBackend: Send + Sync { + /// Allocate a terminal/process session and return the handles the + /// adapter pumps. The `backend` field of the negotiation frame + /// (ADR-052) selects which registered backend's `allocate` is called. + async fn allocate(&self, params: &TtyParams) -> Result; + + /// The pre-existing resource this session targets, for ownership + /// checks (ADR-050). `None` = no pre-existing resource (the session + /// creates its own — local process, SSH channel). `Some((kind, id))` + /// = the session targets an existing resource the caller must own + /// (e.g., `DockerTtyBackend` returns `Some(("container", id))`). The + /// adapter calls this at negotiation to gate access; the backend + /// extracts the id from its own `backend_params`. Default `None` + /// (most backends create their own resource). + fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> { + None + } +} + +/// Mock `TtyControl` for tests. Records the last resize/signal call so +/// tests can assert delegation through [`TtyControlHandle`]. +#[derive(Default)] +pub struct MockControl { + pub last_resize: std::sync::Mutex>, + pub last_signal: std::sync::Mutex>, +} + +impl TtyControl for MockControl { + fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) { + *self.last_resize.lock().expect("resize mutex poisoned") = + Some((cols, rows, pixel_width, pixel_height)); + } + + fn signal(&self, name: &str) { + *self.last_signal.lock().expect("signal mutex poisoned") = Some(name.to_string()); + } +} + +/// In-memory `TtyBackend` for tests. `allocate()` wires tokio mpsc +/// channels for stdin/stdout/stderr, a oneshot for `exit_code`, and a +/// mock [`TtyControl`]. The caller can drive the channels directly or via +/// the adapter pump. Use [`MockBackend::with_exit_code`] to fix the exit +/// code the handle resolves to. +#[derive(Default)] +pub struct MockBackend { + pub exit_code: Option, +} + +impl MockBackend { + pub fn new() -> Self { + Self::default() + } + + pub fn with_exit_code(exit_code: i32) -> Self { + Self { + exit_code: Some(exit_code), + } + } +} + +#[async_trait] +impl TtyBackend for MockBackend { + async fn allocate(&self, _params: &TtyParams) -> Result { + let (_stdout_tx, stdout_rx) = mpsc::channel::(8); + let (_stderr_tx, stderr_rx) = mpsc::channel::(8); + let (stdin_tx, _stdin_rx) = mpsc::channel::(8); + let (exit_tx, exit_rx) = oneshot::channel::>(); + + let code = self.exit_code.unwrap_or(0); + tokio::spawn(async move { + let _ = exit_tx.send(Ok(code)); + }); + + let stdout: Pin + Send>> = + Box::pin(ReceiverStream::new(stdout_rx)); + let stderr: Option + Send>>> = + Some(Box::pin(ReceiverStream::new(stderr_rx))); + let stdin: Box = + Box::new(MockStdinSink { tx: stdin_tx }); + let control = Some(TtyControlHandle::new(Arc::new(MockControl::default()))); + + let exit_code: BoxFuture> = Box::pin(async move { + exit_rx + .await + .map_err(|_| TtyError::WaitFailed { + message: "exit_code sender dropped".to_string(), + }) + .and_then(|r| r) + }); + + Ok(TtyHandle { + stdin, + stdout, + stderr, + exit_code, + control, + }) + } +} + +/// `AsyncWrite` adapter over an `mpsc::Sender` — the mock +/// backend's stdin sink. Copies the buffer into a `Bytes` and best-effort +/// sends; on a full channel returns `Pending`, on a closed channel returns +/// a broken-pipe error. +struct MockStdinSink { + tx: mpsc::Sender, +} + +impl tokio::io::AsyncWrite for MockStdinSink { + fn poll_write( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + use std::task::Poll; + match self.tx.try_reserve() { + Ok(permit) => { + permit.send(Bytes::copy_from_slice(buf)); + Poll::Ready(Ok(buf.len())) + } + Err(mpsc::error::TrySendError::Full(_)) => Poll::Pending, + Err(mpsc::error::TrySendError::Closed(_)) => Poll::Ready(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "stdin channel closed", + ))), + } + } + + fn poll_flush( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tty_control_handle_clone_delegates_resize_and_signal() { + let control = Arc::new(MockControl::default()); + let handle = TtyControlHandle::new(control.clone()); + let handle_clone = handle.clone(); + + handle.resize(80, 24, 0, 0); + handle_clone.signal("HUP"); + + let resize = control.last_resize.lock().expect("resize mutex poisoned"); + assert_eq!(*resize, Some((80, 24, 0, 0))); + let signal = control.last_signal.lock().expect("signal mutex poisoned"); + assert_eq!(*signal, Some("HUP".to_string())); + } + + #[tokio::test] + async fn mock_backend_allocates_and_exits() { + let backend = MockBackend::with_exit_code(42); + let params = TtyParams { + terminal: None, + cmd: vec!["echo".to_string(), "hi".to_string()], + cwd: None, + env: HashMap::new(), + backend_params: serde_json::Map::new(), + }; + let handle = backend.allocate(¶ms).await.expect("allocate"); + assert!(handle.stderr.is_some()); + assert!(handle.control.is_some()); + let code = handle.exit_code.await.expect("exit_code"); + assert_eq!(code, 42); + } + + #[tokio::test] + async fn mock_backend_resource_id_default_none() { + let backend = MockBackend::new(); + let params = TtyParams { + terminal: None, + cmd: vec!["true".to_string()], + cwd: None, + env: HashMap::new(), + backend_params: serde_json::Map::new(), + }; + assert!(backend.resource_id(¶ms).is_none()); + } + + #[test] + fn tty_params_from_negotiate_request_maps_fields() { + let req = NegotiateRequest { + carriage: "raw".to_string(), + backend: "local".to_string(), + tty: Some(TerminalParamsWire { + term: Some("xterm-256color".to_string()), + cols: 80, + rows: 24, + pixel_width: 0, + pixel_height: 0, + modes: serde_json::Value::Null, + }), + cmd: vec!["bash".to_string()], + cwd: Some(PathBuf::from("/tmp")), + env: HashMap::from([("FOO".to_string(), "bar".to_string())]), + backend_params: { + let mut m = serde_json::Map::new(); + m.insert( + "container".to_string(), + serde_json::Value::String("abc".to_string()), + ); + m + }, + }; + let params = TtyParams::from(req); + let term = params.terminal.expect("terminal"); + assert_eq!(term.term.as_deref(), Some("xterm-256color")); + assert_eq!(term.cols, 80); + assert_eq!(term.rows, 24); + assert_eq!(params.cmd, vec!["bash".to_string()]); + assert_eq!(params.cwd.as_deref(), Some(std::path::Path::new("/tmp"))); + assert_eq!(params.env.get("FOO").map(String::as_str), Some("bar")); + assert_eq!( + params + .backend_params + .get("container") + .and_then(|v| v.as_str()), + Some("abc"), + ); + } + + #[test] + fn tty_params_from_negotiate_request_pipe_mode() { + let req = NegotiateRequest { + carriage: "raw".to_string(), + backend: "local".to_string(), + tty: None, + cmd: vec!["true".to_string()], + cwd: None, + env: HashMap::new(), + backend_params: serde_json::Map::new(), + }; + let params = TtyParams::from(req); + assert!(params.terminal.is_none()); + } +} diff --git a/crates/alknet-tty/src/negotiation.rs b/crates/alknet-tty/src/negotiation.rs index 8b758aa..344a8c4 100644 --- a/crates/alknet-tty/src/negotiation.rs +++ b/crates/alknet-tty/src/negotiation.rs @@ -1,4 +1,63 @@ //! Negotiation carriage: `NegotiateRequest`, `TerminalParamsWire`, //! length-prefixed framing reader/writer, and the error response shape. +//! +//! NOTE: `NegotiateRequest` and `TerminalParamsWire` are minimal +//! placeholders defined here so `crate::backend::From +//! for TtyParams` compiles before the negotiation task (tty/negotiation) +//! lands. That task replaces this file with the full wire types, +//! length-prefixed framing reader/writer, and the error response shape. +//! The struct field set is dictated by `tty-wire.md` and is stable; the +//! negotiation task adds the framing machinery around these shapes. -// TODO: implement +use std::collections::HashMap; +use std::path::PathBuf; + +use serde_json::Value; + +/// Minimal placeholder wire-frame shape (see module NOTE). Field set per +/// `tty-wire.md` §"Negotiation carriage." +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct NegotiateRequest { + /// `"raw"` in v1; any other value → `malformed_negotiation`. + pub carriage: String, + /// Backend selector key (`"local"`, `"docker"`, `"ssh"`). + pub backend: String, + /// `None` = pipe mode (no PTY — ADR-054). `Some` = allocate a PTY. + #[serde(default)] + pub tty: Option, + /// Command vector (argv[0] + args); non-empty. + pub cmd: Vec, + /// Working directory (`None` = inherit/default). + #[serde(default)] + pub cwd: Option, + /// Environment variables (empty = inherit). + #[serde(default)] + pub env: HashMap, + /// Backend-specific selector fields, opaque to alknet-tty. The adapter + /// passes this through verbatim; the backend deserializes its own + /// strongly-typed params struct. See ADR-053 §"Backend params are + /// opaque." + #[serde(default)] + pub backend_params: serde_json::Map, +} + +/// Minimal placeholder wire terminal params (see module NOTE). Field set +/// per `tty-wire.md` §"Negotiation carriage." Maps to +/// `crate::backend::TerminalParams` via `From`. +#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)] +pub struct TerminalParamsWire { + /// `TERM` value; `None` = backend default. + #[serde(default)] + pub term: Option, + #[serde(default)] + pub cols: u16, + #[serde(default)] + pub rows: u16, + #[serde(default)] + pub pixel_width: u16, + #[serde(default)] + pub pixel_height: u16, + /// Reserved — OQ-44; backends MUST ignore content in v1. + #[serde(default)] + pub modes: Value, +} From 2074a71f5f8b85e1a6dc156e44d754428156a2c1 Mon Sep 17 00:00:00 2001 From: "glm-5.2" Date: Tue, 7 Jul 2026 13:55:32 +0000 Subject: [PATCH 2/2] chore(tty): drop unused futures crate dep backend.rs defines its own BoxFuture type alias (Pin>) using std::future::Future + futures_core, so the futures crate is unused. Removes the dead dependency. --- Cargo.lock | 1 - crates/alknet-tty/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 390f73d..49d6d82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,7 +137,6 @@ dependencies = [ "alknet-core", "async-trait", "bytes", - "futures", "futures-core", "serde", "serde_json", diff --git a/crates/alknet-tty/Cargo.toml b/crates/alknet-tty/Cargo.toml index df66dcb..208a342 100644 --- a/crates/alknet-tty/Cargo.toml +++ b/crates/alknet-tty/Cargo.toml @@ -19,7 +19,6 @@ local = [] alknet-core = { path = "../alknet-core" } tokio = { version = "1", features = ["full"] } bytes = "1" -futures = "0.3" futures-core = "0.3" tokio-stream = "0.1" serde = { version = "1", features = ["derive"] }