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
180 lines
6.1 KiB
Rust
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 ¶ms.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(¶ms(
|
|
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(¶ms(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(¶ms(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(¶ms(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(¶ms).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(¶ms).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn new_constructs() {
|
|
let backend = LocalTtyBackend::new();
|
|
let params = params(None, vec!["true".to_string()]);
|
|
assert!(backend.resource_id(¶ms).is_none());
|
|
}
|
|
}
|