Files
alktty/src/local/backend.rs
T
glm-5.2 e1610c2825 phase 3: port alknet-tty-local behind the local feature
Folds the alknet-tty-local crate into alktty as a feature-gated local
submodule (the single-crate + local-feature design from ADR-054; the
cyclic-dep workaround the mono-repo needed doesn't apply to one crate).

src/local/mod.rs:
- Module root, re-exports LocalTtyBackend, declares backend/pipe/pty
- Doc-commented as feature-gated + non-wasm by design (portable-pty +
  tokio::process need a real OS; enabling local on a wasm target is a
  build error by design)

src/local/backend.rs:
- LocalTtyBackend: implements crate::backend::TtyBackend
- allocate() dispatches on params.terminal: Some -> pty::allocate_pty,
  None -> pipe::allocate_pipe (ADR-054)
- 7 tests: PTY/pipe dispatch, empty-cmd rejection (both modes),
  resource_id returns None, new() constructs

src/local/pty.rs:
- portable_pty + 3 std threads (reader/writer/waiter) feeding tokio
  mpsc/oneshot (REQ-TTY-01 blocking->async bridge)
- PtyControl: resize + REQ-TTY-02 process-group signal forwarding
  (libc::kill(-pgid, sig) + kill(pid, sig) fallback + ChildKiller::kill
  for unknown names)
- LocalExitFuture: ADR-056 kill-on-Drop guard (ChildKiller on cancel,
  Option::take disarms on resolve)
- StdinSink: AsyncWrite over mpc::Sender<StdinCmd> with in-flight
  reserve+send parking for full-channel backpressure
- 7 tests incl. process-group reach (bash -c sleep), cancel-cleanup,
  unknown-signal fallback

src/local/pipe.rs:
- tokio::process::Command + tokio_util::io::ReaderStream
- PipeControl: no-op resize, libc::kill(pid, sig) with SIGKILL
  fallback for unknown names (pid-only, no process group - documented
  limitation of the runner case)
- PipeExitFuture: in-place Child::wait() poll (avoids self-referential
  borrow) + ADR-056 kill-on-Drop guard (start_kill on cancel)
- BytesStream: wraps ReaderStream, strips io::Error to EOF
- 9 tests incl. separate stderr, SIGTERM=-15, SIGKILL=-9 fallback,
  cancel-cleanup pid probe (kill(pid,0) returns ESRCH)

Import migration: alknet_tty::backend::{...} -> crate::backend::{...},
alknet_tty::control::signal_from_name -> crate::control::signal_from_name.

Cargo.toml: no changes needed (portable-pty + tokio-util optional, libc
under cfg(unix), and the local feature wiring tokio/process +
tokio/rt-multi-thread were already in the scaffold per Phase 0).

Also fixes pre-existing rustfmt drift in adapter.rs/channels.rs/
session.rs/lib.rs left by the phase 2 commit (cargo fmt --check without
--features local reported 28 diffs; cargo fmt does not accept
--features, so the earlier 'clean' check was a false negative — the
check errored on the unknown flag and grepped an empty stdout). Lesson:
run cargo fmt --check with no feature flags; cargo fmt doesn't gate on
features.

Verification:
- cargo test --features local -> 103/103 pass (was 80 at phase 2 end;
  +23 new tests across the 3 local modules: 7 backend, 7 pty, 9 pipe)
- cargo test (no features) -> 80/80 pass (local module not compiled)
- cargo clippy --features local --all-targets -> clean
- cargo clippy --all-targets (no features) -> clean
- cargo check --target wasm32-unknown-unknown -> clean (default crate
  stays wasm-clean; local is feature-gated and non-wasm by design)
- cargo fmt --check -> clean
2026-08-17 10:19:22 +00:00

180 lines
6.1 KiB
Rust

//! `LocalTtyBackend`: the [`crate::backend::TtyBackend`] implementation
//! for local processes.
//!
//! `allocate()` branches on `TtyParams::terminal`: `Some(TerminalParams)`
//! selects PTY mode (this crate's `pty` module); `None` selects pipe mode
//! (the runner case, this crate's `pipe` module). One backend serves both
//! (ADR-054).
use crate::backend::{TtyBackend, TtyError, TtyHandle, TtyParams};
use async_trait::async_trait;
/// Local TTY backend: implements [`crate::backend::TtyBackend`] for local
/// processes.
///
/// `allocate()` branches on `TtyParams::terminal`: `Some(TerminalParams)`
/// selects PTY mode (this crate's `pty` module); `None` selects pipe mode
/// (the runner case, this crate's `pipe` module). One backend serves both
/// (ADR-054).
///
/// Takes no constructor dependencies — unlike `DockerTtyBackend` (wraps a
/// `bollard::Docker` client) or `SshTtyBackend` (wraps an SSH session), the
/// `portable_pty` system is process-global. The assembly layer constructs one
/// `LocalTtyBackend` and registers it as `"local"`.
pub struct LocalTtyBackend;
impl LocalTtyBackend {
/// Construct a new `LocalTtyBackend`. The backend is dependency-free;
/// `portable_pty`'s native system is process-global.
pub fn new() -> Self {
Self
}
}
impl Default for LocalTtyBackend {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl TtyBackend for LocalTtyBackend {
/// Allocate a terminal/process session, branching on
/// `params.terminal`.
///
/// `Some(TerminalParams)` dispatches to [`crate::local::pty::allocate_pty`]
/// (PTY mode, real terminal semantics — resize, process-group signal
/// forwarding, merged stdout/stderr). `None` dispatches to
/// [`crate::local::pipe::allocate_pipe`] (pipe mode, the runner case —
/// separate stdout/stderr, no-op resize, pid-only signal).
///
/// `backend_params` is ignored: the local backend has no
/// backend-specific selector fields. An empty `cmd` returns
/// [`TtyError::AllocFailed`] (the adapter already checks this at
/// negotiation, but the backend fails gracefully if called directly).
async fn allocate(&self, params: &TtyParams) -> Result<TtyHandle, TtyError> {
if params.cmd.is_empty() {
return Err(TtyError::AllocFailed {
message: "cmd must be non-empty".to_string(),
});
}
match &params.terminal {
Some(terminal) => crate::local::pty::allocate_pty(
terminal.clone(),
params.cmd.clone(),
params.cwd.clone(),
params.env.clone(),
),
None => crate::local::pipe::allocate_pipe(
params.cmd.clone(),
params.cwd.clone(),
params.env.clone(),
),
}
}
/// The local backend creates its own resource (a process), so there is
/// no pre-existing resource for the ownership check. Returns `None`.
fn resource_id(&self, _params: &TtyParams) -> Option<(&'static str, String)> {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn term() -> crate::backend::TerminalParams {
crate::backend::TerminalParams {
term: None,
cols: 80,
rows: 24,
pixel_width: 0,
pixel_height: 0,
modes: serde_json::Value::Null,
}
}
fn params(terminal: Option<crate::backend::TerminalParams>, cmd: Vec<String>) -> TtyParams {
TtyParams {
terminal,
cmd,
cwd: None,
env: HashMap::new(),
backend_params: serde_json::Map::new(),
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_dispatch_yields_no_stderr() {
let backend = LocalTtyBackend::new();
let handle = backend
.allocate(&params(
Some(term()),
vec!["echo".to_string(), "hi".to_string()],
))
.await
.expect("allocate");
assert!(handle.stderr.is_none(), "PTY backends merge stdout/stderr");
assert!(handle.control.is_some(), "PTY backends provide control");
let _ = handle.exit_code.await;
}
#[tokio::test]
async fn pipe_dispatch_yields_some_stderr() {
let backend = LocalTtyBackend::new();
let handle = backend
.allocate(&params(None, vec!["echo".to_string(), "hi".to_string()]))
.await
.expect("allocate");
assert!(
handle.stderr.is_some(),
"pipe backends have separate stderr"
);
assert!(handle.control.is_some(), "pipe backends provide control");
let _ = handle.exit_code.await;
}
#[tokio::test]
async fn empty_cmd_returns_alloc_failed() {
let backend = LocalTtyBackend::new();
let result = backend.allocate(&params(None, vec![])).await;
assert!(
matches!(result, Err(TtyError::AllocFailed { .. })),
"expected AllocFailed for empty cmd"
);
}
#[tokio::test]
async fn empty_cmd_with_terminal_returns_alloc_failed() {
let backend = LocalTtyBackend::new();
let result = backend.allocate(&params(Some(term()), vec![])).await;
assert!(
matches!(result, Err(TtyError::AllocFailed { .. })),
"expected AllocFailed for empty cmd even with terminal set"
);
}
#[test]
fn resource_id_returns_none() {
let backend = LocalTtyBackend::new();
let params = params(None, vec!["true".to_string()]);
assert!(backend.resource_id(&params).is_none());
}
#[test]
fn resource_id_returns_none_with_terminal() {
let backend = LocalTtyBackend::new();
let params = params(Some(term()), vec!["true".to_string()]);
assert!(backend.resource_id(&params).is_none());
}
#[test]
fn new_constructs() {
let backend = LocalTtyBackend::new();
let params = params(None, vec!["true".to_string()]);
assert!(backend.resource_id(&params).is_none());
}
}