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:
+34
-62
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user