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
This commit is contained in:
2026-08-17 10:19:22 +00:00
parent 765f40ae34
commit e1610c2825
8 changed files with 1487 additions and 163 deletions
+34 -62
View File
@@ -87,7 +87,10 @@ pub enum TtySessionError {
/// the stream is a length-prefixed JSON `{"error":"..."}` rather
/// than a raw chunk).
#[error("negotiation rejected: {error}")]
NegotiationRejected { error: String, fields: HashMap<String, String> },
NegotiationRejected {
error: String,
fields: HashMap<String, String>,
},
/// The session ended (server closed the stream) before an `Exit`
/// control chunk arrived. `wait()` returns this when the
/// stdout/stderr pumps drain and no exit chunk was observed.
@@ -174,17 +177,19 @@ impl TtySession {
params: serde_json::Value,
) -> Result<Self, TtySessionError> {
let (channel_id, send, recv) = client
.open_channel(crate::channels::OP_TTY_OPEN, params.clone(), crate::channels::TTY_ALPN)
.open_channel(
crate::channels::OP_TTY_OPEN,
params.clone(),
crate::channels::TTY_ALPN,
)
.await
.map_err(TtySessionError::ChannelsOpen)?;
debug!("tty: opened channel {channel_id} via channels");
let remote_addr = client.manager().remote_addr();
let source = alkcall::channels::source::channel_source(recv, send, remote_addr);
let channel_conn = Connection::from_source(
source,
crate::channels::TTY_ALPN.as_bytes().to_vec(),
);
let channel_conn =
Connection::from_source(source, crate::channels::TTY_ALPN.as_bytes().to_vec());
let negotiate: NegotiateRequest =
serde_json::from_value(params).map_err(TtySessionError::NegotiationSerialize)?;
@@ -237,12 +242,7 @@ impl TtySession {
let (exit_tx, exit_rx) =
tokio::sync::watch::channel::<Option<Result<i32, TtySessionError>>>(None);
let read_pump = tokio::spawn(read_pump(
read,
stdout_tx,
stderr_tx,
exit_tx,
));
let read_pump = tokio::spawn(read_pump(read, stdout_tx, stderr_tx, exit_tx));
Ok(Self {
writer: Mutex::new(writer),
@@ -327,10 +327,9 @@ impl TtySession {
pub async fn recv_stdout(&self) -> Pin<Box<dyn Stream<Item = Bytes> + Send>> {
let mut guard = self.stdout_rx.lock().await;
if let Some(rx) = guard.take() {
return Box::pin(futures::stream::unfold(
rx,
|mut rx| async move { rx.recv().await.map(|bytes| (bytes, rx)) },
));
return Box::pin(futures::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|bytes| (bytes, rx))
}));
}
// Already taken — return an empty stream.
Box::pin(futures::stream::empty())
@@ -343,10 +342,9 @@ impl TtySession {
pub async fn recv_stderr(&self) -> Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>> {
let mut guard = self.stderr_rx.lock().await;
let rx = guard.take()?;
Some(Box::pin(futures::stream::unfold(
rx,
|mut rx| async move { rx.recv().await.map(|bytes| (bytes, rx)) },
)))
Some(Box::pin(futures::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|bytes| (bytes, rx))
})))
}
/// Await the `Exit` control chunk and return the process exit
@@ -516,18 +514,11 @@ mod tests {
let (client, server) = duplex(64 * 1024);
let (server_read, server_write) = tokio::io::split(server);
let server_task = tokio::spawn(async move {
crate::adapter::drive_session(
server_write,
server_read,
backends,
None,
identity,
)
.await;
crate::adapter::drive_session(server_write, server_read, backends, None, identity)
.await;
});
let client_conn =
Connection::from_bidi(client, b"alk/tty".to_vec(), None);
let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
let session = TtySession::connect_direct(client_conn, test_negotiate("mock"))
.await
.expect("connect_direct");
@@ -547,13 +538,10 @@ mod tests {
resources: HashMap::new(),
});
let (session, _server) = wire_session_and_server(backend, identity).await;
let code = tokio::time::timeout(
std::time::Duration::from_secs(5),
session.wait(),
)
.await
.expect("wait didn't time out")
.expect("wait returns exit code");
let code = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
.await
.expect("wait didn't time out")
.expect("wait returns exit code");
assert_eq!(code, 0);
}
@@ -574,15 +562,8 @@ mod tests {
.send_stdin(Bytes::from_static(b"hello"))
.await
.expect("send_stdin");
session
.close_stdin()
.await
.expect("close_stdin");
let _ = tokio::time::timeout(
std::time::Duration::from_secs(5),
session.wait(),
)
.await;
session.close_stdin().await.expect("close_stdin");
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait()).await;
}
#[tokio::test]
@@ -600,11 +581,7 @@ mod tests {
let (session, _server) = wire_session_and_server(backend, identity).await;
session.resize(80, 24, 0, 0).await.expect("resize");
session.signal("INT").await.expect("signal");
let _ = tokio::time::timeout(
std::time::Duration::from_secs(5),
session.wait(),
)
.await;
let _ = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait()).await;
}
#[tokio::test]
@@ -650,17 +627,13 @@ mod tests {
let _ = server.read_exact(&mut body).await;
// Drop `server` — the client's read pump hits EOF.
});
let client_conn =
Connection::from_bidi(client, b"alk/tty".to_vec(), None);
let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
let session = TtySession::connect_direct(client_conn, test_negotiate("mock"))
.await
.expect("connect_direct");
let result = tokio::time::timeout(
std::time::Duration::from_secs(5),
session.wait(),
)
.await
.expect("wait didn't time out");
let result = tokio::time::timeout(std::time::Duration::from_secs(5), session.wait())
.await
.expect("wait didn't time out");
assert!(matches!(result, Err(TtySessionError::NoExitChunk)));
let _ = server_handle.await;
}
@@ -672,12 +645,11 @@ mod tests {
async fn connect_direct_errors_when_stream_is_broken() {
let (client, server) = duplex(64);
drop(server);
let client_conn =
Connection::from_bidi(client, b"alk/tty".to_vec(), None);
let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
let result = TtySession::connect_direct(client_conn, test_negotiate("mock")).await;
assert!(
result.is_err(),
"construction should fail when the negotiation write hits a broken pipe"
);
}
}
}