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:
glm-5.2 committed 2026-08-17 10:19:22 +00:00
1 parent 765f40ae34
commit e1610c2825
8 files changed
+1487 -163

No files matched your search

+78 -98
View File
@@ -182,33 +182,35 @@ fn make_tty_open_handler(
ownership: Option<Arc<dyn OwnershipProvider>>,
identity: Option<alkcall::core::auth::Identity>,
) -> OpenHandler {
Arc::new(move |input: Value, channel_conn: Connection, auth: AuthContext| {
let backends = Arc::clone(&backends);
let ownership = ownership.clone();
let identity = identity.clone().or_else(|| auth.identity.clone());
let _ = input;
Arc::new(
move |input: Value, channel_conn: Connection, auth: AuthContext| {
let backends = Arc::clone(&backends);
let ownership = ownership.clone();
let identity = identity.clone().or_else(|| auth.identity.clone());
let _ = input;
tokio::spawn(async move {
let stream = match channel_conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
debug!("tty: channels open: accept_bi failed: {e}");
return;
}
};
let (client_read, client_write) = tokio::io::split(stream);
drive_session(client_write, client_read, backends, ownership, identity).await;
})
})
tokio::spawn(async move {
let stream = match channel_conn.accept_bi().await {
Ok(s) => s,
Err(e) => {
debug!("tty: channels open: accept_bi failed: {e}");
return;
}
};
let (client_read, client_write) = tokio::io::split(stream);
drive_session(client_write, client_read, backends, ownership, identity).await;
})
},
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::{MockBackend, TtyError};
use alkcall::channels::client::ChannelClient;
use alkcall::channels::operations::ChannelCore;
use alkcall::channels::policy::default_policy;
use alkcall::channels::client::ChannelClient;
use alkcall::core::auth::Identity;
use alkcall::core::types::Connection as CoreConnection;
use alkcall::registry::registration::OperationRegistry;
@@ -230,19 +232,14 @@ mod tests {
ownership: Option<Arc<dyn OwnershipProvider>>,
identity: Option<Identity>,
) -> ChannelClient {
use alkcall::channels::adapter::{
ChannelsAdapter, InstallChannelZero,
};
use alkcall::channels::adapter::{ChannelsAdapter, InstallChannelZero};
use alkcall::core::auth::IdentityProvider;
use alkcall::protocol::connection::split_single_stream;
use alkcall::protocol::dispatch::Dispatcher;
struct NoopIdProvider;
impl IdentityProvider for NoopIdProvider {
fn resolve_from_fingerprint(
&self,
_: &str,
) -> Option<alkcall::core::auth::Identity> {
fn resolve_from_fingerprint(&self, _: &str) -> Option<alkcall::core::auth::Identity> {
None
}
fn resolve_from_token(
@@ -257,65 +254,56 @@ mod tests {
let policy_for_hook = Arc::clone(&policy);
let identity_for_conn = identity.clone();
let install_hook: InstallChannelZero =
Arc::new(move |manager, channel0_conn, auth| {
let backends = Arc::clone(&backends);
let ownership = ownership.clone();
let _identity = identity.clone();
let policy = Arc::clone(&policy_for_hook);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
let install_hook: InstallChannelZero = Arc::new(move |manager, channel0_conn, auth| {
let backends = Arc::clone(&backends);
let ownership = ownership.clone();
let _identity = identity.clone();
let policy = Arc::clone(&policy_for_hook);
tokio::spawn(async move {
let channel0_bidi = match channel0_conn.accept_bi().await {
Ok(s) => s,
Err(_) => return,
};
let (writer, reader) = split_single_stream(channel0_bidi);
// Propagate the identity to the channel-0
// connection so the call dispatch's
// `resolve_identity` sees it. The
// `ChannelsAdapter` builds `channel0_conn` fresh
// from `channel_source` and does NOT inherit the
// outer connection's identity — we set it here
// so the `AccessControl` scope-gate can check it.
if let Some(id) = _identity {
let _ = channel0_conn.set_identity(id);
}
// Propagate the identity to the channel-0
// connection so the call dispatch's
// `resolve_identity` sees it. The
// `ChannelsAdapter` builds `channel0_conn` fresh
// from `channel_source` and does NOT inherit the
// outer connection's identity — we set it here
// so the `AccessControl` scope-gate can check it.
if let Some(id) = _identity {
let _ = channel0_conn.set_identity(id);
}
let core = ChannelCore::new(manager, policy);
let mut registry = OperationRegistry::new();
register_openable(
&core,
Arc::clone(&backends),
ownership.clone(),
&mut registry,
auth.clone(),
)
.expect("register_openable");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(
alkcall::protocol::connection::CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
),
);
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let core = ChannelCore::new(manager, policy);
let mut registry = OperationRegistry::new();
register_openable(
&core,
Arc::clone(&backends),
ownership.clone(),
&mut registry,
auth.clone(),
)
.expect("register_openable");
let registry = Arc::new(registry);
let provider: Arc<dyn IdentityProvider> = Arc::new(NoopIdProvider);
let call_connection = Arc::new(
alkcall::protocol::connection::CallConnection::new_single_stream(
channel0_conn,
Arc::clone(&writer),
),
);
let dp = Dispatcher::new(registry, provider);
dp.run_loop_single_stream(call_connection, reader, writer)
.await;
})
});
let (client_end, server_end) = duplex(64 * 1024);
let client_conn = CoreConnection::from_bidi(
client_end,
b"alk/channels".to_vec(),
None,
);
let server_conn = CoreConnection::from_bidi(
server_end,
b"alk/channels".to_vec(),
None,
);
let client_conn = CoreConnection::from_bidi(client_end, b"alk/channels".to_vec(), None);
let server_conn = CoreConnection::from_bidi(server_end, b"alk/channels".to_vec(), None);
// Set the identity on the server connection so the call
// dispatch sees it (the ACL check runs against the
// connection's identity, not the `install_channel_zero`
@@ -327,10 +315,8 @@ mod tests {
let adapter = ChannelsAdapter::new(install_hook, Arc::new(NoCap));
let auth = AuthContext::anonymous(b"alk/channels");
let _server_handle = tokio::spawn(async move {
let _ = alkcall::core::types::ProtocolHandler::handle(
&adapter, server_conn, &auth,
)
.await;
let _ =
alkcall::core::types::ProtocolHandler::handle(&adapter, server_conn, &auth).await;
});
ChannelClient::from_connection(client_conn)
@@ -376,8 +362,7 @@ mod tests {
.get("required")
.and_then(|v| v.as_array())
.expect("required array");
let required_names: Vec<&str> =
required.iter().filter_map(|v| v.as_str()).collect();
let required_names: Vec<&str> = required.iter().filter_map(|v| v.as_str()).collect();
assert!(required_names.contains(&"carriage"));
assert!(required_names.contains(&"backend"));
assert!(required_names.contains(&"cmd"));
@@ -393,8 +378,7 @@ mod tests {
let manager = alkcall::channels::manager::ChannelManager::with_defaults(handle, None);
let core = ChannelCore::new(manager, default_policy());
let backends: Arc<HashMap<String, Arc<dyn TtyBackend>>> =
Arc::new(HashMap::new());
let backends: Arc<HashMap<String, Arc<dyn TtyBackend>>> = Arc::new(HashMap::new());
let mut registry = OperationRegistry::new();
register_openable(
&core,
@@ -415,10 +399,7 @@ mod tests {
#[tokio::test]
async fn end_to_end_open_op_returns_channel_id() {
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert(
"mock".to_string(),
Arc::new(MockBackend::with_exit_code(0)),
);
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let backends = Arc::new(backends);
let identity = Identity {
@@ -459,10 +440,7 @@ mod tests {
#[tokio::test]
async fn end_to_end_open_op_denies_without_tty_open_scope() {
let mut backends: HashMap<String, Arc<dyn TtyBackend>> = HashMap::new();
backends.insert(
"mock".to_string(),
Arc::new(MockBackend::with_exit_code(0)),
);
backends.insert("mock".to_string(), Arc::new(MockBackend::with_exit_code(0)));
let backends = Arc::new(backends);
let identity = Identity {
@@ -488,7 +466,9 @@ mod tests {
let err = response.result.expect_err("open op should be denied");
assert!(
err.message.contains("scope") || err.code.contains("FORBIDDEN") || err.code.contains("AUTH"),
err.message.contains("scope")
|| err.code.contains("FORBIDDEN")
|| err.code.contains("AUTH"),
"error should mention scope/forbidden/auth, got code={} message={}",
err.code,
err.message
@@ -514,4 +494,4 @@ mod tests {
assert_eq!(code, 7);
let _: Result<i32, TtyError> = Ok(code);
}
}
}