fix: address code review #001 findings (M1, M2, L2, L4, L5, N1-N3, N5)
- M1: TtySession now handles the negotiation-rejection error frame. from_halves peeks the first response byte (ADR-052 §5 disambiguation) and returns NegotiationRejected on a 0x00-prefixed error frame; ChunkReader gains peek_stream_type/read_chunk_after_peek. - M2: wait() now surfaces MalformedExitChunk instead of collapsing it to NoExitChunk. The exit watch channel carries a cloneable ExitOutcome enum; MalformedExitChunk carries a String. - L2: add EmittingBackend + recv_stdout_and_stderr_route_backend_data test covering the consumer read-pump stdout/stderr routing. - L4: MockBackend/MockControl/MockStdinSink are now #[cfg(test)] pub(crate), removing them from the public API. - L5: cargo fmt (the BAST drift test was unformatted). - N1: fix all 9 rustdoc intra-doc links. - N2: fix stale doc paths (crates/tty/ and docs/research/). - N3: amend AGENTS.md §14 to accurately describe the local module's libc::kill unsafe blocks. - N5: consolidate nanos_seed into tests/common/mod.rs. Verification: cargo test (84), cargo test --all-features (107), clippy clean (native + wasm), fmt clean, doc clean, wasm check clean. Coverage: session.rs 79.71% -> 87.43%, total 90.74% -> 91.47%.
This commit is contained in:
@@ -213,11 +213,16 @@ implementation agents.
|
|||||||
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
|
modules, `PascalCase` for types/traits, `SCREAMING_SNAKE_CASE` for
|
||||||
constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`).
|
constants (`STREAM_STDIN`, `TTY_OPEN_SCOPE`, `CHUNK_HEADER_LEN`).
|
||||||
|
|
||||||
14. **No `unsafe`** — the crate has zero `unsafe` blocks. The PTY
|
14. **No `unsafe`** — the crate has zero `unsafe` blocks outside the
|
||||||
bridge's `libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls
|
`local` feature module's signal-forwarding calls. The PTY bridge's
|
||||||
are safe `libc` crate APIs (not `unsafe` blocks in this crate);
|
`libc::kill(-pgid, sig)` and `libc::kill(pid, sig)` calls (and the
|
||||||
bounds-checked slice access via `get(..)`/`ok_or_else` is the
|
pipe-mode `libc::kill(pid, sig)` / `libc::kill(pid, SIGKILL)`
|
||||||
pattern. Do not introduce `unsafe` for performance.
|
fallback) are safe `libc` crate APIs wrapped in `unsafe { ... }`
|
||||||
|
blocks because `libc::kill` is an `unsafe fn`; they are the
|
||||||
|
documented signal-forwarding path, not `unsafe` in the crate's own
|
||||||
|
logic. Bounds-checked slice access via `get(..)`/`ok_or_else` is
|
||||||
|
the pattern. Do not introduce `unsafe` for performance, and do not
|
||||||
|
add `unsafe` outside the `local` module's `libc::kill` calls.
|
||||||
|
|
||||||
## Verification Commands
|
## Verification Commands
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -48,7 +48,7 @@
|
|||||||
//! 3` was documented as "bidirectional" but the adapter ignored `Exit`
|
//! 3` was documented as "bidirectional" but the adapter ignored `Exit`
|
||||||
//! from the client because the two directions were indistinguishable on
|
//! from the client because the two directions were indistinguishable on
|
||||||
//! the same stream_type. The split makes the bidirectionality explicit
|
//! the same stream_type. The split makes the bidirectionality explicit
|
||||||
//! — see `docs/research/alknet-crate-extraction/findings.md` Phase 7.
|
//! — see `docs/architecture/tty-wire.md` §"Control Channel".
|
||||||
//!
|
//!
|
||||||
//! # Cancel cleanup (ADR-056)
|
//! # Cancel cleanup (ADR-056)
|
||||||
//!
|
//!
|
||||||
|
|||||||
+14
-5
@@ -19,7 +19,9 @@ use std::sync::Arc;
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use futures_core::Stream;
|
use futures_core::Stream;
|
||||||
|
#[cfg(test)]
|
||||||
use tokio::sync::{mpsc, oneshot};
|
use tokio::sync::{mpsc, oneshot};
|
||||||
|
#[cfg(test)]
|
||||||
use tokio_stream::wrappers::ReceiverStream;
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
use crate::negotiation::{NegotiateRequest, TerminalParamsWire};
|
use crate::negotiation::{NegotiateRequest, TerminalParamsWire};
|
||||||
@@ -93,7 +95,7 @@ pub struct TtyParams {
|
|||||||
/// Terminal parameters. `None` = pipe mode (no PTY — ADR-054). `Some` =
|
/// Terminal parameters. `None` = pipe mode (no PTY — ADR-054). `Some` =
|
||||||
/// allocate a PTY with these dimensions.
|
/// allocate a PTY with these dimensions.
|
||||||
pub terminal: Option<TerminalParams>,
|
pub terminal: Option<TerminalParams>,
|
||||||
/// Command vector (argv[0] + args). Non-empty.
|
/// Command vector (`argv[0]` + args). Non-empty.
|
||||||
pub cmd: Vec<String>,
|
pub cmd: Vec<String>,
|
||||||
/// Working directory (`None` = inherit/default).
|
/// Working directory (`None` = inherit/default).
|
||||||
pub cwd: Option<PathBuf>,
|
pub cwd: Option<PathBuf>,
|
||||||
@@ -282,12 +284,14 @@ pub trait TtyBackend: Send + Sync {
|
|||||||
|
|
||||||
/// Mock `TtyControl` for tests. Records the last resize/signal call so
|
/// Mock `TtyControl` for tests. Records the last resize/signal call so
|
||||||
/// tests can assert delegation through [`TtyControlHandle`].
|
/// tests can assert delegation through [`TtyControlHandle`].
|
||||||
|
#[cfg(test)]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct MockControl {
|
pub(crate) struct MockControl {
|
||||||
pub last_resize: std::sync::Mutex<Option<(u16, u16, u16, u16)>>,
|
pub last_resize: std::sync::Mutex<Option<(u16, u16, u16, u16)>>,
|
||||||
pub last_signal: std::sync::Mutex<Option<String>>,
|
pub last_signal: std::sync::Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
impl TtyControl for MockControl {
|
impl TtyControl for MockControl {
|
||||||
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) {
|
fn resize(&self, cols: u16, rows: u16, pixel_width: u16, pixel_height: u16) {
|
||||||
*self.last_resize.lock().expect("resize mutex poisoned") =
|
*self.last_resize.lock().expect("resize mutex poisoned") =
|
||||||
@@ -304,23 +308,26 @@ impl TtyControl for MockControl {
|
|||||||
/// mock [`TtyControl`]. The caller can drive the channels directly or via
|
/// mock [`TtyControl`]. The caller can drive the channels directly or via
|
||||||
/// the adapter pump. Use [`MockBackend::with_exit_code`] to fix the exit
|
/// the adapter pump. Use [`MockBackend::with_exit_code`] to fix the exit
|
||||||
/// code the handle resolves to.
|
/// code the handle resolves to.
|
||||||
|
#[cfg(test)]
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
pub struct MockBackend {
|
pub(crate) struct MockBackend {
|
||||||
pub exit_code: Option<i32>,
|
pub exit_code: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
impl MockBackend {
|
impl MockBackend {
|
||||||
pub fn new() -> Self {
|
pub(crate) fn new() -> Self {
|
||||||
Self::default()
|
Self::default()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn with_exit_code(exit_code: i32) -> Self {
|
pub(crate) fn with_exit_code(exit_code: i32) -> Self {
|
||||||
Self {
|
Self {
|
||||||
exit_code: Some(exit_code),
|
exit_code: Some(exit_code),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl TtyBackend for MockBackend {
|
impl TtyBackend for MockBackend {
|
||||||
async fn allocate(&self, _params: &TtyParams) -> Result<TtyHandle, TtyError> {
|
async fn allocate(&self, _params: &TtyParams) -> Result<TtyHandle, TtyError> {
|
||||||
@@ -365,10 +372,12 @@ impl TtyBackend for MockBackend {
|
|||||||
/// backend's stdin sink. Copies the buffer into a `Bytes` and best-effort
|
/// 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
|
/// sends; on a full channel returns `Pending`, on a closed channel returns
|
||||||
/// a broken-pipe error.
|
/// a broken-pipe error.
|
||||||
|
#[cfg(test)]
|
||||||
struct MockStdinSink {
|
struct MockStdinSink {
|
||||||
tx: mpsc::Sender<Bytes>,
|
tx: mpsc::Sender<Bytes>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
impl tokio::io::AsyncWrite for MockStdinSink {
|
impl tokio::io::AsyncWrite for MockStdinSink {
|
||||||
fn poll_write(
|
fn poll_write(
|
||||||
self: Pin<&mut Self>,
|
self: Pin<&mut Self>,
|
||||||
|
|||||||
+4
-4
@@ -9,7 +9,7 @@
|
|||||||
//! on the per-connection overlay registry. When a consumer (a
|
//! on the per-connection overlay registry. When a consumer (a
|
||||||
//! [`crate::session::TtySession`] or any `ChannelClient` caller)
|
//! [`crate::session::TtySession`] or any `ChannelClient` caller)
|
||||||
//! invokes `channels/tty/sub`, the alkcall channels wrapper does
|
//! invokes `channels/tty/sub`, the alkcall channels wrapper does
|
||||||
//! `check_open` → `open_channel` → spawn the [`TtyOpenHandler`] →
|
//! `check_open` → `open_channel` → spawn the `TtyOpenHandler` →
|
||||||
//! respond with `{ channel_id }`. The `TtyOpenHandler` receives the
|
//! respond with `{ channel_id }`. The `TtyOpenHandler` receives the
|
||||||
//! channel's `Connection` (data-plane ALPN `alk/tty`), calls
|
//! channel's `Connection` (data-plane ALPN `alk/tty`), calls
|
||||||
//! `accept_bi()` to get the channel's `BiStream`, and runs
|
//! `accept_bi()` to get the channel's `BiStream`, and runs
|
||||||
@@ -49,8 +49,8 @@ use crate::backend::TtyBackend;
|
|||||||
/// the op is `sub` (subscribe), not `pub` (publish).
|
/// the op is `sub` (subscribe), not `pub` (publish).
|
||||||
pub const OP_TTY_OPEN: &str = "channels/tty/sub";
|
pub const OP_TTY_OPEN: &str = "channels/tty/sub";
|
||||||
|
|
||||||
/// The data-plane ALPN the channel carries. Matches the constant in
|
/// The data-plane ALPN the channel carries. Matches the ALPN returned
|
||||||
/// [`crate::adapter::TtyAdapter::alpn`].
|
/// by [`crate::adapter::TtyAdapter`]'s `alpn()`.
|
||||||
pub const TTY_ALPN: &str = "alk/tty";
|
pub const TTY_ALPN: &str = "alk/tty";
|
||||||
|
|
||||||
/// Register the `channels/tty/sub` open op on a per-connection
|
/// Register the `channels/tty/sub` open op on a per-connection
|
||||||
@@ -67,7 +67,7 @@ pub const TTY_ALPN: &str = "alk/tty";
|
|||||||
/// the `tty:open` scope gate, and a permissive input schema (the
|
/// the `tty:open` scope gate, and a permissive input schema (the
|
||||||
/// `NegotiateRequest` shape — JSON, validated by the
|
/// `NegotiateRequest` shape — JSON, validated by the
|
||||||
/// `drive_session` negotiation reader). It then wraps the
|
/// `drive_session` negotiation reader). It then wraps the
|
||||||
/// [`TtyOpenHandler`] factory and calls
|
/// `TtyOpenHandler` factory and calls
|
||||||
/// [`ChannelCore::register_openable`].
|
/// [`ChannelCore::register_openable`].
|
||||||
///
|
///
|
||||||
/// `backends` is the same backend map the direct-ALPN `TtyAdapter`
|
/// `backends` is the same backend map the direct-ALPN `TtyAdapter`
|
||||||
|
|||||||
+2
-2
@@ -20,14 +20,14 @@
|
|||||||
//! # WASM target
|
//! # WASM target
|
||||||
//!
|
//!
|
||||||
//! The default crate (no features) compiles to
|
//! The default crate (no features) compiles to
|
||||||
//! `wasm32-unknown-unknown`. The [`local`] module is feature-gated
|
//! `wasm32-unknown-unknown`. The `local` module is feature-gated
|
||||||
//! and non-wasm by design (`portable-pty` + `tokio::process` need a
|
//! and non-wasm by design (`portable-pty` + `tokio::process` need a
|
||||||
//! real OS). Downstream TS/Python adapters compile the protocol
|
//! real OS). Downstream TS/Python adapters compile the protocol
|
||||||
//! layer in a sandbox; the local-process backend runs on a real OS.
|
//! layer in a sandbox; the local-process backend runs on a real OS.
|
||||||
//!
|
//!
|
||||||
//! # Local backend
|
//! # Local backend
|
||||||
//!
|
//!
|
||||||
//! The local backend ([`local::LocalTtyBackend`]) is gated behind the
|
//! The local backend (`local::LocalTtyBackend`) is gated behind the
|
||||||
//! `local` feature. It implements [`backend::TtyBackend`] via
|
//! `local` feature. It implements [`backend::TtyBackend`] via
|
||||||
//! `portable_pty` (PTY mode, terminal semantics) and
|
//! `portable_pty` (PTY mode, terminal semantics) and
|
||||||
//! `tokio::process::Command` (pipe mode, the runner case). The
|
//! `tokio::process::Command` (pipe mode, the runner case). The
|
||||||
|
|||||||
+1
-1
@@ -72,7 +72,7 @@ pub struct NegotiateRequest {
|
|||||||
/// these dimensions.
|
/// these dimensions.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub tty: Option<TerminalParamsWire>,
|
pub tty: Option<TerminalParamsWire>,
|
||||||
/// Command vector (argv[0] + args); non-empty (checked by the adapter).
|
/// Command vector (`argv[0]` + args); non-empty (checked by the adapter).
|
||||||
pub cmd: Vec<String>,
|
pub cmd: Vec<String>,
|
||||||
/// Working directory (`None` = inherit/default).
|
/// Working directory (`None` = inherit/default).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
|||||||
+307
-25
@@ -13,7 +13,7 @@
|
|||||||
//!
|
//!
|
||||||
//! - [`TtySession::open_via_channels`] — for the `alk/channels`
|
//! - [`TtySession::open_via_channels`] — for the `alk/channels`
|
||||||
//! multiplexed path. The consumer holds a
|
//! multiplexed path. The consumer holds a
|
||||||
//! [`ChannelClient`][alkcall::channels::ChannelClient], calls
|
//! [`alkcall::channels::client::ChannelClient`], calls
|
||||||
//! `open_via_channels(client, params)`, which invokes
|
//! `open_via_channels(client, params)`, which invokes
|
||||||
//! `channels/tty/sub` on channel 0, adopts the resulting channel,
|
//! `channels/tty/sub` on channel 0, adopts the resulting channel,
|
||||||
//! builds a `Connection` from the reassembled read half + mux write
|
//! builds a `Connection` from the reassembled read half + mux write
|
||||||
@@ -98,7 +98,7 @@ pub enum TtySessionError {
|
|||||||
NoExitChunk,
|
NoExitChunk,
|
||||||
/// The `Exit` control chunk's JSON payload failed to parse.
|
/// The `Exit` control chunk's JSON payload failed to parse.
|
||||||
#[error("malformed exit chunk: {0}")]
|
#[error("malformed exit chunk: {0}")]
|
||||||
MalformedExitChunk(serde_json::Error),
|
MalformedExitChunk(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A live `alk/tty` session — the typed consumer-side handle.
|
/// A live `alk/tty` session — the typed consumer-side handle.
|
||||||
@@ -126,15 +126,31 @@ pub struct TtySession {
|
|||||||
/// (stdout/stderr merged into stdout by the kernel PTY) or after
|
/// (stdout/stderr merged into stdout by the kernel PTY) or after
|
||||||
/// `recv_stderr()` has taken it.
|
/// `recv_stderr()` has taken it.
|
||||||
stderr_rx: Mutex<Option<mpsc::Receiver<Bytes>>>,
|
stderr_rx: Mutex<Option<mpsc::Receiver<Bytes>>>,
|
||||||
/// The exit code, resolved by the read pump when it observes the
|
/// The exit outcome, resolved by the read pump when it observes the
|
||||||
/// `Exit` control chunk. `wait()` awaits this. `Option<Result>`
|
/// `Exit` control chunk. `wait()` awaits this. `Option<ExitOutcome>`
|
||||||
/// starts as `None`; the pump sends `Some(Ok(code))` on exit
|
/// starts as `None`; the pump sends `Some(Exited(code))` on exit
|
||||||
/// chunk or `Some(Err(NoExitChunk))` on stream close.
|
/// chunk, `Some(MalformedExit(msg))` on a malformed exit chunk, or
|
||||||
exit_code: tokio::sync::watch::Receiver<Option<Result<i32, TtySessionError>>>,
|
/// `Some(NoExitChunk)` on stream close.
|
||||||
|
exit_code: tokio::sync::watch::Receiver<Option<ExitOutcome>>,
|
||||||
/// The read pump task handle. Dropping the session aborts it.
|
/// The read pump task handle. Dropping the session aborts it.
|
||||||
_read_pump: JoinHandle<()>,
|
_read_pump: JoinHandle<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The cloneable outcome the read pump resolves into the exit watch
|
||||||
|
/// channel. `wait()` maps this to a [`TtySessionError`] (or the exit
|
||||||
|
/// code). Kept separate from `TtySessionError` because the watch channel
|
||||||
|
/// requires `Clone`, and `TtySessionError` carries non-`Clone` payloads
|
||||||
|
/// (`std::io::Error`, `serde_json::Error`).
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum ExitOutcome {
|
||||||
|
/// The `Exit` control chunk was observed with this code.
|
||||||
|
Exited(i32),
|
||||||
|
/// A `STREAM_CTRL_OUT` chunk failed to parse as a `ControlMessage`.
|
||||||
|
MalformedExit(String),
|
||||||
|
/// The stream closed before an `Exit` chunk was observed.
|
||||||
|
NoExitChunk,
|
||||||
|
}
|
||||||
|
|
||||||
impl TtySession {
|
impl TtySession {
|
||||||
/// Connect directly over a `alk/tty` ALPN connection.
|
/// Connect directly over a `alk/tty` ALPN connection.
|
||||||
///
|
///
|
||||||
@@ -237,12 +253,38 @@ impl TtySession {
|
|||||||
neg_writer.write_frame(&body).await?;
|
neg_writer.write_frame(&body).await?;
|
||||||
let writer = ChunkWriter::new(neg_writer.into_inner());
|
let writer = ChunkWriter::new(neg_writer.into_inner());
|
||||||
|
|
||||||
|
let mut reader = ChunkReader::new(read);
|
||||||
|
// Disambiguate the first response frame (ADR-052 §5): a
|
||||||
|
// negotiation error frame's 4-byte length prefix starts with
|
||||||
|
// `0x00`, while a raw chunk's first byte is a `stream_type` in
|
||||||
|
// `{1, 2, 4}` (the server never sends `0` or `3`). If the server
|
||||||
|
// rejected the negotiation, read the error frame and return
|
||||||
|
// `NegotiationRejected`; otherwise hand the peeked reader to the
|
||||||
|
// read pump.
|
||||||
|
let mut first_byte_peeked = false;
|
||||||
|
match reader.peek_stream_type().await {
|
||||||
|
Ok(0x00) => {
|
||||||
|
return Err(read_negotiation_error(reader.into_inner()).await);
|
||||||
|
}
|
||||||
|
Ok(_) => first_byte_peeked = true,
|
||||||
|
Err(RawError::ConnectionClosed) => {
|
||||||
|
// The server closed cleanly without a response. Fall
|
||||||
|
// through to the read pump, which resolves `NoExitChunk`.
|
||||||
|
}
|
||||||
|
Err(e) => return Err(TtySessionError::Wire(e)),
|
||||||
|
}
|
||||||
|
|
||||||
let (stdout_tx, stdout_rx) = mpsc::channel::<Bytes>(64);
|
let (stdout_tx, stdout_rx) = mpsc::channel::<Bytes>(64);
|
||||||
let (stderr_tx, stderr_rx) = mpsc::channel::<Bytes>(64);
|
let (stderr_tx, stderr_rx) = mpsc::channel::<Bytes>(64);
|
||||||
let (exit_tx, exit_rx) =
|
let (exit_tx, exit_rx) = tokio::sync::watch::channel::<Option<ExitOutcome>>(None);
|
||||||
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(
|
||||||
|
reader,
|
||||||
|
first_byte_peeked,
|
||||||
|
stdout_tx,
|
||||||
|
stderr_tx,
|
||||||
|
exit_tx,
|
||||||
|
));
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
writer: Mutex::new(writer),
|
writer: Mutex::new(writer),
|
||||||
@@ -256,7 +298,7 @@ impl TtySession {
|
|||||||
/// Send stdin bytes. Writes a stdin chunk (stream_type 0) with the
|
/// Send stdin bytes. Writes a stdin chunk (stream_type 0) with the
|
||||||
/// given payload. An empty `bytes` writes a zero-length sentinel
|
/// given payload. An empty `bytes` writes a zero-length sentinel
|
||||||
/// (client stdin EOF — see `tty-wire.md` §"Sentinels"); callers
|
/// (client stdin EOF — see `tty-wire.md` §"Sentinels"); callers
|
||||||
/// that want to signal EOF should use [`close_stdin`] instead,
|
/// that want to signal EOF should use [`Self::close_stdin`] instead,
|
||||||
/// which is explicit.
|
/// which is explicit.
|
||||||
pub async fn send_stdin(&self, bytes: Bytes) -> Result<(), TtySessionError> {
|
pub async fn send_stdin(&self, bytes: Bytes) -> Result<(), TtySessionError> {
|
||||||
let mut writer = self.writer.lock().await;
|
let mut writer = self.writer.lock().await;
|
||||||
@@ -362,11 +404,8 @@ impl TtySession {
|
|||||||
// watch's current value is `Some(_)` — return it.
|
// watch's current value is `Some(_)` — return it.
|
||||||
{
|
{
|
||||||
let borrow = rx.borrow();
|
let borrow = rx.borrow();
|
||||||
if let Some(Ok(code)) = borrow.as_ref() {
|
if let Some(outcome) = borrow.as_ref() {
|
||||||
return Ok(*code);
|
return outcome_to_result(outcome);
|
||||||
}
|
|
||||||
if let Some(Err(_)) = borrow.as_ref() {
|
|
||||||
return Err(TtySessionError::NoExitChunk);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Wait for the read pump to send a value.
|
// Wait for the read pump to send a value.
|
||||||
@@ -375,8 +414,8 @@ impl TtySession {
|
|||||||
.map_err(|_| TtySessionError::NoExitChunk)?;
|
.map_err(|_| TtySessionError::NoExitChunk)?;
|
||||||
let borrow = rx.borrow();
|
let borrow = rx.borrow();
|
||||||
match borrow.as_ref() {
|
match borrow.as_ref() {
|
||||||
Some(Ok(code)) => Ok(*code),
|
Some(outcome) => outcome_to_result(outcome),
|
||||||
Some(Err(_)) | None => Err(TtySessionError::NoExitChunk),
|
None => Err(TtySessionError::NoExitChunk),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,6 +426,62 @@ impl Drop for TtySession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Map a resolved [`ExitOutcome`] to the `wait()` result.
|
||||||
|
fn outcome_to_result(outcome: &ExitOutcome) -> Result<i32, TtySessionError> {
|
||||||
|
match outcome {
|
||||||
|
ExitOutcome::Exited(code) => Ok(*code),
|
||||||
|
ExitOutcome::MalformedExit(msg) => Err(TtySessionError::MalformedExitChunk(msg.clone())),
|
||||||
|
ExitOutcome::NoExitChunk => Err(TtySessionError::NoExitChunk),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read a negotiation error frame (ADR-052 §5) from the raw transport
|
||||||
|
/// and map it to [`TtySessionError::NegotiationRejected`]. The caller
|
||||||
|
/// has already peeked the first byte (`0x00`); this reads the remaining
|
||||||
|
/// 3 length bytes, the body, and parses the `{"error": "...", ...}`
|
||||||
|
/// JSON. Any non-`error` fields are collected into the `fields` map.
|
||||||
|
async fn read_negotiation_error<R>(mut read: R) -> TtySessionError
|
||||||
|
where
|
||||||
|
R: AsyncRead + Unpin,
|
||||||
|
{
|
||||||
|
use tokio::io::AsyncReadExt;
|
||||||
|
|
||||||
|
let mut len_rest = [0u8; 3];
|
||||||
|
if let Err(e) = read.read_exact(&mut len_rest).await {
|
||||||
|
return TtySessionError::Wire(RawError::Io(e));
|
||||||
|
}
|
||||||
|
let length = u32::from_be_bytes([0x00, len_rest[0], len_rest[1], len_rest[2]]) as usize;
|
||||||
|
let mut body = vec![0u8; length];
|
||||||
|
if let Err(e) = read.read_exact(&mut body).await {
|
||||||
|
return TtySessionError::Wire(RawError::Io(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
let value: serde_json::Value = match serde_json::from_slice(&body) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(_) => {
|
||||||
|
return TtySessionError::NegotiationRejected {
|
||||||
|
error: String::from_utf8_lossy(&body).into_owned(),
|
||||||
|
fields: HashMap::new(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut fields = HashMap::new();
|
||||||
|
let mut error = String::new();
|
||||||
|
if let Some(obj) = value.as_object() {
|
||||||
|
for (k, v) in obj {
|
||||||
|
if k == "error" {
|
||||||
|
if let Some(s) = v.as_str() {
|
||||||
|
error = s.to_string();
|
||||||
|
}
|
||||||
|
} else if let Some(s) = v.as_str() {
|
||||||
|
fields.insert(k.clone(), s.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
TtySessionError::NegotiationRejected { error, fields }
|
||||||
|
}
|
||||||
|
|
||||||
/// The read pump: reads chunks off the bidi stream's read half and
|
/// The read pump: reads chunks off the bidi stream's read half and
|
||||||
/// routes them to the stdout/stderr/exit channels. The pump owns the
|
/// routes them to the stdout/stderr/exit channels. The pump owns the
|
||||||
/// `ChunkReader`. When the stream closes (clean EOF or transport
|
/// `ChunkReader`. When the stream closes (clean EOF or transport
|
||||||
@@ -403,17 +498,23 @@ impl Drop for TtySession {
|
|||||||
/// the server shouldn't send these, the pump ignores them (with a
|
/// the server shouldn't send these, the pump ignores them (with a
|
||||||
/// debug log)
|
/// debug log)
|
||||||
async fn read_pump<R>(
|
async fn read_pump<R>(
|
||||||
read: R,
|
mut reader: ChunkReader<R>,
|
||||||
|
mut first_byte_peeked: bool,
|
||||||
stdout_tx: mpsc::Sender<Bytes>,
|
stdout_tx: mpsc::Sender<Bytes>,
|
||||||
stderr_tx: mpsc::Sender<Bytes>,
|
stderr_tx: mpsc::Sender<Bytes>,
|
||||||
exit_tx: tokio::sync::watch::Sender<Option<Result<i32, TtySessionError>>>,
|
exit_tx: tokio::sync::watch::Sender<Option<ExitOutcome>>,
|
||||||
) where
|
) where
|
||||||
R: AsyncRead + Send + Unpin + 'static,
|
R: AsyncRead + Send + Unpin + 'static,
|
||||||
{
|
{
|
||||||
let mut reader = ChunkReader::new(read);
|
|
||||||
let mut exit_resolved = false;
|
let mut exit_resolved = false;
|
||||||
loop {
|
loop {
|
||||||
match reader.read_chunk().await {
|
let read = if first_byte_peeked {
|
||||||
|
first_byte_peeked = false;
|
||||||
|
reader.read_chunk_after_peek().await
|
||||||
|
} else {
|
||||||
|
reader.read_chunk().await
|
||||||
|
};
|
||||||
|
match read {
|
||||||
Ok(chunk) => match chunk.stream_type {
|
Ok(chunk) => match chunk.stream_type {
|
||||||
crate::wire::STREAM_STDOUT => {
|
crate::wire::STREAM_STDOUT => {
|
||||||
if stdout_tx.send(chunk.bytes).await.is_err() {
|
if stdout_tx.send(chunk.bytes).await.is_err() {
|
||||||
@@ -429,7 +530,7 @@ async fn read_pump<R>(
|
|||||||
}
|
}
|
||||||
STREAM_CTRL_OUT => match ControlMessage::from_slice(&chunk.bytes) {
|
STREAM_CTRL_OUT => match ControlMessage::from_slice(&chunk.bytes) {
|
||||||
Ok(ControlMessage::Exit { code }) => {
|
Ok(ControlMessage::Exit { code }) => {
|
||||||
let _ = exit_tx.send(Some(Ok(code)));
|
let _ = exit_tx.send(Some(ExitOutcome::Exited(code)));
|
||||||
exit_resolved = true;
|
exit_resolved = true;
|
||||||
debug!("tty: exit chunk received, code={code}");
|
debug!("tty: exit chunk received, code={code}");
|
||||||
break;
|
break;
|
||||||
@@ -438,7 +539,7 @@ async fn read_pump<R>(
|
|||||||
debug!("tty: ignoring non-exit control on STREAM_CTRL_OUT: {other:?}");
|
debug!("tty: ignoring non-exit control on STREAM_CTRL_OUT: {other:?}");
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = exit_tx.send(Some(Err(TtySessionError::MalformedExitChunk(e))));
|
let _ = exit_tx.send(Some(ExitOutcome::MalformedExit(e.to_string())));
|
||||||
exit_resolved = true;
|
exit_resolved = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -469,7 +570,7 @@ async fn read_pump<R>(
|
|||||||
drop(stdout_tx);
|
drop(stdout_tx);
|
||||||
drop(stderr_tx);
|
drop(stderr_tx);
|
||||||
if !exit_resolved {
|
if !exit_resolved {
|
||||||
let _ = exit_tx.send(Some(Err(TtySessionError::NoExitChunk)));
|
let _ = exit_tx.send(Some(ExitOutcome::NoExitChunk));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,4 +753,185 @@ mod tests {
|
|||||||
"construction should fail when the negotiation write hits a broken pipe"
|
"construction should fail when the negotiation write hits a broken pipe"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A backend that emits fixed stdout/stderr chunks before resolving
|
||||||
|
/// exit, so the consumer's read-pump routing can be tested with
|
||||||
|
/// real data (the `MockBackend` emits nothing).
|
||||||
|
struct EmittingBackend {
|
||||||
|
stdout: Vec<Bytes>,
|
||||||
|
stderr: Vec<Bytes>,
|
||||||
|
exit_code: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl TtyBackend for EmittingBackend {
|
||||||
|
async fn allocate(
|
||||||
|
&self,
|
||||||
|
_params: &crate::backend::TtyParams,
|
||||||
|
) -> Result<crate::backend::TtyHandle, crate::backend::TtyError> {
|
||||||
|
use crate::backend::{TtyControlHandle, TtyHandle};
|
||||||
|
use tokio_stream::wrappers::ReceiverStream;
|
||||||
|
|
||||||
|
let (stdout_tx, stdout_rx) = mpsc::channel::<Bytes>(8);
|
||||||
|
let (stderr_tx, stderr_rx) = mpsc::channel::<Bytes>(8);
|
||||||
|
let (_stdin_tx, _stdin_rx) = mpsc::channel::<Bytes>(8);
|
||||||
|
let (exit_tx, exit_rx) =
|
||||||
|
tokio::sync::oneshot::channel::<Result<i32, crate::backend::TtyError>>();
|
||||||
|
|
||||||
|
let stdout = self.stdout.clone();
|
||||||
|
let stderr = self.stderr.clone();
|
||||||
|
let code = self.exit_code;
|
||||||
|
tokio::spawn(async move {
|
||||||
|
for b in stdout {
|
||||||
|
let _ = stdout_tx.send(b).await;
|
||||||
|
}
|
||||||
|
for b in stderr {
|
||||||
|
let _ = stderr_tx.send(b).await;
|
||||||
|
}
|
||||||
|
let _ = exit_tx.send(Ok(code));
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout: Pin<Box<dyn Stream<Item = Bytes> + Send>> =
|
||||||
|
Box::pin(ReceiverStream::new(stdout_rx));
|
||||||
|
let stderr: Option<Pin<Box<dyn Stream<Item = Bytes> + Send>>> =
|
||||||
|
Some(Box::pin(ReceiverStream::new(stderr_rx)));
|
||||||
|
let stdin: Box<dyn AsyncWrite + Send + Unpin> = Box::new(tokio::io::sink());
|
||||||
|
let control = Some(TtyControlHandle::new(Arc::new(
|
||||||
|
crate::backend::MockControl::default(),
|
||||||
|
)));
|
||||||
|
let exit_code: crate::backend::BoxFuture<Result<i32, crate::backend::TtyError>> =
|
||||||
|
Box::pin(async move {
|
||||||
|
exit_rx
|
||||||
|
.await
|
||||||
|
.map_err(|_| crate::backend::TtyError::WaitFailed {
|
||||||
|
message: "exit sender dropped".to_string(),
|
||||||
|
})
|
||||||
|
.and_then(|r| r)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(TtyHandle {
|
||||||
|
stdin,
|
||||||
|
stdout,
|
||||||
|
stderr,
|
||||||
|
exit_code,
|
||||||
|
control,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The consumer's read pump routes stdout and stderr chunks to the
|
||||||
|
/// correct channels (L2). `MockBackend` emits nothing, so this uses
|
||||||
|
/// an `EmittingBackend` that produces real stdout/stderr data.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn recv_stdout_and_stderr_route_backend_data() {
|
||||||
|
let backend = Arc::new(EmittingBackend {
|
||||||
|
stdout: vec![Bytes::from_static(b"out1"), Bytes::from_static(b"out2")],
|
||||||
|
stderr: vec![Bytes::from_static(b"err1")],
|
||||||
|
exit_code: 0,
|
||||||
|
});
|
||||||
|
let identity = Some(Identity {
|
||||||
|
id: "alice".to_string(),
|
||||||
|
scopes: vec![crate::adapter::TTY_OPEN_SCOPE.to_string()],
|
||||||
|
resources: HashMap::new(),
|
||||||
|
});
|
||||||
|
let (session, _server) = wire_session_and_server(backend, identity).await;
|
||||||
|
|
||||||
|
let stdout = session.recv_stdout().await;
|
||||||
|
let collected: Vec<Bytes> = stdout.collect().await;
|
||||||
|
// The adapter emits a zero-length stdout sentinel after the
|
||||||
|
// backend stream ends; filter it out to assert the data chunks.
|
||||||
|
let data: Vec<Bytes> = collected.into_iter().filter(|b| !b.is_empty()).collect();
|
||||||
|
assert_eq!(
|
||||||
|
data,
|
||||||
|
vec![Bytes::from_static(b"out1"), Bytes::from_static(b"out2")],
|
||||||
|
"stdout chunks should route to the stdout stream"
|
||||||
|
);
|
||||||
|
|
||||||
|
let stderr = session.recv_stderr().await.expect("stderr present");
|
||||||
|
let collected: Vec<Bytes> = stderr.collect().await;
|
||||||
|
assert_eq!(
|
||||||
|
collected,
|
||||||
|
vec![Bytes::from_static(b"err1")],
|
||||||
|
"stderr chunks should route to the stderr stream"
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `wait()` surfaces a malformed exit chunk as
|
||||||
|
/// `MalformedExitChunk`, not `NoExitChunk` (M2). The server sends a
|
||||||
|
/// `STREAM_CTRL_OUT` chunk whose JSON fails to parse as a
|
||||||
|
/// `ControlMessage`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn wait_returns_malformed_exit_chunk() {
|
||||||
|
let (client, mut server) = duplex(64 * 1024);
|
||||||
|
let server_handle = tokio::spawn(async move {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
let _ = server.read_exact(&mut len_buf).await;
|
||||||
|
let len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
let mut body = vec![0u8; len];
|
||||||
|
let _ = server.read_exact(&mut body).await;
|
||||||
|
|
||||||
|
// Write a ctrl_out chunk with a malformed exit payload.
|
||||||
|
let payload = br#"{"type":"exit","code":"not-a-number"}"#;
|
||||||
|
let mut header = [0u8; 5];
|
||||||
|
header[0] = crate::wire::STREAM_CTRL_OUT;
|
||||||
|
header[1..].copy_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||||
|
let _ = server.write_all(&header).await;
|
||||||
|
let _ = server.write_all(payload).await;
|
||||||
|
let _ = server.flush().await;
|
||||||
|
});
|
||||||
|
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");
|
||||||
|
assert!(
|
||||||
|
matches!(result, Err(TtySessionError::MalformedExitChunk(_))),
|
||||||
|
"expected MalformedExitChunk, got {result:?}"
|
||||||
|
);
|
||||||
|
let _ = server_handle.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `connect_direct` returns `NegotiationRejected` when the server
|
||||||
|
/// rejects the negotiation with an error frame (M1). The server
|
||||||
|
/// reads the negotiation frame and writes back a length-prefixed
|
||||||
|
/// `{"error":"unknown_backend","backend":"nope"}` frame.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn connect_direct_returns_negotiation_rejected() {
|
||||||
|
let (client, mut server) = duplex(64 * 1024);
|
||||||
|
let server_handle = tokio::spawn(async move {
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
let mut len_buf = [0u8; 4];
|
||||||
|
let _ = server.read_exact(&mut len_buf).await;
|
||||||
|
let len = u32::from_be_bytes(len_buf) as usize;
|
||||||
|
let mut body = vec![0u8; len];
|
||||||
|
let _ = server.read_exact(&mut body).await;
|
||||||
|
|
||||||
|
let err_body = br#"{"error":"unknown_backend","backend":"nope"}"#;
|
||||||
|
let _ = server
|
||||||
|
.write_all(&(err_body.len() as u32).to_be_bytes())
|
||||||
|
.await;
|
||||||
|
let _ = server.write_all(err_body).await;
|
||||||
|
let _ = server.flush().await;
|
||||||
|
});
|
||||||
|
let client_conn = Connection::from_bidi(client, b"alk/tty".to_vec(), None);
|
||||||
|
let result = TtySession::connect_direct(client_conn, test_negotiate("mock")).await;
|
||||||
|
match result {
|
||||||
|
Err(TtySessionError::NegotiationRejected { error, fields }) => {
|
||||||
|
assert_eq!(error, "unknown_backend");
|
||||||
|
assert_eq!(fields.get("backend").map(String::as_str), Some("nope"));
|
||||||
|
}
|
||||||
|
Ok(_) => panic!("expected NegotiationRejected, got Ok(session)"),
|
||||||
|
Err(other) => panic!("expected NegotiationRejected, got {other:?}"),
|
||||||
|
}
|
||||||
|
let _ = server_handle.await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-6
@@ -29,7 +29,7 @@
|
|||||||
//! server. Control chunks are never zero-length (the JSON payload is at
|
//! server. Control chunks are never zero-length (the JSON payload is at
|
||||||
//! least `{}`). The codec does not special-case sentinels — they are just
|
//! least `{}`). The codec does not special-case sentinels — they are just
|
||||||
//! chunks with `length == 0`; the adapter interprets them. See
|
//! chunks with `length == 0`; the adapter interprets them. See
|
||||||
//! `docs/architecture/crates/tty/tty-wire.md` §"Sentinels".
|
//! `docs/architecture/tty-wire.md` §"Sentinels".
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
@@ -164,7 +164,31 @@ impl<R: AsyncRead + Unpin> ChunkReader<R> {
|
|||||||
|
|
||||||
/// Read one chunk: header, validate, payload.
|
/// Read one chunk: header, validate, payload.
|
||||||
pub async fn read_chunk(&mut self) -> Result<Chunk, RawError> {
|
pub async fn read_chunk(&mut self) -> Result<Chunk, RawError> {
|
||||||
match self.reader.read_exact(&mut self.header).await {
|
self.peek_stream_type().await?;
|
||||||
|
self.read_chunk_after_peek().await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Peek the first byte of the next chunk (the `stream_type`) without
|
||||||
|
/// consuming the rest of the header. Used by the consumer to
|
||||||
|
/// disambiguate a negotiation error frame (whose 4-byte length prefix
|
||||||
|
/// starts with `0x00`) from a raw chunk (whose first byte is a
|
||||||
|
/// `stream_type` in `{1, 2, 4}` — the server never sends `0` or `3`).
|
||||||
|
/// After peeking, the caller either reads the error frame itself (via
|
||||||
|
/// [`ChunkReader::into_inner`]) or completes the chunk with
|
||||||
|
/// [`ChunkReader::read_chunk_after_peek`].
|
||||||
|
pub async fn peek_stream_type(&mut self) -> Result<u8, RawError> {
|
||||||
|
match self.reader.read_exact(&mut self.header[..1]).await {
|
||||||
|
Ok(_) => Ok(self.header[0]),
|
||||||
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => Err(RawError::ConnectionClosed),
|
||||||
|
Err(e) => Err(RawError::Io(e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Complete reading a chunk whose first byte was already read via
|
||||||
|
/// [`ChunkReader::peek_stream_type`]. Reads the remaining 4 header
|
||||||
|
/// bytes, validates, and reads the payload.
|
||||||
|
pub async fn read_chunk_after_peek(&mut self) -> Result<Chunk, RawError> {
|
||||||
|
match self.reader.read_exact(&mut self.header[1..]).await {
|
||||||
Ok(_) => {}
|
Ok(_) => {}
|
||||||
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
|
Err(e) if e.kind() == io::ErrorKind::UnexpectedEof => {
|
||||||
return Err(RawError::ConnectionClosed);
|
return Err(RawError::ConnectionClosed);
|
||||||
@@ -444,8 +468,8 @@ mod tests {
|
|||||||
/// mitigation.
|
/// mitigation.
|
||||||
#[test]
|
#[test]
|
||||||
fn bast_stream_type_enum_matches_wire_constants() {
|
fn bast_stream_type_enum_matches_wire_constants() {
|
||||||
let bast_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
let bast_path =
|
||||||
.join("docs/architecture/tty-bast.md");
|
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/architecture/tty-bast.md");
|
||||||
let markdown = std::fs::read_to_string(&bast_path)
|
let markdown = std::fs::read_to_string(&bast_path)
|
||||||
.unwrap_or_else(|e| panic!("failed to read {}: {e}", bast_path.display()));
|
.unwrap_or_else(|e| panic!("failed to read {}: {e}", bast_path.display()));
|
||||||
|
|
||||||
@@ -457,8 +481,8 @@ mod tests {
|
|||||||
.find("\n```")
|
.find("\n```")
|
||||||
.expect("BAST json block is terminated")
|
.expect("BAST json block is terminated")
|
||||||
+ json_start;
|
+ json_start;
|
||||||
let bast: serde_json::Value = serde_json::from_str(&markdown[json_start..json_end])
|
let bast: serde_json::Value =
|
||||||
.expect("BAST json block parses");
|
serde_json::from_str(&markdown[json_start..json_end]).expect("BAST json block parses");
|
||||||
|
|
||||||
let stream_type = bast
|
let stream_type = bast
|
||||||
.get("$defs")
|
.get("$defs")
|
||||||
|
|||||||
@@ -289,3 +289,14 @@ pub fn negotiate_pipe_json(backend: &str, cmd: &[&str]) -> String {
|
|||||||
cmd = cmd_json.join(",")
|
cmd = cmd_json.join(",")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A nanos-timestamp seed for uniquifying temp-file names in the
|
||||||
|
/// cancel-cleanup tests (which write a child pid to a temp file and
|
||||||
|
/// probe it after drop).
|
||||||
|
pub fn nanos_seed() -> u64 {
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_nanos() as u64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|||||||
+1
-9
@@ -18,7 +18,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use alktty::local::LocalTtyBackend;
|
use alktty::local::LocalTtyBackend;
|
||||||
use alktty::wire::STREAM_STDOUT;
|
use alktty::wire::STREAM_STDOUT;
|
||||||
use common::{negotiate_pipe_json, spawn_session};
|
use common::{nanos_seed, negotiate_pipe_json, spawn_session};
|
||||||
|
|
||||||
/// 9. Happy path (echo): negotiate `{backend:"local", tty:null,
|
/// 9. Happy path (echo): negotiate `{backend:"local", tty:null,
|
||||||
/// cmd:["echo","hello"]}`, read stdout chunks, assert "hello", exit 0.
|
/// cmd:["echo","hello"]}`, read stdout chunks, assert "hello", exit 0.
|
||||||
@@ -214,11 +214,3 @@ async fn pipe_echo_emits_stdout_chunk_then_sentinel() {
|
|||||||
);
|
);
|
||||||
let _ = server.await;
|
let _ = server.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nanos_seed() -> u64 {
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_nanos() as u64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-9
@@ -21,7 +21,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use alktty::local::LocalTtyBackend;
|
use alktty::local::LocalTtyBackend;
|
||||||
use alktty::wire::{STREAM_CTRL_OUT, STREAM_STDIN};
|
use alktty::wire::{STREAM_CTRL_OUT, STREAM_STDIN};
|
||||||
use common::{negotiate_pty_json, spawn_session};
|
use common::{nanos_seed, negotiate_pty_json, spawn_session};
|
||||||
|
|
||||||
const PTY_NEG_ECHO: &str = r#"{"carriage":"raw","backend":"local","tty":{"cols":80,"rows":24,"pixel_width":0,"pixel_height":0},"cmd":["echo","hello"]}"#;
|
const PTY_NEG_ECHO: &str = r#"{"carriage":"raw","backend":"local","tty":{"cols":80,"rows":24,"pixel_width":0,"pixel_height":0},"cmd":["echo","hello"]}"#;
|
||||||
|
|
||||||
@@ -258,11 +258,3 @@ async fn pty_exit_chunk_is_last() {
|
|||||||
assert!(saw_exit, "did not see the exit chunk");
|
assert!(saw_exit, "did not see the exit chunk");
|
||||||
let _ = server.await;
|
let _ = server.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nanos_seed() -> u64 {
|
|
||||||
use std::time::{SystemTime, UNIX_EPOCH};
|
|
||||||
SystemTime::now()
|
|
||||||
.duration_since(UNIX_EPOCH)
|
|
||||||
.map(|d| d.as_nanos() as u64)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user