Publish harness and TUI open-source

initial sync from the monorepo
This commit is contained in:
grokkybara[bot] 2026-07-16 06:46:02 +01:00
commit c68e39f604
2734 changed files with 1437016 additions and 0 deletions

View file

@ -0,0 +1,103 @@
use std::fmt;
use tokio::sync::{mpsc, oneshot};
use crate::{
common::{AcpChannelFailure, AcpResult, acp_channel_failure_error},
message::{AcpAgentMessage, AcpArgs, AcpClientMessage, AcpMethod, AcpRequest},
};
/// Receiver/sender pair, either for client/agent or agent/client message types.
pub struct AcpChannel<I, O> {
pub rx: mpsc::UnboundedReceiver<I>,
pub tx: mpsc::UnboundedSender<O>,
}
impl<I: AcpMethod, O: AcpMethod> AcpChannel<I, O> {
pub fn new(rx: mpsc::UnboundedReceiver<I>, tx: mpsc::UnboundedSender<O>) -> Self {
Self { rx, tx }
}
}
/// Client channel: receive client messages from agent, send agent messages to agent.
pub type AcpClientChannel = AcpChannel<AcpClientMessage, AcpAgentMessage>;
/// Agent channel: receive agent messages from client, send client messages to client.
pub type AcpAgentChannel = AcpChannel<AcpAgentMessage, AcpClientMessage>;
/// Create a linked pair of client/agent channels.
pub fn acp_channels() -> (AcpClientChannel, AcpAgentChannel) {
let (tx1, rx1) = mpsc::unbounded_channel();
let (tx2, rx2) = mpsc::unbounded_channel();
(AcpChannel::new(rx1, tx2), AcpChannel::new(rx2, tx1))
}
pub async fn acp_send<R, T>(request: T, tx: &mpsc::UnboundedSender<R>) -> AcpResult<T::Response>
where
T: AcpRequest,
R: From<AcpArgs<T>> + fmt::Debug,
{
let (response_tx, response_rx) = oneshot::channel();
let method = request.method_name();
let args = AcpArgs {
request,
response_tx,
};
tx.send(args.into()).map_err(|_| {
acp_channel_failure_error(
format!("unable to send '{method}' request, channel closed"),
AcpChannelFailure::SendFailed,
)
})?;
response_rx.await.map_err(|_| {
acp_channel_failure_error(
format!("unable to receive '{method}' response, channel closed"),
AcpChannelFailure::RecvFailed,
)
})?
}
#[cfg(test)]
mod acp_send_failure_tests {
use super::acp_send;
use crate::common::{AcpChannelFailure, acp_channel_failure};
use crate::message::AcpAgentMessage;
use agent_client_protocol as acp;
use tokio::sync::mpsc;
fn ext_request() -> acp::ExtRequest {
acp::ExtRequest::new(
"x.ai/test",
serde_json::value::to_raw_value(&serde_json::json!({}))
.unwrap()
.into(),
)
}
#[tokio::test]
async fn send_failed_when_receiver_dropped_before_send() {
let (tx, rx) = mpsc::unbounded_channel::<AcpAgentMessage>();
drop(rx); // no peer listening -> enqueue fails
let err = acp_send(ext_request(), &tx).await.unwrap_err();
assert_eq!(
acp_channel_failure(&err),
Some(AcpChannelFailure::SendFailed)
);
}
#[tokio::test]
async fn recv_failed_when_response_channel_dropped_after_send() {
let (tx, mut rx) = mpsc::unbounded_channel::<AcpAgentMessage>();
let mut send_fut = Box::pin(acp_send(ext_request(), &tx));
// First poll enqueues the request, then parks on the response channel.
assert!(futures::poll!(send_fut.as_mut()).is_pending());
// The peer "receives" the request then drops it (dropping response_tx).
drop(rx.try_recv().expect("request should be enqueued"));
let err = send_fut.await.unwrap_err();
assert_eq!(
acp_channel_failure(&err),
Some(AcpChannelFailure::RecvFailed)
);
}
}

View file

@ -0,0 +1,115 @@
use agent_client_protocol as acp;
use tokio::sync::{mpsc, oneshot};
use crate::message::{AcpAgentMessage, AcpClientMessage};
pub type AcpResult<T> = Result<T, acp::Error>;
pub type AcpRxo<T> = oneshot::Receiver<AcpResult<T>>;
pub type AcpTxo<T> = oneshot::Sender<AcpResult<T>>;
pub type AcpClientRx = mpsc::UnboundedReceiver<AcpClientMessage>;
pub type AcpClientTx = mpsc::UnboundedSender<AcpClientMessage>;
pub type AcpAgentRx = mpsc::UnboundedReceiver<AcpAgentMessage>;
pub type AcpAgentTx = mpsc::UnboundedSender<AcpAgentMessage>;
pub fn acp_internal_error(message: impl Into<String>) -> acp::Error {
acp::Error::new(acp::ErrorCode::InternalError.into(), message)
}
/// The two distinct ways an [`acp_send`](crate::acp_send) round-trip can fail
/// when the underlying channel is closed. Both surface as a JSON-RPC
/// `INTERNAL_ERROR` (so existing callers and the wire format are unaffected);
/// this typed discriminant — carried in the error's `data` — lets callers tell
/// them apart WITHOUT substring-matching the human-readable `message`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AcpChannelFailure {
/// The request could not be ENQUEUED: the receiver half (the peer's
/// connection task) is already gone, so no peer is listening — e.g. a
/// headless run with no client wired.
SendFailed,
/// The request was enqueued but the RESPONSE channel was dropped before a
/// reply arrived: a peer received the request, then went away (disconnect /
/// process exit) without answering.
RecvFailed,
}
impl AcpChannelFailure {
/// `data` object key under which [`acp_send`](crate::acp_send) records the
/// kind. Namespaced so it can never collide with other `with_data` payloads.
const DATA_KEY: &'static str = "xaiAcpChannelFailure";
const fn tag(self) -> &'static str {
match self {
Self::SendFailed => "send_failed",
Self::RecvFailed => "recv_failed",
}
}
fn from_tag(tag: &str) -> Option<Self> {
match tag {
"send_failed" => Some(Self::SendFailed),
"recv_failed" => Some(Self::RecvFailed),
_ => None,
}
}
}
/// Build the channel-closed error for [`acp_send`](crate::acp_send), tagging it
/// with a typed [`AcpChannelFailure`] discriminant in `data`. The error `code`
/// stays `INTERNAL_ERROR`, so this is purely additive for callers that just
/// propagate the error.
pub(crate) fn acp_channel_failure_error(
message: impl Into<String>,
kind: AcpChannelFailure,
) -> acp::Error {
acp_internal_error(message).data(serde_json::json!({ AcpChannelFailure::DATA_KEY: kind.tag() }))
}
/// Recover the [`AcpChannelFailure`] kind from an error, or `None` if the error
/// did not originate from [`acp_send`](crate::acp_send)'s channel-closed paths
/// (or predates the tag). Consumers use this instead of inspecting `message`.
pub fn acp_channel_failure(err: &acp::Error) -> Option<AcpChannelFailure> {
err.data
.as_ref()
.and_then(|data| data.get(AcpChannelFailure::DATA_KEY))
.and_then(|value| value.as_str())
.and_then(AcpChannelFailure::from_tag)
}
/// Compact single-line JSON for gateway debug traces. Plain (uncolored)
/// output: this feeds `tracing::debug!`, which typically lands in log files
/// where ANSI colors are noise. Replaces the former `colored_json`-backed
/// `color_json` (dropped to shrink the shipped dependency tree).
#[doc(hidden)]
pub fn compact_json<T: serde::Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
#[cfg(test)]
mod channel_failure_tests {
use super::{
AcpChannelFailure, acp, acp_channel_failure, acp_channel_failure_error, acp_internal_error,
};
#[test]
fn classifier_round_trips_both_kinds() {
for kind in [AcpChannelFailure::SendFailed, AcpChannelFailure::RecvFailed] {
let err = acp_channel_failure_error("boom", kind);
// Code stays INTERNAL_ERROR for backward compatibility.
assert_eq!(err.code, acp::ErrorCode::InternalError);
assert_eq!(acp_channel_failure(&err), Some(kind));
}
}
#[test]
fn classifier_none_for_untagged_errors() {
assert_eq!(acp_channel_failure(&acp_internal_error("plain")), None);
// A different `with_data` payload must not be misread as a channel kind.
assert_eq!(
acp_channel_failure(&acp::Error::invalid_params().data("unknown session id")),
None
);
}
}

View file

@ -0,0 +1,695 @@
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use agent_client_protocol as acp;
use serde::Serialize;
use tokio::sync::{mpsc, oneshot};
use tracing::Instrument;
use crate::{
AcpMethod, acp_send,
common::AcpResult,
message::{AcpAgentMessage, AcpArgs, AcpClientMessage, AcpRequest, AcpSide},
};
type SpawnFn = Rc<dyn Fn(Pin<Box<dyn Future<Output = ()>>>)>;
/// Callback that creates a `tracing::Span` from `_meta` for distributed tracing.
type OnMetaFn = Rc<dyn Fn(&acp::Meta) -> tracing::Span>;
/// Gateway receiver - allows sending messages to it via a channel and it will
/// forward them to an underlying connection.
pub struct AcpGatewayReceiver<S: AcpSide, C> {
rx: mpsc::UnboundedReceiver<S::OutMessage>,
conn: C,
tracing: bool,
spawn_fn: SpawnFn,
on_meta: Option<OnMetaFn>,
}
impl<S: AcpSide, C> AcpGatewayReceiver<S, C> {
pub fn new(rx: mpsc::UnboundedReceiver<S::OutMessage>, conn: C) -> Self {
Self {
rx,
conn,
tracing: false,
spawn_fn: Rc::new(|fut| {
tokio::task::spawn_local(fut);
}),
on_meta: None,
}
}
pub fn with_tracing(mut self, tracing: bool) -> Self {
self.tracing = tracing;
self
}
/// Override the spawner used for dispatching incoming messages.
///
/// By default, `spawn_local` is used (suitable for `LocalSet` runtimes).
/// Pass a custom spawner to use a different execution strategy.
pub fn with_spawn_fn(
mut self,
f: impl Fn(Pin<Box<dyn Future<Output = ()>>>) + 'static,
) -> Self {
self.spawn_fn = Rc::new(f);
self
}
/// Hook that builds a `tracing::Span` from `_meta` to `.instrument()` dispatched messages.
pub fn with_on_meta(mut self, f: impl Fn(&acp::Meta) -> tracing::Span + 'static) -> Self {
self.on_meta = Some(Rc::new(f));
self
}
}
/// The other side of the gateway. Allows to send messages to a channel so that
/// they will be forwarded automatically to a connection (as long as gateway
/// receiver side is running in the background).
pub struct AcpGatewaySender<S: AcpSide> {
tx: mpsc::UnboundedSender<S::OutMessage>,
tracing: bool,
}
impl<S: AcpSide> Clone for AcpGatewaySender<S> {
fn clone(&self) -> Self {
Self {
tx: self.tx.clone(),
tracing: self.tracing,
}
}
}
impl<S: AcpSide> AcpGatewaySender<S> {
pub fn new(tx: mpsc::UnboundedSender<S::OutMessage>) -> Self {
Self { tx, tracing: false }
}
pub fn tx(&self) -> mpsc::UnboundedSender<S::OutMessage> {
self.tx.clone()
}
pub fn with_tracing(mut self, tracing: bool) -> Self {
self.tracing = tracing;
self
}
}
pub fn acp_gateway<S: AcpSide, C>(conn: C) -> (AcpGatewaySender<S>, AcpGatewayReceiver<S, C>) {
let (tx, rx) = mpsc::unbounded_channel();
let sender = AcpGatewaySender::new(tx);
let receiver = AcpGatewayReceiver::new(rx, conn);
(sender, receiver)
}
pub type AcpAgentGatewayReceiver = AcpGatewayReceiver<acp::AgentSide, acp::AgentSideConnection>;
pub type AcpAgentGatewaySender = AcpGatewaySender<acp::AgentSide>;
pub type AcpClientGatewayReceiver = AcpGatewayReceiver<acp::ClientSide, acp::ClientSideConnection>;
pub type AcpClientGatewaySender = AcpGatewaySender<acp::ClientSide>;
fn before_request<T: AcpRequest>(args: &AcpArgs<T>, tracing: bool) -> Option<String> {
tracing.then(|| {
let method = crate::common::compact_json(&args.method_name());
tracing::debug!(
"sending {method} request: {}",
crate::common::compact_json(&args.request)
);
method
})
}
fn after_request<T: Serialize>(
response_tx: oneshot::Sender<AcpResult<T>>,
response: AcpResult<T>,
method: Option<String>,
) -> bool {
if let Some(method) = method {
match response {
Ok(ref response) => {
tracing::debug!(
"received {method} response: {}",
crate::common::compact_json(&response)
);
}
Err(ref err) => {
// Log at debug level - errors are handled visually in the TUI status bar
tracing::debug!("received {method} error: {err}");
}
}
}
response_tx.send(response).is_ok()
}
macro_rules! handle {
($args:expr, $tracing:expr, $conn:expr, $name:ident, $spawn:expr, $on_meta:expr $(,)?) => {{
let span = ($on_meta)
.as_ref()
.zip(($args).request.meta.as_ref())
.map(|(f, meta)| f(meta))
.unwrap_or_else(tracing::Span::none);
($spawn)(Box::pin(
async move {
let method = before_request(&($args), $tracing);
let response = ($conn).$name(($args).request).await;
let _ = after_request(($args).response_tx, response, method);
}
.instrument(span),
));
}};
// Variant for types without `meta` field (ExtRequest, ExtNotification).
// $on_meta is accepted (but unused) to disambiguate from the primary pattern.
(no_meta, $args:expr, $tracing:expr, $conn:expr, $name:ident, $spawn:expr, $on_meta:expr $(,)?) => {
($spawn)(Box::pin(async move {
let method = before_request(&($args), $tracing);
let response = ($conn).$name(($args).request).await;
let _ = after_request(($args).response_tx, response, method);
}));
};
}
impl<C: acp::Agent + 'static> AcpGatewayReceiver<acp::ClientSide, C> {
pub async fn run(mut self) {
let conn = Rc::new(self.conn);
let spawn = self.spawn_fn.clone();
let on_meta = self.on_meta.clone();
while let Some(msg) = self.rx.recv().await {
let conn = conn.clone();
match msg {
AcpAgentMessage::Initialize(args) => {
handle!(args, self.tracing, conn, initialize, spawn, on_meta);
}
AcpAgentMessage::Authenticate(args) => {
handle!(args, self.tracing, conn, authenticate, spawn, on_meta);
}
AcpAgentMessage::NewSession(args) => {
handle!(args, self.tracing, conn, new_session, spawn, on_meta);
}
AcpAgentMessage::LoadSession(args) => {
handle!(args, self.tracing, conn, load_session, spawn, on_meta);
}
AcpAgentMessage::SetSessionMode(args) => {
handle!(args, self.tracing, conn, set_session_mode, spawn, on_meta);
}
AcpAgentMessage::Prompt(args) => {
handle!(args, self.tracing, conn, prompt, spawn, on_meta);
}
AcpAgentMessage::Cancel(args) => {
handle!(args, self.tracing, conn, cancel, spawn, on_meta);
}
AcpAgentMessage::ExtMethod(args) => {
handle!(
no_meta,
args,
self.tracing,
conn,
ext_method,
spawn,
on_meta
);
}
AcpAgentMessage::ExtNotification(args) => {
handle!(
no_meta,
args,
self.tracing,
conn,
ext_notification,
spawn,
on_meta
);
}
AcpAgentMessage::SetSessionModel(args) => {
handle!(args, self.tracing, conn, set_session_model, spawn, on_meta);
}
}
}
if self.tracing {
tracing::trace!("stopping gateway loop: receiver channel is closed");
}
}
}
impl<C: acp::Client + 'static> AcpGatewayReceiver<acp::AgentSide, C> {
pub async fn run(mut self) {
let conn = Rc::new(self.conn);
let spawn = self.spawn_fn.clone();
let on_meta = self.on_meta.clone();
while let Some(msg) = self.rx.recv().await {
let conn = conn.clone();
match msg {
AcpClientMessage::RequestPermission(args) => {
handle!(args, self.tracing, conn, request_permission, spawn, on_meta);
}
AcpClientMessage::ReadTextFile(args) => {
handle!(args, self.tracing, conn, read_text_file, spawn, on_meta);
}
AcpClientMessage::WriteTextFile(args) => {
handle!(args, self.tracing, conn, write_text_file, spawn, on_meta);
}
AcpClientMessage::SessionNotification(args) => {
handle!(
args,
self.tracing,
conn,
session_notification,
spawn,
on_meta
);
}
AcpClientMessage::CreateTerminal(args) => {
handle!(args, self.tracing, conn, create_terminal, spawn, on_meta);
}
AcpClientMessage::TerminalOutput(args) => {
handle!(args, self.tracing, conn, terminal_output, spawn, on_meta);
}
AcpClientMessage::ReleaseTerminal(args) => {
handle!(args, self.tracing, conn, release_terminal, spawn, on_meta);
}
AcpClientMessage::WaitForTerminalExit(args) => {
handle!(
args,
self.tracing,
conn,
wait_for_terminal_exit,
spawn,
on_meta
);
}
AcpClientMessage::KillTerminalCommand(args) => {
handle!(args, self.tracing, conn, kill_terminal, spawn, on_meta);
}
AcpClientMessage::ExtMethod(args) => {
handle!(
no_meta,
args,
self.tracing,
conn,
ext_method,
spawn,
on_meta
);
}
AcpClientMessage::ExtNotification(args) => {
handle!(
no_meta,
args,
self.tracing,
conn,
ext_notification,
spawn,
on_meta
);
}
}
}
if self.tracing {
tracing::trace!("stopping gateway loop: receiver channel is closed");
}
}
}
impl<S: AcpSide> AcpGatewaySender<S> {
/// Shared enqueue for the forward variants; `caller` attributes the
/// dropped-receiver log to the right public method.
fn enqueue<T>(
&self,
request: T,
caller: &'static str,
) -> (bool, oneshot::Receiver<AcpResult<T::Response>>)
where
T: AcpRequest,
S::OutMessage: From<AcpArgs<T>>,
{
let (response_tx, response_rx) = oneshot::channel();
let method = request.method_name();
let args = AcpArgs {
request,
response_tx,
};
let accepted = self.tx.send(args.into()).is_ok();
if !accepted {
tracing::debug!(method, "{caller}: receiver dropped, notification discarded");
}
(accepted, response_rx)
}
/// Enqueue a request and return a completion receiver for handler finish.
pub fn forward_with_completion<T>(
&self,
request: T,
) -> oneshot::Receiver<AcpResult<T::Response>>
where
T: AcpRequest,
S::OutMessage: From<AcpArgs<T>>,
{
self.enqueue(request, "forward_with_completion").1
}
/// Enqueue a request without waiting for the response. Returns whether
/// the gateway channel accepted it (`false`: receiver gone, message
/// discarded) so callers keeping delivery-dependent state can retry.
pub fn forward_fire_and_forget<T>(&self, request: T) -> bool
where
T: AcpRequest,
S::OutMessage: From<AcpArgs<T>>,
{
self.enqueue(request, "forward_fire_and_forget").0
}
/// Send a request and await the response. Returns a `Send` future.
///
/// Equivalent to the `acp::Client` / `acp::Agent` trait methods but the
/// returned future is `Send` because this is an inherent async fn — not
/// wrapped by `#[async_trait(?Send)]`.
pub async fn send<T>(&self, request: T) -> AcpResult<T::Response>
where
T: AcpRequest,
S::OutMessage: From<AcpArgs<T>>,
{
self.forward(request).await
}
async fn forward<T>(&self, request: T) -> AcpResult<T::Response>
where
T: AcpRequest,
S::OutMessage: From<AcpArgs<T>>,
{
if self.tracing {
let method = crate::common::compact_json(&request.method_name());
tracing::debug!(
"received {method} request: {}",
crate::common::compact_json(&request)
);
}
acp_send(request, &self.tx).await
}
}
#[async_trait::async_trait(?Send)]
impl acp::Client for AcpGatewaySender<acp::AgentSide> {
async fn request_permission(
&self,
args: acp::RequestPermissionRequest,
) -> AcpResult<acp::RequestPermissionResponse> {
self.forward(args).await
}
async fn write_text_file(
&self,
args: acp::WriteTextFileRequest,
) -> AcpResult<acp::WriteTextFileResponse> {
self.forward(args).await
}
async fn read_text_file(
&self,
args: acp::ReadTextFileRequest,
) -> AcpResult<acp::ReadTextFileResponse> {
self.forward(args).await
}
async fn create_terminal(
&self,
args: acp::CreateTerminalRequest,
) -> AcpResult<acp::CreateTerminalResponse> {
self.forward(args).await
}
async fn terminal_output(
&self,
args: acp::TerminalOutputRequest,
) -> AcpResult<acp::TerminalOutputResponse> {
self.forward(args).await
}
async fn release_terminal(
&self,
args: acp::ReleaseTerminalRequest,
) -> AcpResult<acp::ReleaseTerminalResponse> {
self.forward(args).await
}
async fn wait_for_terminal_exit(
&self,
args: acp::WaitForTerminalExitRequest,
) -> AcpResult<acp::WaitForTerminalExitResponse> {
self.forward(args).await
}
async fn kill_terminal(
&self,
args: acp::KillTerminalRequest,
) -> AcpResult<acp::KillTerminalResponse> {
self.forward(args).await
}
async fn session_notification(&self, args: acp::SessionNotification) -> AcpResult<()> {
// Fire-and-forget: session notifications carry no meaningful response (the
// ACK is `()`), so we must not block the caller waiting for the client to
// acknowledge. When the agent→relay→client path is degraded (e.g. a Slack
// session whose ephemeral WebSocket died mid-turn), the relay write can
// stall for minutes (TCP retransmit timeout). Blocking here freezes the
// terminal streaming loop — its timeout check never fires, the session
// actor can't process new prompts, and the entire session hangs.
self.forward_fire_and_forget(args);
Ok(())
}
async fn ext_method(&self, args: acp::ExtRequest) -> AcpResult<acp::ExtResponse> {
self.forward(args).await
}
async fn ext_notification(&self, args: acp::ExtNotification) -> AcpResult<()> {
// Fire-and-forget for the same reason as `session_notification` above:
// the ACK is `()` and blocking risks hanging the caller when the
// relay→client path is degraded. Many call sites already bypass this
// trait method and call `forward_fire_and_forget` directly.
self.forward_fire_and_forget(args);
Ok(())
}
}
#[async_trait::async_trait(?Send)]
impl acp::Agent for AcpGatewaySender<acp::ClientSide> {
async fn initialize(&self, args: acp::InitializeRequest) -> AcpResult<acp::InitializeResponse> {
self.forward(args).await
}
async fn authenticate(
&self,
args: acp::AuthenticateRequest,
) -> AcpResult<acp::AuthenticateResponse> {
self.forward(args).await
}
async fn new_session(
&self,
args: acp::NewSessionRequest,
) -> AcpResult<acp::NewSessionResponse> {
self.forward(args).await
}
async fn load_session(
&self,
args: acp::LoadSessionRequest,
) -> AcpResult<acp::LoadSessionResponse> {
self.forward(args).await
}
async fn set_session_mode(
&self,
args: acp::SetSessionModeRequest,
) -> AcpResult<acp::SetSessionModeResponse> {
self.forward(args).await
}
async fn prompt(&self, args: acp::PromptRequest) -> AcpResult<acp::PromptResponse> {
self.forward(args).await
}
async fn cancel(&self, args: acp::CancelNotification) -> AcpResult<()> {
self.forward(args).await
}
async fn ext_method(&self, args: acp::ExtRequest) -> AcpResult<acp::ExtResponse> {
self.forward(args).await
}
async fn ext_notification(&self, args: acp::ExtNotification) -> AcpResult<()> {
self.forward(args).await
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
use std::rc::Rc;
use agent_client_protocol as acp;
struct OrderTrackingClient {
log: Rc<RefCell<Vec<String>>>,
}
#[async_trait::async_trait(?Send)]
impl acp::Client for OrderTrackingClient {
async fn request_permission(
&self,
_: acp::RequestPermissionRequest,
) -> acp::Result<acp::RequestPermissionResponse> {
unimplemented!()
}
async fn session_notification(&self, args: acp::SessionNotification) -> acp::Result<()> {
if let acp::SessionUpdate::AgentMessageChunk(chunk) = &args.update
&& let acp::ContentBlock::Text(text) = &chunk.content
{
self.log.borrow_mut().push(text.text.clone());
}
Ok(())
}
}
fn text_notification(marker: &str) -> acp::SessionNotification {
acp::SessionNotification::new(
acp::SessionId::new("s"),
acp::SessionUpdate::AgentMessageChunk(acp::ContentChunk::new(acp::ContentBlock::Text(
acp::TextContent::new(marker),
))),
)
}
/// Regression: draining completion receivers preserves notification ordering.
#[tokio::test]
async fn completion_drain_preserves_notification_ordering() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let log = Rc::new(RefCell::new(Vec::<String>::new()));
let (sender, receiver) =
acp_gateway::<acp::AgentSide, _>(OrderTrackingClient { log: log.clone() });
tokio::task::spawn_local(receiver.run());
const N: usize = 100;
let completions: Vec<_> = (0..N)
.map(|i| sender.forward_with_completion(text_notification(&format!("{i}"))))
.collect();
for rx in completions {
let _ = rx.await;
}
log.borrow_mut().push("RESPONSE".into());
let log = log.borrow();
assert_eq!(log.len(), N + 1);
assert_eq!(log[N], "RESPONSE");
for i in 0..N {
assert_eq!(log[i], format!("{i}"));
}
})
.await;
}
/// Regression: two-phase cutover keeps replay-before-response and avoids
/// dropping live updates during drain.
#[tokio::test]
async fn two_phase_cutover_no_missing_updates() {
let local = tokio::task::LocalSet::new();
local
.run_until(async {
let log = Rc::new(RefCell::new(Vec::<String>::new()));
let (sender, receiver) =
acp_gateway::<acp::AgentSide, _>(OrderTrackingClient { log: log.clone() });
tokio::task::spawn_local(receiver.run());
const DELTA: usize = 50;
const LIVE: usize = 20;
// Phase 1: sync enqueue of replay notifications.
let completions: Vec<_> = (0..DELTA)
.map(|i| {
sender.forward_with_completion(text_notification(&format!("delta-{i}")))
})
.collect();
// Gate-open point; then concurrent producer emits live updates.
let live_sender = sender.clone();
let producer = tokio::task::spawn_local(async move {
for i in 0..LIVE {
live_sender
.forward_fire_and_forget(text_notification(&format!("live-{i}")));
// Encourage interleaving with drain.
tokio::task::yield_now().await;
}
});
// Drain replay completions while producer runs.
for rx in completions {
let _ = rx.await;
}
// Mark response boundary.
log.borrow_mut().push("RESPONSE".into());
// Let producer and gateway finish remaining live updates.
let _ = producer.await;
for _ in 0..LIVE + 5 {
tokio::task::yield_now().await;
}
let log = log.borrow();
let response_idx = log
.iter()
.position(|s| s == "RESPONSE")
.expect("RESPONSE marker must be in the log");
// (1) Delta notifications are all present and before RESPONSE.
for i in 0..DELTA {
let tag = format!("delta-{i}");
let pos = log
.iter()
.position(|s| s == &tag)
.unwrap_or_else(|| panic!("missing delta notification: {tag}"));
assert!(
pos < response_idx,
"{tag} at index {pos} must precede RESPONSE at index {response_idx}"
);
}
// (2) Delta notifications preserve enqueue order.
let delta_positions: Vec<usize> = (0..DELTA)
.map(|i| log.iter().position(|s| s == &format!("delta-{i}")).unwrap())
.collect();
for w in delta_positions.windows(2) {
assert!(
w[0] < w[1],
"delta ordering violated: delta at index {} came after delta at index {}",
w[0],
w[1]
);
}
// (3) No live updates are lost.
for i in 0..LIVE {
let tag = format!("live-{i}");
assert!(
log.iter().any(|s| s == &tag),
"live update lost: {tag} not found in log"
);
}
// (4) Live updates do not precede replay delta.
let last_delta = *delta_positions.last().unwrap();
for i in 0..LIVE {
let tag = format!("live-{i}");
let pos = log.iter().position(|s| s == &tag).unwrap();
assert!(
pos > last_delta,
"{tag} at index {pos} must come after last delta at index {last_delta}"
);
}
})
.await;
}
}

View file

@ -0,0 +1,30 @@
mod channel;
mod common;
mod gateway;
mod line_reader;
mod message;
mod normalize;
mod stdin_reader;
pub use self::{
channel::{AcpAgentChannel, AcpChannel, AcpClientChannel, acp_channels, acp_send},
common::{
AcpAgentRx, AcpAgentTx, AcpChannelFailure, AcpClientRx, AcpClientTx, AcpResult, AcpRxo,
AcpTxo, acp_channel_failure, acp_internal_error,
},
gateway::{
AcpAgentGatewayReceiver, AcpAgentGatewaySender, AcpClientGatewayReceiver,
AcpClientGatewaySender, AcpGatewayReceiver, AcpGatewaySender, acp_gateway,
},
message::{
AcpAgentMessage, AcpAgentMessageBox, AcpAgentMessageGeneric, AcpArgs, AcpArgsBox,
AcpClientMessage, AcpClientMessageBox, AcpClientMessageGeneric, AcpMethod, AcpRequest,
AcpSide, Boxed, StorageMarker, Unboxed,
},
};
pub use self::line_reader::LineBufferedRead;
pub use self::stdin_reader::spawn_stdin_line_reader;
#[doc(hidden)]
pub use self::common::compact_json;

View file

@ -0,0 +1,303 @@
//! Cancel-safe line-buffered [`AsyncRead`] wrapper.
//!
//! `agent-client-protocol` v0.6's `handle_io` uses `select_biased!` with
//! `BufReader::read_line`. `read_line` is **not** cancel-safe: it internally
//! calls `consume()` on partial reads, so dropping the future mid-read loses
//! bytes and corrupts the stream.
//!
//! [`LineBufferedRead`] works around this by pre-reading complete `\n`-delimited
//! lines on a dedicated task and serving them through a channel. The `poll_read`
//! implementation only returns `Pending` *between* lines (when no buffered data
//! remains), so ACP's `BufReader::read_line` always finds `\n` without
//! suspending, and can never be cancelled mid-read by `select_biased!`.
use std::{
io,
pin::Pin,
task::{Context, Poll},
};
use futures::{
AsyncBufRead, AsyncBufReadExt as _, AsyncRead, SinkExt as _, StreamExt as _, channel::mpsc,
io::BufReader,
};
/// Maximum size of a single NDJSON line (64 MiB).
///
/// Prevents unbounded memory growth if a peer sends data without newlines.
/// 64 MiB accommodates the largest legitimate ACP messages (e.g. a
/// multi-megabyte file read response after JSON string escaping).
const MAX_LINE_SIZE: usize = 64 * 1024 * 1024;
/// An [`AsyncRead`] that only yields complete `\n`-delimited lines.
///
/// Internally, a background task reads lines from the wrapped reader and sends
/// them through a channel. [`poll_read`](AsyncRead::poll_read) serves bytes
/// from the current line buffer and only returns `Poll::Pending` when no
/// buffered bytes remain (i.e. between lines). This guarantees that a consumer
/// calling `BufReader::read_line` on this reader will always complete without
/// intermediate `Pending` states, making it safe to use inside `select!`.
pub struct LineBufferedRead {
/// Buffered bytes from the current line being served.
buf: Vec<u8>,
/// Read cursor within `buf`.
pos: usize,
/// Receives complete lines (or an IO error) from the reader task.
rx: mpsc::Receiver<io::Result<Vec<u8>>>,
}
impl LineBufferedRead {
/// Wrap an `AsyncRead` source, spawning the reader task via
/// [`tokio::task::spawn_local`].
pub fn spawn_local(source: impl AsyncRead + Unpin + 'static) -> Self {
Self::new(source, |fut| {
tokio::task::spawn_local(fut);
})
}
/// Wrap an `AsyncRead` source with cancel-safe line buffering.
///
/// A background task is spawned (via `spawn`) that reads `\n`-delimited
/// lines from `source` and feeds them into the returned reader.
pub fn new(
source: impl AsyncRead + Unpin + 'static,
spawn: impl FnOnce(futures::future::LocalBoxFuture<'static, ()>),
) -> Self {
let (mut tx, rx) = mpsc::channel(64);
spawn(Box::pin(async move {
let mut reader = BufReader::new(source);
let mut line = Vec::new();
loop {
match read_line_capped(&mut reader, &mut line).await {
Ok(0) => break,
Ok(_) => {
if tx.send(Ok(line.split_off(0))).await.is_err() {
break;
}
}
Err(e) => {
let _ = tx.send(Err(e)).await;
break;
}
}
}
}));
Self {
buf: Vec::new(),
pos: 0,
rx,
}
}
}
impl AsyncRead for LineBufferedRead {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut [u8],
) -> Poll<io::Result<usize>> {
let this = self.get_mut();
// Serve remaining bytes from the current line.
if this.pos < this.buf.len() {
let avail = this.buf.len() - this.pos;
let n = avail.min(buf.len());
buf[..n].copy_from_slice(&this.buf[this.pos..this.pos + n]);
this.pos += n;
if this.pos >= this.buf.len() {
this.buf.clear();
this.pos = 0;
}
return Poll::Ready(Ok(n));
}
// No buffered data — try to receive the next complete line.
match this.rx.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(line))) => {
let n = line.len().min(buf.len());
buf[..n].copy_from_slice(&line[..n]);
if n < line.len() {
// Stash the remainder for subsequent poll_read calls.
this.buf = line;
this.pos = n;
}
Poll::Ready(Ok(n))
}
Poll::Ready(Some(Err(e))) => Poll::Ready(Err(e)),
Poll::Ready(None) => Poll::Ready(Ok(0)), // EOF
Poll::Pending => Poll::Pending,
}
}
}
/// Read a single `\n`-delimited line into `buf`, capped at [`MAX_LINE_SIZE`].
///
/// Unlike `read_line`, this checks the accumulated size after each internal
/// buffer fill, so memory usage stays bounded even if the peer never sends
/// a newline.
async fn read_line_capped(
reader: &mut (impl AsyncBufRead + Unpin),
buf: &mut Vec<u8>,
) -> io::Result<usize> {
buf.clear();
loop {
let (consumed, done) = {
let available = reader.fill_buf().await?;
if available.is_empty() {
return Ok(buf.len()); // EOF
}
match available.iter().position(|&b| b == b'\n') {
Some(pos) => {
buf.extend_from_slice(&available[..=pos]);
(pos + 1, true)
}
None => {
buf.extend_from_slice(available);
(available.len(), false)
}
}
};
reader.consume_unpin(consumed);
if buf.len() > MAX_LINE_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!(
"ACP message exceeds {} byte limit ({} bytes read)",
MAX_LINE_SIZE,
buf.len()
),
));
}
if done {
return Ok(buf.len());
}
}
}
#[cfg(test)]
mod tests {
use futures::{AsyncReadExt as _, io::Cursor};
use super::*;
/// Helper: run a test inside a tokio LocalSet so spawn_local works.
fn run<F: Future<Output = ()>>(f: F) {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
tokio::task::LocalSet::new().run_until(f).await;
});
}
#[test]
fn single_line() {
run(async {
let source = Cursor::new(b"hello world\n");
let mut reader = LineBufferedRead::spawn_local(source);
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.unwrap();
assert_eq!(buf, b"hello world\n");
});
}
#[test]
fn multiple_lines() {
run(async {
let source = Cursor::new(b"line1\nline2\nline3\n");
let mut reader = LineBufferedRead::spawn_local(source);
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.unwrap();
assert_eq!(buf, b"line1\nline2\nline3\n");
});
}
#[test]
fn eof_with_partial_line() {
run(async {
let source = Cursor::new(b"complete\nno trailing newline");
let mut reader = LineBufferedRead::spawn_local(source);
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.unwrap();
assert_eq!(buf, b"complete\nno trailing newline");
});
}
#[test]
fn empty_input() {
run(async {
let source = Cursor::new(b"");
let mut reader = LineBufferedRead::spawn_local(source);
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.unwrap();
assert!(buf.is_empty());
});
}
#[test]
fn large_line_within_limit() {
run(async {
// A line larger than BufReader's 8KB buffer but well under 64 MiB.
let mut data = vec![b'x'; 100_000];
data.push(b'\n');
let source = Cursor::new(data.clone());
let mut reader = LineBufferedRead::spawn_local(source);
let mut buf = Vec::new();
reader.read_to_end(&mut buf).await.unwrap();
assert_eq!(buf, data);
});
}
#[test]
fn read_line_capped_rejects_oversized() {
// Test the capped reader directly with a small override isn't
// practical (MAX_LINE_SIZE is const), so test via the real limit.
// Just verify the function works for normal input.
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap()
.block_on(async {
let data = b"normal line\n";
let mut reader = BufReader::new(Cursor::new(&data[..]));
let mut buf = Vec::new();
let n = read_line_capped(&mut reader, &mut buf).await.unwrap();
assert_eq!(n, 12);
assert_eq!(buf, b"normal line\n");
// EOF returns 0
buf.clear();
let n = read_line_capped(&mut reader, &mut buf).await.unwrap();
assert_eq!(n, 0);
});
}
#[test]
fn small_read_buffer() {
run(async {
// Verify poll_read correctly serves a line across multiple small reads.
let source = Cursor::new(b"abcdef\n");
let mut reader = LineBufferedRead::spawn_local(source);
let mut small_buf = [0u8; 3];
// First read: "abc"
let n = reader.read(&mut small_buf).await.unwrap();
assert_eq!(&small_buf[..n], b"abc");
// Second read: "def"
let n = reader.read(&mut small_buf).await.unwrap();
assert_eq!(&small_buf[..n], b"def");
// Third read: "\n"
let n = reader.read(&mut small_buf).await.unwrap();
assert_eq!(&small_buf[..n], b"\n");
// EOF
let n = reader.read(&mut small_buf).await.unwrap();
assert_eq!(n, 0);
});
}
}

View file

@ -0,0 +1,634 @@
use std::{borrow::Borrow, fmt, ops::Deref};
use agent_client_protocol as acp;
use derive_more::From;
use serde::{Deserialize, Serialize, ser::SerializeStruct};
use tokio::sync::oneshot;
use crate::common::AcpResult;
pub use self::{
agent::{AcpAgentMessage, AcpAgentMessageBox, AcpAgentMessageGeneric},
client::{AcpClientMessage, AcpClientMessageBox, AcpClientMessageGeneric},
};
/// Marker trait representing one side of the ACP connection.
pub trait AcpSide {
/// What does this side receive.
type InMessage: AcpMethod + fmt::Debug;
/// What does this side send.
type OutMessage: AcpMethod + fmt::Debug;
/// Marker type for the other side.
type OtherSide: AcpSide;
/// Display name for this side.
const NAME: &'static str;
}
/// Marker type representing the agent's view of the ACP connection (as one side of that connection).
impl AcpSide for acp::AgentSide {
type InMessage = AcpAgentMessage; // inbound messages = messages meant *for* the agent
type OutMessage = AcpClientMessage; // outbound messages = messages meant *for* the client
type OtherSide = acp::ClientSide;
const NAME: &'static str = "agent";
}
/// Marker type representing the agent's view of the ACP connection (as one side of that connection).
impl AcpSide for acp::ClientSide {
type InMessage = AcpClientMessage; // inbound messages = messages meant *for* the client
type OutMessage = AcpAgentMessage; // outbound messages = messages meant *for* the agent
type OtherSide = acp::AgentSide;
const NAME: &'static str = "client";
}
/// Extends each request/response type pair with the side marker type and schema method name.
pub trait AcpMethod {
fn method_name(&self) -> &'static str;
}
/// Connect together ACP request and response types for each rpc method.
pub trait AcpRequest: Clone + fmt::Debug + Serialize + AcpMethod {
type Response: Clone + fmt::Debug + Serialize;
}
/// Contains an ACP request and a oneshot channel where the response of a matching type can be sent.
pub struct AcpArgsGeneric<T: AcpRequest, S: StorageMarker> {
pub request: S::Type<T>,
pub response_tx: oneshot::Sender<AcpResult<T::Response>>,
}
impl<T: AcpRequest, S: StorageMarker> Deref for AcpArgsGeneric<T, S> {
type Target = T;
fn deref(&self) -> &Self::Target {
self.request.borrow()
}
}
impl<T: AcpRequest, S: StorageMarker> fmt::Debug for AcpArgsGeneric<T, S> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:?}", self.request.borrow())
}
}
impl<T: AcpRequest, S: StorageMarker> AcpMethod for AcpArgsGeneric<T, S> {
fn method_name(&self) -> &'static str {
self.request.borrow().method_name()
}
}
#[allow(type_alias_bounds)]
pub type AcpArgs<T: AcpRequest> = AcpArgsGeneric<T, Unboxed>;
#[allow(type_alias_bounds)]
pub type AcpArgsBox<T: AcpRequest> = AcpArgsGeneric<T, Boxed>;
impl<T: AcpRequest> AcpArgs<T> {
pub fn boxed(self) -> AcpArgsBox<T> {
AcpArgsBox {
request: Box::new(self.request),
response_tx: self.response_tx,
}
}
}
macro_rules! acp_define_request_response {
($request:ty, $response:ty, $method:expr $(,)?) => {
impl AcpRequest for $request {
type Response = $response;
}
impl AcpMethod for $request {
fn method_name(&self) -> &'static str {
$method
}
}
};
}
acp_define_request_response!(acp::ExtRequest, acp::ExtResponse, "ext_method");
acp_define_request_response!(acp::ExtNotification, (), "ext_notification");
pub trait StorageMarker: fmt::Debug + Clone + Copy {
type Type<T>: Borrow<T> + From<T>;
}
#[derive(Debug, Clone, Copy)]
pub struct Unboxed;
#[derive(Debug, Clone, Copy)]
pub struct Boxed;
impl StorageMarker for Unboxed {
type Type<T> = T;
}
impl StorageMarker for Boxed {
type Type<T> = Box<T>;
}
mod client {
use futures::{FutureExt as _, future::LocalBoxFuture};
use super::*;
acp_define_request_response!(
acp::RequestPermissionRequest,
acp::RequestPermissionResponse,
acp::CLIENT_METHOD_NAMES.session_request_permission,
);
acp_define_request_response!(
acp::ReadTextFileRequest,
acp::ReadTextFileResponse,
acp::CLIENT_METHOD_NAMES.fs_read_text_file,
);
acp_define_request_response!(
acp::WriteTextFileRequest,
acp::WriteTextFileResponse,
acp::CLIENT_METHOD_NAMES.fs_write_text_file,
);
acp_define_request_response!(
acp::SessionNotification,
(),
acp::CLIENT_METHOD_NAMES.session_update,
);
acp_define_request_response!(
acp::CreateTerminalRequest,
acp::CreateTerminalResponse,
acp::CLIENT_METHOD_NAMES.terminal_create,
);
acp_define_request_response!(
acp::TerminalOutputRequest,
acp::TerminalOutputResponse,
acp::CLIENT_METHOD_NAMES.terminal_output,
);
acp_define_request_response!(
acp::ReleaseTerminalRequest,
acp::ReleaseTerminalResponse,
acp::CLIENT_METHOD_NAMES.terminal_release,
);
acp_define_request_response!(
acp::WaitForTerminalExitRequest,
acp::WaitForTerminalExitResponse,
acp::CLIENT_METHOD_NAMES.terminal_wait_for_exit,
);
acp_define_request_response!(
acp::KillTerminalRequest,
acp::KillTerminalResponse,
acp::CLIENT_METHOD_NAMES.terminal_kill,
);
/// ACP messages meant *for* the client.
#[derive(Debug, From)]
pub enum AcpClientMessageGeneric<S: StorageMarker> {
RequestPermission(AcpArgsGeneric<acp::RequestPermissionRequest, S>),
ReadTextFile(AcpArgsGeneric<acp::ReadTextFileRequest, S>),
WriteTextFile(AcpArgsGeneric<acp::WriteTextFileRequest, S>),
SessionNotification(AcpArgsGeneric<acp::SessionNotification, S>),
CreateTerminal(AcpArgsGeneric<acp::CreateTerminalRequest, S>),
TerminalOutput(AcpArgsGeneric<acp::TerminalOutputRequest, S>),
ReleaseTerminal(AcpArgsGeneric<acp::ReleaseTerminalRequest, S>),
WaitForTerminalExit(AcpArgsGeneric<acp::WaitForTerminalExitRequest, S>),
KillTerminalCommand(AcpArgsGeneric<acp::KillTerminalRequest, S>),
ExtMethod(AcpArgsGeneric<acp::ExtRequest, S>),
ExtNotification(AcpArgsGeneric<acp::ExtNotification, S>),
}
#[allow(type_alias_bounds)]
pub type AcpClientMessage = AcpClientMessageGeneric<Unboxed>;
#[allow(type_alias_bounds)]
pub type AcpClientMessageBox = AcpClientMessageGeneric<Boxed>;
impl<S: StorageMarker> AcpMethod for AcpClientMessageGeneric<S> {
fn method_name(&self) -> &'static str {
match self {
Self::RequestPermission(a) => a.method_name(),
Self::ReadTextFile(a) => a.method_name(),
Self::WriteTextFile(a) => a.method_name(),
Self::SessionNotification(a) => a.method_name(),
Self::CreateTerminal(a) => a.method_name(),
Self::TerminalOutput(a) => a.method_name(),
Self::ReleaseTerminal(a) => a.method_name(),
Self::WaitForTerminalExit(a) => a.method_name(),
Self::KillTerminalCommand(a) => a.method_name(),
Self::ExtMethod(a) => a.method_name(),
Self::ExtNotification(a) => a.method_name(),
}
}
}
impl AcpClientMessage {
pub fn boxed(self) -> AcpClientMessageBox {
match self {
Self::RequestPermission(args) => {
AcpClientMessageBox::RequestPermission(args.boxed())
}
Self::ReadTextFile(args) => AcpClientMessageBox::ReadTextFile(args.boxed()),
Self::WriteTextFile(args) => AcpClientMessageBox::WriteTextFile(args.boxed()),
Self::SessionNotification(args) => {
AcpClientMessageBox::SessionNotification(args.boxed())
}
Self::CreateTerminal(args) => AcpClientMessageBox::CreateTerminal(args.boxed()),
Self::TerminalOutput(args) => AcpClientMessageBox::TerminalOutput(args.boxed()),
Self::ReleaseTerminal(args) => AcpClientMessageBox::ReleaseTerminal(args.boxed()),
Self::WaitForTerminalExit(args) => {
AcpClientMessageBox::WaitForTerminalExit(args.boxed())
}
Self::KillTerminalCommand(args) => {
AcpClientMessageBox::KillTerminalCommand(args.boxed())
}
Self::ExtMethod(args) => AcpClientMessageBox::ExtMethod(args.boxed()),
Self::ExtNotification(args) => AcpClientMessageBox::ExtNotification(args.boxed()),
}
}
pub fn route_to_client(
self,
client: impl acp::Client + 'static, // note: acp::Client is auto-implemented for Rc/Arc
spawn: impl Fn(LocalBoxFuture<'static, ()>) + 'static,
) {
match self {
AcpClientMessage::RequestPermission(args) => spawn(
async move {
_ = args
.response_tx
.send(client.request_permission(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::ReadTextFile(args) => spawn(
async move {
_ = args
.response_tx
.send(client.read_text_file(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::WriteTextFile(args) => spawn(
async move {
_ = args
.response_tx
.send(client.write_text_file(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::SessionNotification(args) => spawn(
async move {
_ = args
.response_tx
.send(client.session_notification(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::CreateTerminal(args) => spawn(
async move {
_ = args
.response_tx
.send(client.create_terminal(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::TerminalOutput(args) => spawn(
async move {
_ = args
.response_tx
.send(client.terminal_output(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::ReleaseTerminal(args) => spawn(
async move {
_ = args
.response_tx
.send(client.release_terminal(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::WaitForTerminalExit(args) => spawn(
async move {
_ = args
.response_tx
.send(client.wait_for_terminal_exit(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::KillTerminalCommand(args) => spawn(
async move {
_ = args
.response_tx
.send(client.kill_terminal(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::ExtMethod(args) => spawn(
async move {
_ = args
.response_tx
.send(client.ext_method(args.request).await)
.ok();
}
.boxed_local(),
),
AcpClientMessage::ExtNotification(args) => spawn(
async move {
_ = args
.response_tx
.send(client.ext_notification(args.request).await)
.ok();
}
.boxed_local(),
),
}
}
}
}
mod agent {
use futures::{FutureExt as _, future::LocalBoxFuture};
use super::*;
acp_define_request_response!(
acp::InitializeRequest,
acp::InitializeResponse,
acp::AGENT_METHOD_NAMES.initialize,
);
acp_define_request_response!(
acp::AuthenticateRequest,
acp::AuthenticateResponse,
acp::AGENT_METHOD_NAMES.authenticate,
);
acp_define_request_response!(
acp::NewSessionRequest,
acp::NewSessionResponse,
acp::AGENT_METHOD_NAMES.session_new,
);
acp_define_request_response!(
acp::LoadSessionRequest,
acp::LoadSessionResponse,
acp::AGENT_METHOD_NAMES.session_load,
);
acp_define_request_response!(
acp::SetSessionModeRequest,
acp::SetSessionModeResponse,
acp::AGENT_METHOD_NAMES.session_set_mode,
);
acp_define_request_response!(
acp::PromptRequest,
acp::PromptResponse,
acp::AGENT_METHOD_NAMES.session_prompt,
);
acp_define_request_response!(
acp::CancelNotification,
(),
acp::AGENT_METHOD_NAMES.session_cancel,
);
acp_define_request_response!(
acp::SetSessionModelRequest,
acp::SetSessionModelResponse,
acp::AGENT_METHOD_NAMES.session_set_model,
);
/// ACP messages meant *for* the agent.
#[derive(Debug, From)]
pub enum AcpAgentMessageGeneric<S: StorageMarker> {
Initialize(AcpArgsGeneric<acp::InitializeRequest, S>),
Authenticate(AcpArgsGeneric<acp::AuthenticateRequest, S>),
NewSession(AcpArgsGeneric<acp::NewSessionRequest, S>),
LoadSession(AcpArgsGeneric<acp::LoadSessionRequest, S>),
SetSessionMode(AcpArgsGeneric<acp::SetSessionModeRequest, S>),
Prompt(AcpArgsGeneric<acp::PromptRequest, S>),
Cancel(AcpArgsGeneric<acp::CancelNotification, S>),
ExtMethod(AcpArgsGeneric<acp::ExtRequest, S>),
ExtNotification(AcpArgsGeneric<acp::ExtNotification, S>),
SetSessionModel(AcpArgsGeneric<acp::SetSessionModelRequest, S>),
}
#[allow(type_alias_bounds)]
pub type AcpAgentMessage = AcpAgentMessageGeneric<Unboxed>;
#[allow(type_alias_bounds)]
pub type AcpAgentMessageBox = AcpAgentMessageGeneric<Boxed>;
impl<S: StorageMarker> AcpMethod for AcpAgentMessageGeneric<S> {
fn method_name(&self) -> &'static str {
match self {
Self::Initialize(a) => a.method_name(),
Self::Authenticate(a) => a.method_name(),
Self::NewSession(a) => a.method_name(),
Self::LoadSession(a) => a.method_name(),
Self::SetSessionMode(a) => a.method_name(),
Self::Prompt(a) => a.method_name(),
Self::Cancel(a) => a.method_name(),
Self::ExtMethod(a) => a.method_name(),
Self::ExtNotification(a) => a.method_name(),
Self::SetSessionModel(a) => a.method_name(),
}
}
}
impl<S: StorageMarker> Serialize for AcpAgentMessageGeneric<S> {
fn serialize<Ser>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error>
where
Ser: serde::Serializer,
{
let mut state = serializer.serialize_struct("AcpAgentMessage", 2)?;
state.serialize_field("method_name", self.method_name())?;
match self {
Self::Initialize(args) => {
state.serialize_field("request", args.request.borrow())?
}
Self::Authenticate(args) => {
state.serialize_field("request", args.request.borrow())?
}
Self::NewSession(args) => {
state.serialize_field("request", args.request.borrow())?
}
Self::LoadSession(args) => {
state.serialize_field("request", args.request.borrow())?
}
Self::SetSessionMode(args) => {
state.serialize_field("request", args.request.borrow())?
}
Self::Prompt(args) => state.serialize_field("request", args.request.borrow())?,
Self::Cancel(args) => state.serialize_field("request", args.request.borrow())?,
Self::ExtMethod(args) => state.serialize_field("request", args.request.borrow())?,
Self::ExtNotification(args) => {
state.serialize_field("request", args.request.borrow())?
}
Self::SetSessionModel(args) => {
state.serialize_field("request", args.request.borrow())?
}
}
state.end()
}
}
impl<'de> Deserialize<'de> for AcpAgentMessage {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawMessage {
method_name: String,
request: serde_json::Value,
}
let raw = RawMessage::deserialize(deserializer)?;
let method = raw.method_name.as_str();
macro_rules! parse {
($variant:ident) => {{
let (response_tx, _) = oneshot::channel();
Ok(Self::$variant(AcpArgs {
request: serde_json::from_value(raw.request)
.map_err(serde::de::Error::custom)?,
response_tx,
}))
}};
}
if method == acp::AGENT_METHOD_NAMES.initialize {
parse!(Initialize)
} else if method == acp::AGENT_METHOD_NAMES.authenticate {
parse!(Authenticate)
} else if method == acp::AGENT_METHOD_NAMES.session_new {
parse!(NewSession)
} else if method == acp::AGENT_METHOD_NAMES.session_load {
parse!(LoadSession)
} else if method == acp::AGENT_METHOD_NAMES.session_set_mode {
parse!(SetSessionMode)
} else if method == acp::AGENT_METHOD_NAMES.session_prompt {
parse!(Prompt)
} else if method == acp::AGENT_METHOD_NAMES.session_cancel {
parse!(Cancel)
} else if method == acp::AGENT_METHOD_NAMES.session_set_model {
parse!(SetSessionModel)
} else if method == "ext_method" {
parse!(ExtMethod)
} else if method == "ext_notification" {
parse!(ExtNotification)
} else {
Err(serde::de::Error::custom(format!(
"Unknown method name: {method}"
)))
}
}
}
impl AcpAgentMessage {
pub fn boxed(self) -> AcpAgentMessageBox {
match self {
Self::Initialize(args) => AcpAgentMessageBox::Initialize(args.boxed()),
Self::Authenticate(args) => AcpAgentMessageBox::Authenticate(args.boxed()),
Self::NewSession(args) => AcpAgentMessageBox::NewSession(args.boxed()),
Self::LoadSession(args) => AcpAgentMessageBox::LoadSession(args.boxed()),
Self::SetSessionMode(args) => AcpAgentMessageBox::SetSessionMode(args.boxed()),
Self::Prompt(args) => AcpAgentMessageBox::Prompt(args.boxed()),
Self::Cancel(args) => AcpAgentMessageBox::Cancel(args.boxed()),
Self::ExtMethod(args) => AcpAgentMessageBox::ExtMethod(args.boxed()),
Self::ExtNotification(args) => AcpAgentMessageBox::ExtNotification(args.boxed()),
Self::SetSessionModel(args) => AcpAgentMessageBox::SetSessionModel(args.boxed()),
}
}
pub fn route_to_agent(
self,
agent: impl acp::Agent + 'static, // note: acp::Agent is auto-implemented for Rc/Arc
spawn: impl Fn(LocalBoxFuture<'static, ()>) + 'static,
) {
match self {
AcpAgentMessage::Initialize(args) => spawn(
async move {
_ = args
.response_tx
.send(agent.initialize(args.request).await)
.ok();
}
.boxed_local(),
),
AcpAgentMessage::Authenticate(args) => spawn(
async move {
_ = args
.response_tx
.send(agent.authenticate(args.request).await)
.ok();
}
.boxed_local(),
),
AcpAgentMessage::NewSession(args) => spawn(
async move {
_ = args
.response_tx
.send(agent.new_session(args.request).await)
.ok();
}
.boxed_local(),
),
AcpAgentMessage::LoadSession(args) => spawn(
async move {
_ = args
.response_tx
.send(agent.load_session(args.request).await)
.ok();
}
.boxed_local(),
),
AcpAgentMessage::SetSessionMode(args) => spawn(
async move {
_ = args
.response_tx
.send(agent.set_session_mode(args.request).await)
.ok();
}
.boxed_local(),
),
AcpAgentMessage::Prompt(args) => spawn(
async move {
_ = args.response_tx.send(agent.prompt(args.request).await).ok();
}
.boxed_local(),
),
AcpAgentMessage::Cancel(args) => spawn(
async move {
_ = args.response_tx.send(agent.cancel(args.request).await).ok();
}
.boxed_local(),
),
AcpAgentMessage::ExtMethod(args) => spawn(
async move {
_ = args
.response_tx
.send(agent.ext_method(args.request).await)
.ok();
}
.boxed_local(),
),
AcpAgentMessage::ExtNotification(args) => spawn(
async move {
_ = args
.response_tx
.send(agent.ext_notification(args.request).await)
.ok();
}
.boxed_local(),
),
AcpAgentMessage::SetSessionModel(args) => spawn(
async move {
_ = args
.response_tx
.send(agent.set_session_model(args.request).await)
.ok();
}
.boxed_local(),
),
}
}
}
}

View file

@ -0,0 +1,181 @@
//! Foundation escaped-slash normalization for inbound ACP stdin lines — the
//! crate's second `agent-client-protocol` v0.6 wire workaround, alongside
//! [`LineBufferedRead`](crate::LineBufferedRead).
//!
//! [`spawn_stdin_line_reader`](crate::spawn_stdin_line_reader) feeds every
//! line through [`normalize_json_line`]; unhooking that one call site removes
//! the workaround once the upstream envelope parses `method` as an
//! owned/`Cow` string.
//!
//! Scope: only process-stdin ingress is normalized. Clients that connect
//! directly to the leader socket bypass this module — fine today, those are
//! first-party clients whose encoders never emit `\/`.
//!
//! Downstream dependency: the leader bridge's replay sniff
//! (`xai-grok-pager-bin/src/main.rs`, the `trimmed.contains("\"session/new\"")`
//! checks) matches escaped Foundation input only because this normalization
//! runs upstream of it.
/// Foundation (Xcode) escapes `/` as `\/` by default, and the pinned
/// `agent-client-protocol` 0.6 envelope parses `method` as a borrowed `&str`
/// ([`RawIncomingMessage`](agent_client_protocol::RawIncomingMessage)), so any
/// escape inside `method` fails the whole envelope parse and the line is
/// silently dropped. Re-serializing through `serde_json` — which never emits
/// `\/` — makes the method borrowable again.
///
/// Rewrites touch only lines the crate would otherwise drop: the two-byte `\/`
/// scan is a cheap prefilter (serde_json / `JSON.stringify` never emit it),
/// and a line that then parses as the real pinned envelope — e.g. a
/// clean-method prompt whose params contain `s/\//_/g` — passes through
/// byte-identical, so healthy clients are untouched by construction. Tradeoff:
/// a hypothetical `\u002F`-escaped method is not normalized (no known encoder
/// emits that, and `\u` can't be the prefilter — JS legitimately emits
/// `\u2028` and surrogate pairs in text).
///
/// Any line that fails both parses passes through byte-identical —
/// deliberately: the acp crate keeps ownership of garbage handling.
pub(crate) fn normalize_json_line(line: Vec<u8>) -> Vec<u8> {
if !line.windows(2).any(|w| w == br"\/") {
return line;
}
// Same type + bytes the acp crate will parse (trailing terminator is JSON
// whitespace): if it accepts the line, forward it byte-identical.
if serde_json::from_slice::<agent_client_protocol::RawIncomingMessage>(&line).is_ok() {
return line;
}
let body_len = line
.iter()
.rposition(|&b| b != b'\n' && b != b'\r')
.map_or(0, |pos| pos + 1);
let Ok(value) = serde_json::from_slice::<serde_json::Value>(&line[..body_len]) else {
// Exactly the line class the acp 0.6 envelope will then drop silently.
tracing::debug!(
len = line.len(),
"unparseable escaped-slash stdin line passed through; acp may drop it"
);
return line;
};
let Ok(mut normalized) = serde_json::to_vec(&value) else {
return line;
};
normalized.extend_from_slice(&line[body_len..]);
tracing::debug!(
len = line.len(),
normalized_len = normalized.len(),
"normalized escaped-slash line for the acp 0.6 envelope"
);
normalized
}
#[cfg(test)]
mod tests {
use agent_client_protocol::RawIncomingMessage;
use super::*;
#[test]
fn escaped_slash_method_accepted_by_upstream_cow_envelope() {
// acp 0.10.4+ parses `method` as Cow<str>, so Foundation-style
// `session\/prompt` is accepted without our re-serialization rewrite.
let raw =
br#"{"jsonrpc":"2.0","id":"5DE7EA60-0B0C-4A43-9650-2B72CDF6A44B","method":"session\/prompt","params":{}}"#;
let mut line = raw.to_vec();
line.push(b'\n');
assert!(serde_json::from_slice::<RawIncomingMessage>(raw).is_ok());
let normalized = normalize_json_line(line.clone());
// Early-return path: envelope-acceptable lines pass through byte-identical.
assert_eq!(normalized, line);
}
#[test]
fn invalid_json_with_escaped_slash_passes_through_byte_identical() {
let line = b"not json \\/ at all\n".to_vec();
assert_eq!(normalize_json_line(line.clone()), line);
}
#[test]
fn line_without_backslash_passes_through_untouched() {
let expected = br#"{"jsonrpc":"2.0","id":1,"method":"session/new","params":{}}"#.to_vec();
let line = expected.clone();
let ptr = line.as_ptr();
let normalized = normalize_json_line(line);
assert_eq!(normalized, expected);
// Same allocation: the fast path never parsed or re-serialized.
assert_eq!(normalized.as_ptr(), ptr);
}
#[test]
fn string_escapes_without_escaped_slash_pass_through_untouched() {
let raw =
br#"{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"text":"a\nb \"q\" c\\d"}}"#;
let expected = raw.to_vec();
let line = expected.clone();
let ptr = line.as_ptr();
let normalized = normalize_json_line(line);
assert_eq!(normalized, expected);
// Same allocation: `\n`/`\"`/`\\` escapes alone never trip the rewrite.
assert_eq!(normalized.as_ptr(), ptr);
}
#[test]
fn escaped_slash_in_params_with_clean_method_passes_through_byte_identical() {
// serde_json wire form of a prompt containing `s/\//_/g`: the `\\/`
// bytes trip the `\/` prefilter, but the envelope parses the line.
let raw =
br#"{"jsonrpc":"2.0","id":1,"method":"session/prompt","params":{"text":"s/\\//_/g"}}"#;
assert!(raw.windows(2).any(|w| w == br"\/"));
assert!(serde_json::from_slice::<RawIncomingMessage>(raw).is_ok());
let expected = raw.to_vec();
let line = expected.clone();
let ptr = line.as_ptr();
let normalized = normalize_json_line(line);
assert_eq!(normalized, expected);
// Same allocation: envelope-acceptable lines are never re-serialized.
assert_eq!(normalized.as_ptr(), ptr);
}
#[test]
fn params_string_escapes_keep_their_semantics() {
let raw =
br#"{"jsonrpc":"2.0","id":1,"method":"session\/prompt","params":{"text":"a\/b\nc \"q\" d\\e"}}"#;
let mut line = raw.to_vec();
line.push(b'\n');
let normalized = normalize_json_line(line);
let value: serde_json::Value = serde_json::from_slice(&normalized).unwrap();
assert_eq!(value["method"], "session/prompt");
assert_eq!(value["params"]["text"], "a/b\nc \"q\" d\\e");
}
#[test]
fn crlf_terminator_is_preserved() {
let mut line = br#"{"id":2,"method":"session\/new"}"#.to_vec();
line.extend_from_slice(b"\r\n");
let normalized = normalize_json_line(line);
assert!(normalized.ends_with(b"\r\n"));
let value: serde_json::Value =
serde_json::from_slice(&normalized[..normalized.len() - 2]).unwrap();
assert_eq!(value["method"], "session/new");
}
#[test]
fn final_line_without_newline_gains_no_newline() {
let line = br#"{"id":3,"method":"session\/new"}"#.to_vec();
let normalized = normalize_json_line(line);
assert_ne!(normalized.last(), Some(&b'\n'));
let value: serde_json::Value = serde_json::from_slice(&normalized).unwrap();
assert_eq!(value["method"], "session/new");
}
}

View file

@ -0,0 +1,216 @@
//! Dedicated-thread reader for the ACP stdio transport's standard input.
//!
//! Every ACP client (VS Code extension, grok-desktop, the leader bridge) drives
//! the agent over a **persistent, bidirectional** newline-delimited JSON-RPC
//! stream on stdio: it writes requests on the child's stdin and reads responses
//! on stdout, keeping **stdin open for the whole session**.
//!
//! # Why not `tokio::io::stdin()`
//!
//! `tokio::io::stdin()` is not truly asynchronous. Tokio services it with a
//! blocking `std::io` read on an internal pool thread, and that read **cannot be
//! cancelled**. For interactive / persistent uses the
//! [`tokio::io::Stdin`](https://docs.rs/tokio/latest/tokio/io/struct.Stdin.html)
//! docs recommend "spawn a thread dedicated to user input and use blocking IO
//! directly in that thread". [`spawn_stdin_line_reader`] does exactly that.
//!
//! # Why the reader takes *exclusive* ownership of stdin (Windows)
//!
//! `std::io::Stdin` is a process-global handle guarded by a re-entrant mutex
//! (the `StdinLock`). A blocking read **holds that lock for the entire duration
//! of the read** — and for the persistent stdio transport the reader is almost
//! always parked in a read, waiting for the client's next line. If *any other*
//! code in the process then calls `std::io::stdin()` (e.g. a stray interactive
//! prompt reached only on a particular platform), it blocks on the lock until
//! the reader's in-flight read returns — which only happens at **EOF**, i.e.
//! when the client closes stdin. For a persistent ACP client that never closes
//! stdin mid-session this is a hard hang: the agent freezes part-way through a
//! request (observed on **Windows** during `session/new`) and only unblocks when
//! the transport is torn down. macOS/Linux don't reach the offending stray read,
//! so they were unaffected — but the hazard is real on any platform.
//!
//! To make the transport robust, on Windows the reader thread takes a **private
//! duplicate** of the real stdin handle and then points the process's standard
//! input at **`NUL`**. The reader keeps reading the client's bytes through its
//! private handle, while every *other* `std::io::stdin()` read in the process
//! observes immediate EOF instead of deadlocking on the lock. This mirrors what
//! already makes leader mode safe (the agent subprocess is spawned with
//! `stdin = NUL`, so its stray reads EOF instantly). Unix keeps reading
//! `std::io::stdin()` directly — it has no second stdin reader on these paths
//! and the extra FFI/`dup` would add risk for no benefit.
//!
//! # Escaped-slash normalization (acp 0.6 wire workaround)
//!
//! Every line is forwarded through `normalize_json_line` — see the
//! crate-private `normalize` module for the contract and its scope.
use std::io::BufRead;
use tokio::sync::mpsc;
use crate::normalize::normalize_json_line;
/// Channel depth for buffered stdin lines. Small: the reader thread blocks on a
/// full channel, applying natural backpressure to a flooding peer rather than
/// growing memory without bound.
const STDIN_LINE_CHANNEL_DEPTH: usize = 64;
/// Spawn a dedicated OS thread that reads newline-delimited lines from the
/// process's standard input with **synchronous, blocking** `std::io` and yields
/// each line (its trailing `\n` included, like `read_line`/`read_until`) on the
/// returned channel. A final line without a trailing newline is still delivered
/// before the channel closes.
///
/// Yielded lines are **not guaranteed byte-verbatim**: a line the pinned acp
/// 0.6 envelope would otherwise drop (a `\/`-escaped `method`, as Foundation
/// encoders emit) is re-serialized compactly (key order, whitespace, and
/// number formatting normalized) before forwarding — see the crate-private
/// `normalize` module. Every line the envelope already accepts, and anything
/// that fails to parse, passes through byte-identical (trailing terminator
/// always preserved).
///
/// The channel closes (so [`recv`](mpsc::Receiver::recv) returns `None`) when
/// stdin reaches EOF, the read fails, or the [`Receiver`](mpsc::Receiver) is
/// dropped. The reader is meant to be the **sole** stdin consumer in the
/// agent-stdio / leader-bridge paths; on Windows it enforces that by redirecting
/// the process's standard input to `NUL` so stray readers can't deadlock on it
/// (see the [module docs](self)).
pub fn spawn_stdin_line_reader() -> mpsc::Receiver<Vec<u8>> {
let (tx, rx) = mpsc::channel::<Vec<u8>>(STDIN_LINE_CHANNEL_DEPTH);
// On Windows, synchronously take a private duplicate of the real stdin and
// redirect the process's standard input to `NUL` *before* the reader thread
// parks in a blocking read holding the global `StdinLock`. After this, any
// other `std::io::stdin()` read in the process EOFs immediately instead of
// deadlocking. `None` means we couldn't isolate (we fall back to reading
// `std::io::stdin()` directly — no worse than before).
#[cfg(windows)]
let private_stdin: Option<std::fs::File> = isolate_process_stdin();
std::thread::Builder::new()
.name("acp-stdin".to_string())
.spawn(move || {
#[cfg(windows)]
if let Some(file) = private_stdin {
forward_lines(std::io::BufReader::new(file), &tx);
return;
}
let stdin = std::io::stdin();
forward_lines(stdin.lock(), &tx);
})
.expect("failed to spawn acp-stdin reader thread");
rx
}
/// Read `\n`-delimited lines from `reader` and forward each on `tx` — via
/// [`normalize_json_line`], so bytes are verbatim except for the lines that
/// workaround rewrites (terminator always preserved) — until EOF, a read
/// error, or the receiver is dropped.
fn forward_lines<R: BufRead>(mut reader: R, tx: &mpsc::Sender<Vec<u8>>) {
let mut line = Vec::new();
loop {
line.clear();
match reader.read_until(b'\n', &mut line) {
// EOF or a fatal read error: return, dropping `tx` closes the channel.
Ok(0) | Err(_) => break,
Ok(_) => {}
}
let normalized = normalize_json_line(std::mem::take(&mut line));
// `blocking_send` parks this thread (not a runtime worker) when the
// channel is full, and errors only once the receiver is dropped — at
// which point there is nothing left to feed.
if tx.blocking_send(normalized).is_err() {
break;
}
}
}
/// Duplicate the real stdin handle for private use and repoint the process's
/// `STD_INPUT_HANDLE` at `NUL`, returning the duplicate as an owned [`File`].
///
/// Returns `None` (caller falls back to `std::io::stdin()`) when there is no
/// stdin handle or duplication fails. Win32 declarations are inlined to avoid a
/// `windows`/`windows-sys` dependency, matching the pager's console setup.
///
/// [`File`]: std::fs::File
#[cfg(windows)]
fn isolate_process_stdin() -> Option<std::fs::File> {
use std::os::windows::io::FromRawHandle as _;
// Win32 constants (inlined to avoid a dependency).
const STD_INPUT_HANDLE: u32 = 0xFFFF_FFF6; // (DWORD)-10
const DUPLICATE_SAME_ACCESS: u32 = 0x0000_0002;
const GENERIC_READ: u32 = 0x8000_0000;
const FILE_SHARE_READ: u32 = 0x0000_0001;
const FILE_SHARE_WRITE: u32 = 0x0000_0002;
const OPEN_EXISTING: u32 = 0x0000_0003;
const INVALID_HANDLE: *mut core::ffi::c_void = -1_isize as *mut core::ffi::c_void;
unsafe extern "system" {
fn GetStdHandle(nStdHandle: u32) -> *mut core::ffi::c_void;
fn SetStdHandle(nStdHandle: u32, hHandle: *mut core::ffi::c_void) -> i32;
fn GetCurrentProcess() -> *mut core::ffi::c_void;
fn DuplicateHandle(
hSourceProcessHandle: *mut core::ffi::c_void,
hSourceHandle: *mut core::ffi::c_void,
hTargetProcessHandle: *mut core::ffi::c_void,
lpTargetHandle: *mut *mut core::ffi::c_void,
dwDesiredAccess: u32,
bInheritHandle: i32,
dwOptions: u32,
) -> i32;
fn CreateFileW(
lpFileName: *const u16,
dwDesiredAccess: u32,
dwShareMode: u32,
lpSecurityAttributes: *mut core::ffi::c_void,
dwCreationDisposition: u32,
dwFlagsAndAttributes: u32,
hTemplateFile: *mut core::ffi::c_void,
) -> *mut core::ffi::c_void;
}
// SAFETY: standard Win32 console/file calls; every return value is checked
// before use and the duplicated handle is wrapped in an owning `File`.
unsafe {
let current = GetStdHandle(STD_INPUT_HANDLE);
if current.is_null() || current == INVALID_HANDLE {
return None;
}
let process = GetCurrentProcess();
let mut duplicate: *mut core::ffi::c_void = std::ptr::null_mut();
if DuplicateHandle(
process,
current,
process,
&mut duplicate,
0,
0, // not inheritable
DUPLICATE_SAME_ACCESS,
) == 0
{
return None;
}
// Repoint the process's std input at NUL so stray `std::io::stdin()`
// reads observe EOF instead of blocking on the held `StdinLock`. If NUL
// can't be opened we still return the duplicate so the reader works;
// we just forgo the stray-read isolation.
let nul: Vec<u16> = "NUL\0".encode_utf16().collect();
let nul_handle = CreateFileW(
nul.as_ptr(),
GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null_mut(),
OPEN_EXISTING,
0,
std::ptr::null_mut(),
);
if nul_handle != INVALID_HANDLE && !nul_handle.is_null() {
SetStdHandle(STD_INPUT_HANDLE, nul_handle);
}
Some(std::fs::File::from_raw_handle(duplicate as _))
}
}