Add opt-in concurrent exec-server request dispatch (#36987)

## Why

Sequential dispatch lets a long-running request block unrelated health checks
and cleanup on the same connection.

## What changed

- Add `--concurrent-requests <COUNT>` for local and remote exec-server
  connections, while retaining sequential dispatch when the option is omitted
  or set to `1`.
- Preserve handshake ordering before enabling concurrent dispatch.
- Reserve separate capacity for status, signal, terminate, and close requests so
  they remain responsive when ordinary request capacity is saturated.
- Drain queued client responses during disconnect and cancel outstanding
  request tasks during connection shutdown.

## Testing

- Cover CLI parsing and concurrency-limit validation.
- Verify default sequential behavior, pipelined handshake ordering, concurrent
  request progress, control-request responsiveness, and disconnect handling.

GitOrigin-RevId: 48e4b092e318204ed635543f01f9ee0e7df095fc
This commit is contained in:
Adam Perry @ OpenAI
2026-08-04 22:28:15 +00:00
committed by copyberry
parent 1d952f027e
commit eeae88d8a6
18 changed files with 836 additions and 99 deletions
+10
View File
@@ -573,6 +573,14 @@ struct ExecServerCommand {
#[arg(long = "strict-config", default_value_t = false)]
strict_config: bool,
/// Maximum number of requests to process concurrently on each connection.
#[arg(
long = "concurrent-requests",
value_name = "COUNT",
default_value = "1"
)]
request_dispatch_mode: codex_exec_server::RequestDispatchMode,
/// Transport endpoint URL. Supported values: `ws://IP:PORT` (default), `stdio`, `stdio://`.
#[arg(long = "listen", value_name = "URL", conflicts_with = "remote")]
listen: Option<String>,
@@ -1745,6 +1753,7 @@ async fn run_exec_server_command(
if let Some(name) = cmd.name {
remote_config.name = name;
}
remote_config.request_dispatch_mode = cmd.request_dispatch_mode;
let remote_config = remote_config.with_telemetry(telemetry);
let parent_lifetime = if cmd.exit_on_stdin_close {
exec_server_telemetry::ParentLifetime::StdinPipe
@@ -1794,6 +1803,7 @@ async fn run_exec_server_command(
runtime_paths,
telemetry,
http_client_factory,
cmd.request_dispatch_mode,
)
.await
},
+20
View File
@@ -90,6 +90,24 @@ fn local_exec_server_ignores_invalid_config_without_strict_config() -> Result<()
Ok(())
}
/// The standalone exec-server accepts an explicit per-connection concurrency limit.
#[test]
fn local_exec_server_accepts_concurrent_requests_flag() -> Result<()> {
let codex_home = TempDir::new()?;
let mut cmd = codex_command(codex_home.path())?;
cmd.args([
"exec-server",
"--listen",
"stdio",
"--concurrent-requests",
"2",
])
.assert()
.success();
Ok(())
}
#[test]
fn local_exec_server_allows_disabled_parent_lifetime_environment_variable() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -173,6 +191,8 @@ metrics_exporter = {{ otlp-http = {{ endpoint = "{collector_url}/v1/metrics", pr
&registry.uri(),
"--environment-id",
ENVIRONMENT_ID,
"--concurrent-requests",
"2",
])
.stdin(Stdio::piped())
.stderr(Stdio::piped())
+3
View File
@@ -115,6 +115,9 @@ Each connection follows this sequence:
3. Send `initialized`.
4. Call process or filesystem RPCs.
Requests run sequentially by default. Pass `--concurrent-requests <COUNT>` to
enable concurrent processing.
If the server receives any notification other than `initialized`, it replies
with an error using request id `-1`.
+2
View File
@@ -189,8 +189,10 @@ pub use remote::run_remote_environment_until_shutdown;
pub use resolved_capability::ResolvedSelectedCapabilityRoot;
pub use resolved_capability::SelectedCapabilityRootsStatus;
pub use runtime_paths::ExecServerRuntimePaths;
pub use server::ConcurrentRequestLimit;
pub use server::DEFAULT_LISTEN_URL;
pub use server::ExecServerListenUrlParseError;
pub use server::RequestDispatchMode;
pub use server::run_main;
pub use server::run_main_with_telemetry;
pub use telemetry::ExecServerTelemetry;
+5
View File
@@ -43,6 +43,7 @@ use crate::noise_relay::noise_relay_websocket_config;
use crate::relay::HarnessKeyValidator;
use crate::relay::run_multiplexed_environment;
use crate::server::ConnectionProcessor;
use crate::server::RequestDispatchMode;
use crate::trace_context::current_trace_context_headers;
const ERROR_BODY_PREVIEW_BYTES: usize = 4096;
@@ -464,6 +465,7 @@ pub struct RemoteEnvironmentConfig {
pub base_url: String,
pub environment_id: String,
pub name: String,
pub request_dispatch_mode: RequestDispatchMode,
auth_provider: SharedAuthProvider,
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
@@ -475,6 +477,7 @@ impl std::fmt::Debug for RemoteEnvironmentConfig {
.field("base_url", &self.base_url)
.field("environment_id", &self.environment_id)
.field("name", &self.name)
.field("request_dispatch_mode", &self.request_dispatch_mode)
.field("auth_provider", &"<redacted>")
.finish()
}
@@ -492,6 +495,7 @@ impl RemoteEnvironmentConfig {
base_url,
environment_id,
name: "codex-exec-server".to_string(),
request_dispatch_mode: RequestDispatchMode::Inline,
auth_provider,
telemetry: ExecServerTelemetry::default(),
http_client_factory,
@@ -539,6 +543,7 @@ where
runtime_paths,
config.telemetry.clone(),
config.http_client_factory.clone(),
config.request_dispatch_mode,
);
let result = {
+13 -1
View File
@@ -9,6 +9,8 @@ mod transport;
pub(crate) use handler::ExecServerHandler;
pub(crate) use processor::ConnectionProcessor;
pub use request_dispatcher::ConcurrentRequestLimit;
pub use request_dispatcher::RequestDispatchMode;
pub use transport::DEFAULT_LISTEN_URL;
pub use transport::ExecServerListenUrlParseError;
@@ -26,6 +28,7 @@ pub async fn run_main(
runtime_paths,
ExecServerTelemetry::default(),
http_client_factory,
RequestDispatchMode::Inline,
)
.await
}
@@ -40,8 +43,16 @@ pub async fn run_main_with_telemetry(
runtime_paths: ExecServerRuntimePaths,
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
request_dispatch_mode: RequestDispatchMode,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
transport::run_transport(listen_url, runtime_paths, telemetry, http_client_factory).await
transport::run_transport(
listen_url,
runtime_paths,
telemetry,
http_client_factory,
request_dispatch_mode,
)
.await
}
#[cfg(test)]
@@ -78,6 +89,7 @@ mod tests {
.expect("runtime paths"),
ExecServerTelemetry::default(),
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
super::RequestDispatchMode::Inline,
)
.await
.expect_err("invalid listen URL should fail");
+128 -7
View File
@@ -9,10 +9,13 @@ use crate::ExecServerRuntimePaths;
use crate::connection::CHANNEL_CAPACITY;
use crate::connection::JsonRpcConnection;
use crate::connection::JsonRpcConnectionEvent;
use crate::rpc::RpcCallError;
use crate::rpc::RpcNotificationSender;
use crate::rpc::RpcServerOutboundMessage;
use crate::rpc::encode_server_message;
use crate::rpc_server_requests::RpcServerRequestSender;
use crate::server::ExecServerHandler;
use crate::server::RequestDispatchMode;
use crate::server::registry::build_router;
use crate::server::request_dispatcher::RequestDispatcher;
use crate::server::request_dispatcher::RequestTaskResult;
@@ -27,6 +30,7 @@ pub(crate) struct ConnectionProcessor {
runtime_paths: ExecServerRuntimePaths,
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
request_dispatch_mode: RequestDispatchMode,
}
impl ConnectionProcessor {
@@ -38,6 +42,7 @@ impl ConnectionProcessor {
codex_http_client::HttpClientFactory::new(
codex_http_client::OutboundProxyPolicy::ReqwestDefault,
),
RequestDispatchMode::Inline,
)
}
@@ -45,12 +50,14 @@ impl ConnectionProcessor {
runtime_paths: ExecServerRuntimePaths,
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
request_dispatch_mode: RequestDispatchMode,
) -> Self {
Self {
session_registry: SessionRegistry::new(telemetry.clone()),
runtime_paths,
telemetry,
http_client_factory,
request_dispatch_mode,
}
}
@@ -66,6 +73,7 @@ impl ConnectionProcessor {
self.telemetry.clone(),
self.http_client_factory.clone(),
transport,
self.request_dispatch_mode,
)
.await;
}
@@ -82,13 +90,13 @@ async fn run_connection(
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
transport: ConnectionTransport,
request_dispatch_mode: RequestDispatchMode,
) {
let _connection_metrics = telemetry.connection_started(transport);
let router = Arc::new(build_router());
let JsonRpcConnection {
outgoing_tx: json_outgoing_tx,
mut incoming_rx,
disconnected_rx,
mut disconnected_rx,
task_handles: connection_tasks,
transport: _transport,
} = connection;
@@ -119,20 +127,41 @@ async fn run_connection(
});
let mut dispatcher = RequestDispatcher::new(
router,
Arc::new(build_router()),
Arc::clone(&handler),
outgoing_tx.clone(),
disconnected_rx,
disconnected_rx.clone(),
requests.clone(),
telemetry,
request_dispatch_mode,
);
// Process inbound events sequentially to preserve initialize/initialized ordering.
while let Some(event) = incoming_rx.recv().await {
loop {
let has_request_tasks = dispatcher.has_tasks();
let event = tokio::select! {
result = dispatcher.join_next(), if has_request_tasks => {
if result == RequestTaskResult::ConnectionClosed {
break;
}
continue;
}
_ = disconnected_rx.changed() => {
debug!("exec-server transport disconnected");
break;
}
event = incoming_rx.recv() => {
let Some(event) = event else {
break;
};
event
}
};
if !handler.is_session_attached() {
debug!("exec-server connection evicted after session resume");
break;
}
let result = match event {
JsonRpcConnectionEvent::MalformedMessage { reason } => {
dispatcher.handle_malformed_message(reason).await
@@ -157,8 +186,11 @@ async fn run_connection(
}
}
if *disconnected_rx.borrow() {
complete_queued_client_responses(&requests, &mut incoming_rx);
}
requests.close();
drop(dispatcher);
dispatcher.shutdown().await;
handler.shutdown().await;
drop(handler);
drop(requests);
@@ -170,6 +202,30 @@ async fn run_connection(
let _ = outbound_task.await;
}
fn complete_queued_client_responses(
requests: &RpcServerRequestSender,
incoming_rx: &mut mpsc::Receiver<JsonRpcConnectionEvent>,
) {
while let Ok(event) = incoming_rx.try_recv() {
let (request_id, response) = match event {
JsonRpcConnectionEvent::Message(JSONRPCMessage::Response(response)) => {
(response.id, Ok(response.result))
}
JsonRpcConnectionEvent::Message(JSONRPCMessage::Error(error)) => {
(error.id, Err(RpcCallError::Server(error.error)))
}
JsonRpcConnectionEvent::Message(
JSONRPCMessage::Request(_) | JSONRPCMessage::Notification(_),
)
| JsonRpcConnectionEvent::MalformedMessage { .. }
| JsonRpcConnectionEvent::Disconnected { .. } => continue,
};
if !requests.complete(request_id.clone(), response) {
warn!("ignoring unexpected client response while disconnecting: {request_id:?}");
}
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
@@ -191,13 +247,16 @@ mod tests {
use tokio::io::DuplexStream;
use tokio::io::Lines;
use tokio::io::duplex;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio::time::timeout;
use super::complete_queued_client_responses;
use super::run_connection;
use crate::ExecServerRuntimePaths;
use crate::ProcessId;
use crate::connection::JsonRpcConnection;
use crate::connection::JsonRpcConnectionEvent;
use crate::protocol::ENVIRONMENT_INFO_METHOD;
use crate::protocol::ENVIRONMENT_STATUS_METHOD;
use crate::protocol::EXEC_METHOD;
@@ -208,13 +267,19 @@ mod tests {
use crate::protocol::EnvironmentStatusKind;
use crate::protocol::ExecParams;
use crate::protocol::ExecResponse;
use crate::protocol::ExecServerNetworkPolicyDecision;
use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::InitializeResponse;
use crate::protocol::NETWORK_POLICY_REQUEST_METHOD;
use crate::protocol::NetworkPolicyRequestResponse;
use crate::protocol::ReadParams;
use crate::protocol::TerminateParams;
use crate::protocol::TerminateResponse;
use crate::rpc::RpcServerOutboundMessage;
use crate::rpc_server_requests::RpcServerRequestSender;
use crate::server::RequestDispatchMode;
use crate::server::session_registry::SessionRegistry;
#[tokio::test]
@@ -256,6 +321,61 @@ mod tests {
.expect("processor should join");
}
/// A callback response received before EOF must survive transport shutdown.
#[tokio::test]
async fn disconnect_completes_queued_network_policy_response() {
let (outgoing_tx, mut outgoing_rx) = mpsc::channel(/*buffer*/ 1);
let requests = RpcServerRequestSender::new(outgoing_tx);
let pending_request = {
let requests = requests.clone();
tokio::spawn(async move {
requests
.call_with_timeout::<_, NetworkPolicyRequestResponse>(
NETWORK_POLICY_REQUEST_METHOD,
&(),
Duration::from_secs(1),
)
.await
})
};
let RpcServerOutboundMessage::Request(request) = outgoing_rx
.recv()
.await
.expect("network policy request should be queued")
else {
panic!("expected outbound network policy request");
};
let (incoming_tx, mut incoming_rx) = mpsc::channel(/*buffer*/ 2);
incoming_tx
.try_send(JsonRpcConnectionEvent::Message(JSONRPCMessage::Response(
JSONRPCResponse {
id: request.id,
result: serde_json::to_value(NetworkPolicyRequestResponse {
decision: ExecServerNetworkPolicyDecision::Allow,
})
.expect("serialize network policy response"),
},
)))
.expect("queue network policy response");
incoming_tx
.try_send(JsonRpcConnectionEvent::Disconnected { reason: None })
.expect("queue transport disconnect");
complete_queued_client_responses(&requests, &mut incoming_rx);
requests.close();
assert_eq!(
pending_request
.await
.expect("network policy request should join")
.expect("network policy request should complete"),
NetworkPolicyRequestResponse {
decision: ExecServerNetworkPolicyDecision::Allow,
}
);
}
#[tokio::test]
async fn transport_disconnect_detaches_session_during_in_flight_read() {
let registry = SessionRegistry::new(crate::ExecServerTelemetry::default());
@@ -363,6 +483,7 @@ mod tests {
codex_http_client::OutboundProxyPolicy::ReqwestDefault,
),
crate::telemetry::ConnectionTransport::Stdio,
RequestDispatchMode::Inline,
));
(client_writer, BufReader::new(client_reader).lines(), task)
}
@@ -1,3 +1,6 @@
use std::num::NonZeroUsize;
use std::num::ParseIntError;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Instant;
@@ -6,12 +9,21 @@ use codex_exec_server_protocol::JSONRPCNotification;
use codex_exec_server_protocol::JSONRPCRequest;
use codex_exec_server_protocol::JSONRPCResponse;
use codex_exec_server_protocol::RequestId;
use tokio::sync::Semaphore;
use tokio::sync::mpsc;
use tokio::sync::watch;
use tokio::task::JoinSet;
use tracing::Instrument;
use tracing::debug;
use tracing::warn;
use crate::protocol::ENVIRONMENT_INFO_METHOD;
use crate::protocol::ENVIRONMENT_STATUS_METHOD;
use crate::protocol::EXEC_SIGNAL_METHOD;
use crate::protocol::EXEC_TERMINATE_METHOD;
use crate::protocol::FS_CLOSE_METHOD;
use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::rpc::RpcCallError;
use crate::rpc::RpcRouter;
use crate::rpc::RpcServerOutboundMessage;
@@ -28,6 +40,9 @@ pub(super) struct RequestDispatcher {
disconnected_rx: watch::Receiver<bool>,
requests: RpcServerRequestSender,
telemetry: ExecServerTelemetry,
lanes: Option<RequestLanes>,
tasks: JoinSet<RequestTaskResult>,
initialized: bool,
}
impl RequestDispatcher {
@@ -38,7 +53,18 @@ impl RequestDispatcher {
disconnected_rx: watch::Receiver<bool>,
requests: RpcServerRequestSender,
telemetry: ExecServerTelemetry,
mode: RequestDispatchMode,
) -> Self {
let lanes = match mode {
RequestDispatchMode::Inline => None,
RequestDispatchMode::Concurrent {
max_concurrent_requests,
} => Some(RequestLanes {
ordinary: Arc::new(Semaphore::new(max_concurrent_requests.get())),
control: Arc::new(Semaphore::new(max_concurrent_requests.get())),
}),
};
Self {
router,
handler,
@@ -46,6 +72,24 @@ impl RequestDispatcher {
disconnected_rx,
requests,
telemetry,
lanes,
tasks: JoinSet::new(),
initialized: false,
}
}
pub(super) fn has_tasks(&self) -> bool {
!self.tasks.is_empty()
}
pub(super) async fn join_next(&mut self) -> RequestTaskResult {
match self.tasks.join_next().await {
Some(Ok(result)) => result,
Some(Err(error)) => {
warn!("exec-server request task failed: {error}");
RequestTaskResult::ConnectionClosed
}
None => RequestTaskResult::Completed,
}
}
@@ -70,6 +114,7 @@ impl RequestDispatcher {
&mut self,
notification: JSONRPCNotification,
) -> RequestTaskResult {
let is_initialized = notification.method == INITIALIZED_METHOD;
let Some(route) = self.router.notification_route(notification.method.as_str()) else {
warn!(
"closing exec-server connection after unexpected notification: {}",
@@ -88,6 +133,9 @@ impl RequestDispatcher {
warn!("closing exec-server connection after protocol error: {error}");
return RequestTaskResult::ConnectionClosed;
}
if is_initialized {
self.initialized = true;
}
RequestTaskResult::Completed
}
@@ -150,33 +198,126 @@ impl RequestDispatcher {
return RequestTaskResult::Completed;
};
let span = request_span(method, &request);
let message = tokio::select! {
message = route(Arc::clone(&self.handler), request).instrument(span.clone()) => message,
_ = self.disconnected_rx.changed() => {
span.record("result", "disconnected");
self.telemetry
.request_completed(method, "disconnected", started_at.elapsed());
debug!("exec-server transport disconnected while handling request");
let task_span = request_span(method, &request);
let route = route(Arc::clone(&self.handler), request);
let outgoing_tx = self.outgoing_tx.clone();
let mut disconnected_rx = self.disconnected_rx.clone();
let telemetry = self.telemetry.clone();
let task = async move {
let message = tokio::select! {
message = route.instrument(task_span.clone()) => message,
_ = disconnected_rx.changed() => {
task_span.record("result", "disconnected");
telemetry.request_completed(method, "disconnected", started_at.elapsed());
return RequestTaskResult::ConnectionClosed;
}
};
let result = request_result(&message);
let response_sent = match message {
Some(message) => tokio::select! {
result = outgoing_tx.send(message) => result.is_ok(),
_ = disconnected_rx.changed() => false,
},
None => true,
};
if !response_sent {
task_span.record("result", "disconnected");
telemetry.request_completed(method, "disconnected", started_at.elapsed());
return RequestTaskResult::ConnectionClosed;
}
task_span.record("result", result);
telemetry.request_completed(method, result, started_at.elapsed());
RequestTaskResult::Completed
};
let result = request_result(&message);
if let Some(message) = message
&& self.outgoing_tx.send(message).await.is_err()
{
span.record("result", "disconnected");
self.telemetry
.request_completed(method, "disconnected", started_at.elapsed());
return RequestTaskResult::ConnectionClosed;
}
span.record("result", result);
self.telemetry
.request_completed(method, result, started_at.elapsed());
drop(span);
let Some(RequestLanes { ordinary, control }) = &self.lanes else {
// Keep requests ordered when concurrent dispatch is not enabled.
return task.await;
};
// Finish the handshake before concurrent requests can observe session state.
if method == INITIALIZE_METHOD || !self.initialized {
return task.await;
}
// Reserve capacity for health checks and cleanup while ordinary requests are blocked.
let admission = if matches!(
method,
ENVIRONMENT_INFO_METHOD
| ENVIRONMENT_STATUS_METHOD
| EXEC_SIGNAL_METHOD
| EXEC_TERMINATE_METHOD
| FS_CLOSE_METHOD
) {
Arc::clone(control)
} else {
Arc::clone(ordinary)
};
// TODO(anp) bound queued request bytes without blocking later responses or cleanup.
self.tasks.spawn(async move {
let Ok(_permit) = admission.acquire_owned().await else {
return RequestTaskResult::ConnectionClosed;
};
task.await
});
RequestTaskResult::Completed
}
pub(super) async fn shutdown(mut self) {
self.tasks.abort_all();
while self.tasks.join_next().await.is_some() {}
}
}
/// Per-connection request dispatch policy for local and remote exec-servers.
#[derive(Clone, Copy, Debug)]
pub enum RequestDispatchMode {
Inline,
Concurrent {
max_concurrent_requests: ConcurrentRequestLimit,
},
}
/// A valid request concurrency limit accepted by Tokio's semaphore.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct ConcurrentRequestLimit(usize);
impl ConcurrentRequestLimit {
/// Returns a limit when it enables concurrency and fits Tokio's semaphore.
pub fn new(max_concurrent_requests: usize) -> Option<Self> {
if !(2..=Semaphore::MAX_PERMITS).contains(&max_concurrent_requests) {
return None;
}
Some(Self(max_concurrent_requests))
}
/// Returns the validated number of concurrent requests.
pub fn get(self) -> usize {
self.0
}
}
impl FromStr for RequestDispatchMode {
type Err = ParseIntError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
let max_concurrent_requests = value.parse::<NonZeroUsize>()?.get();
if max_concurrent_requests == 1 {
Ok(Self::Inline)
} else {
Ok(Self::Concurrent {
max_concurrent_requests: ConcurrentRequestLimit(
max_concurrent_requests.min(Semaphore::MAX_PERMITS),
),
})
}
}
}
struct RequestLanes {
ordinary: Arc<Semaphore>,
control: Arc<Semaphore>,
}
#[derive(Eq, PartialEq)]
@@ -6,11 +6,54 @@ use opentelemetry::trace::TracerProvider as _;
use opentelemetry_sdk::trace::InMemorySpanExporter;
use opentelemetry_sdk::trace::SdkTracerProvider;
use pretty_assertions::assert_eq;
use tokio::sync::Semaphore;
use tracing_subscriber::filter::filter_fn;
use tracing_subscriber::prelude::*;
use super::ConcurrentRequestLimit;
use super::RequestDispatchMode;
use super::request_span;
/// Public limits reject values that cannot safely enable semaphore-backed concurrency.
#[test]
fn concurrent_request_limit_rejects_invalid_values() {
assert_eq!(
ConcurrentRequestLimit::new(/*max_concurrent_requests*/ 0),
None
);
assert_eq!(
ConcurrentRequestLimit::new(/*max_concurrent_requests*/ 1),
None
);
assert_eq!(
ConcurrentRequestLimit::new(Semaphore::MAX_PERMITS.saturating_add(1)),
None
);
assert_eq!(
ConcurrentRequestLimit::new(/*max_concurrent_requests*/ 2).map(ConcurrentRequestLimit::get),
Some(2)
);
}
/// CLI parsing keeps one request inline and bounds larger positive concurrency limits.
#[test]
fn request_dispatch_mode_parses_bounded_concurrency() {
assert!(matches!("1".parse(), Ok(RequestDispatchMode::Inline)));
assert!("0".parse::<RequestDispatchMode>().is_err());
let oversized_limit = Semaphore::MAX_PERMITS.saturating_add(1).to_string();
let mode = oversized_limit
.parse::<RequestDispatchMode>()
.expect("parse oversized concurrent request limit");
let RequestDispatchMode::Concurrent {
max_concurrent_requests,
} = mode
else {
panic!("expected concurrent request dispatch");
};
assert_eq!(max_concurrent_requests.get(), Semaphore::MAX_PERMITS);
}
/// Request spans retain the wire method and inbound trace while bounding their exported names.
#[test]
fn request_span_uses_bounded_name_wire_method_and_inbound_trace_parent() {
+33 -7
View File
@@ -25,6 +25,7 @@ use tracing::warn;
use crate::ExecServerRuntimePaths;
use crate::ExecServerTelemetry;
use crate::connection::JsonRpcConnection;
use crate::server::RequestDispatchMode;
use crate::server::processor::ConnectionProcessor;
use crate::telemetry::ConnectionTransport;
@@ -85,14 +86,27 @@ pub(crate) async fn run_transport(
runtime_paths: ExecServerRuntimePaths,
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
request_dispatch_mode: RequestDispatchMode,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
match parse_listen_url(listen_url)? {
ExecServerListenTransport::WebSocket(bind_address) => {
run_websocket_listener(bind_address, runtime_paths, telemetry, http_client_factory)
.await
run_websocket_listener(
bind_address,
runtime_paths,
telemetry,
http_client_factory,
request_dispatch_mode,
)
.await
}
ExecServerListenTransport::Stdio => {
run_stdio_connection(runtime_paths, telemetry, http_client_factory).await
run_stdio_connection(
runtime_paths,
telemetry,
http_client_factory,
request_dispatch_mode,
)
.await
}
}
}
@@ -101,6 +115,7 @@ async fn run_stdio_connection(
runtime_paths: ExecServerRuntimePaths,
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
request_dispatch_mode: RequestDispatchMode,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
run_stdio_connection_with_io(
io::stdin(),
@@ -108,6 +123,7 @@ async fn run_stdio_connection(
runtime_paths,
telemetry,
http_client_factory,
request_dispatch_mode,
)
.await
}
@@ -118,13 +134,18 @@ async fn run_stdio_connection_with_io<R, W>(
runtime_paths: ExecServerRuntimePaths,
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
request_dispatch_mode: RequestDispatchMode,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>>
where
R: AsyncRead + Unpin + Send + 'static,
W: AsyncWrite + Unpin + Send + 'static,
{
let processor =
ConnectionProcessor::new_with_telemetry(runtime_paths, telemetry, http_client_factory);
let processor = ConnectionProcessor::new_with_telemetry(
runtime_paths,
telemetry,
http_client_factory,
request_dispatch_mode,
);
tracing::info!("codex-exec-server listening on stdio");
processor
.run_connection(
@@ -142,11 +163,16 @@ async fn run_websocket_listener(
runtime_paths: ExecServerRuntimePaths,
telemetry: ExecServerTelemetry,
http_client_factory: HttpClientFactory,
request_dispatch_mode: RequestDispatchMode,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let listener = TcpListener::bind(bind_address).await?;
let local_addr = listener.local_addr()?;
let processor =
ConnectionProcessor::new_with_telemetry(runtime_paths, telemetry, http_client_factory);
let processor = ConnectionProcessor::new_with_telemetry(
runtime_paths,
telemetry,
http_client_factory,
request_dispatch_mode,
);
info!("codex-exec-server listening on ws://{local_addr}");
println!("ws://{local_addr}");
std::io::stdout().flush()?;
@@ -22,6 +22,7 @@ use crate::protocol::INITIALIZE_METHOD;
use crate::protocol::INITIALIZED_METHOD;
use crate::protocol::InitializeParams;
use crate::protocol::InitializeResponse;
use crate::server::RequestDispatchMode;
#[test]
fn parse_listen_url_accepts_default_websocket_url() {
@@ -65,6 +66,7 @@ async fn stdio_listen_transport_serves_initialize() {
codex_http_client::HttpClientFactory::new(
codex_http_client::OutboundProxyPolicy::ReqwestDefault,
),
RequestDispatchMode::Inline,
));
let mut client_lines = BufReader::new(client_reader).lines();
@@ -87,7 +87,7 @@ async fn exec_server_replays_only_chatgpt_cloudflare_cookies() -> anyhow::Result
(OsString::from("NO_PROXY"), empty.clone()),
(OsString::from("no_proxy"), empty),
];
let mut server = exec_server_with_env(env).await?;
let mut server = exec_server_with_env(env, &[]).await?;
initialize_exec_server(&mut server).await?;
let first_response = execute_http_request(&mut server, CHATGPT_MCP_URL, "first").await?;
@@ -76,10 +76,13 @@ pub(crate) fn test_codex_helper_paths() -> anyhow::Result<TestCodexHelperPaths>
}
pub(crate) async fn exec_server() -> anyhow::Result<ExecServerHarness> {
exec_server_with_env(std::iter::empty::<(&str, &str)>()).await
exec_server_with_env(std::iter::empty::<(&str, &str)>(), &[]).await
}
pub(crate) async fn exec_server_with_env<I, K, V>(env: I) -> anyhow::Result<ExecServerHarness>
pub(crate) async fn exec_server_with_env<I, K, V>(
env: I,
args: &[&str],
) -> anyhow::Result<ExecServerHarness>
where
I: IntoIterator<Item = (K, V)>,
K: AsRef<std::ffi::OsStr>,
@@ -89,6 +92,7 @@ where
let codex_home = TempDir::new()?;
let mut child = Command::new(&helper_paths.codex_exe);
child.args(["exec-server", "--listen", "ws://127.0.0.1:0"]);
child.args(args);
child.stdin(Stdio::null());
child.stdout(Stdio::piped());
child.stderr(Stdio::inherit());
+20 -5
View File
@@ -9,6 +9,8 @@ use std::time::Duration;
use codex_exec_server::CODEX_ARG0_EXEC_HELPER_ARG1;
use codex_exec_server::CODEX_FS_HELPER_ARG1;
use codex_exec_server::ExecServerRuntimePaths;
use codex_exec_server::ExecServerTelemetry;
use codex_exec_server::RequestDispatchMode;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
@@ -161,10 +163,21 @@ fn maybe_run_exec_server_from_test_binary(guard: Option<&TestBinaryDispatchGuard
eprintln!("expected listen URL");
std::process::exit(1);
};
if args.next().is_some() {
eprintln!("unexpected extra arguments");
std::process::exit(1);
}
let remaining_args = args.collect::<Vec<_>>();
let request_dispatch_mode = match remaining_args.as_slice() {
[] => RequestDispatchMode::Inline,
[flag, value] if flag == "--concurrent-requests" => match value.parse() {
Ok(mode) => mode,
Err(error) => {
eprintln!("invalid concurrent request count: {error}");
std::process::exit(1);
}
},
args => {
eprintln!("unexpected exec-server arguments: {args:?}");
std::process::exit(1);
}
};
let current_exe = match env::current_exe() {
Ok(current_exe) => current_exe,
@@ -209,10 +222,12 @@ fn maybe_run_exec_server_from_test_binary(guard: Option<&TestBinaryDispatchGuard
std::process::exit(1);
}
};
let exit_code = match runtime.block_on(codex_exec_server::run_main(
let exit_code = match runtime.block_on(codex_exec_server::run_main_with_telemetry(
&listen_url,
runtime_paths,
ExecServerTelemetry::default(),
http_client_factory,
request_dispatch_mode,
)) {
Ok(()) => 0,
Err(error) => {
@@ -190,7 +190,7 @@ async fn sandboxed_file_system_helper_finds_bwrap_on_preserved_path() -> Result<
}
let helper_path = std::env::join_paths(path_entries)?;
let server = exec_server_with_env([("PATH", helper_path.as_os_str())]).await?;
let server = exec_server_with_env([("PATH", helper_path.as_os_str())], &[]).await?;
let environment = Environment::create_for_tests(Some(server.websocket_url().to_string()))?;
let file_system = environment.get_filesystem();
let workspace = tmp.path().join("workspace");
+30 -24
View File
@@ -162,18 +162,21 @@ async fn exec_server_http_request_uses_configured_system_proxy() -> anyhow::Resu
let proxy_listener = TcpListener::bind("127.0.0.1:0").await?;
let proxy_url = format!("http://{}", proxy_listener.local_addr()?);
let request_url = "http://exec-server-system-proxy.invalid/delegated?route=system";
let mut server = exec_server_with_env([
(SYSTEM_PROXY_REQUEST_URL_ENV, request_url),
(SYSTEM_PROXY_URL_ENV, proxy_url.as_str()),
("HTTP_PROXY", ""),
("http_proxy", ""),
("HTTPS_PROXY", ""),
("https_proxy", ""),
("ALL_PROXY", ""),
("all_proxy", ""),
("NO_PROXY", ""),
("no_proxy", ""),
])
let mut server = exec_server_with_env(
[
(SYSTEM_PROXY_REQUEST_URL_ENV, request_url),
(SYSTEM_PROXY_URL_ENV, proxy_url.as_str()),
("HTTP_PROXY", ""),
("http_proxy", ""),
("HTTPS_PROXY", ""),
("https_proxy", ""),
("ALL_PROXY", ""),
("all_proxy", ""),
("NO_PROXY", ""),
("no_proxy", ""),
],
&[],
)
.await?;
initialize_exec_server(&mut server).await?;
@@ -218,18 +221,21 @@ async fn exec_server_http_request_normalizes_unicode_hostname() -> anyhow::Resul
let proxy_url = format!("http://{}", proxy_listener.local_addr()?);
let request_url = "http://münich.invalid/mcp?route=unicode";
let normalized_url = "http://xn--mnich-kva.invalid/mcp?route=unicode";
let mut server = exec_server_with_env([
(SYSTEM_PROXY_REQUEST_URL_ENV, normalized_url),
(SYSTEM_PROXY_URL_ENV, proxy_url.as_str()),
("HTTP_PROXY", ""),
("http_proxy", ""),
("HTTPS_PROXY", ""),
("https_proxy", ""),
("ALL_PROXY", ""),
("all_proxy", ""),
("NO_PROXY", ""),
("no_proxy", ""),
])
let mut server = exec_server_with_env(
[
(SYSTEM_PROXY_REQUEST_URL_ENV, normalized_url),
(SYSTEM_PROXY_URL_ENV, proxy_url.as_str()),
("HTTP_PROXY", ""),
("http_proxy", ""),
("HTTPS_PROXY", ""),
("https_proxy", ""),
("ALL_PROXY", ""),
("all_proxy", ""),
("NO_PROXY", ""),
("no_proxy", ""),
],
&[],
)
.await?;
initialize_exec_server(&mut server).await?;
+64 -2
View File
@@ -1,12 +1,13 @@
#![cfg(unix)]
mod common;
use codex_exec_server::InitializeParams;
use codex_exec_server::InitializeResponse;
use codex_exec_server_protocol::JSONRPCError;
use codex_exec_server_protocol::JSONRPCErrorError;
use codex_exec_server_protocol::JSONRPCMessage;
use codex_exec_server_protocol::JSONRPCResponse;
use common::exec_server::exec_server;
use common::exec_server::exec_server_with_env;
use pretty_assertions::assert_eq;
use uuid::Uuid;
@@ -34,3 +35,64 @@ async fn exec_server_accepts_initialize() -> anyhow::Result<()> {
server.shutdown().await?;
Ok(())
}
/// Requests retain their wire-order initialization errors even when later handshake messages are pipelined.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_rejects_pipelined_requests_before_initialized() -> anyhow::Result<()> {
let mut server = exec_server_with_env(
std::iter::empty::<(&str, &str)>(),
&["--concurrent-requests", "32"],
)
.await?;
let before_initialize_id = server
.send_request("environment/info", serde_json::json!({}))
.await?;
let initialize_id = server
.send_request(
"initialize",
serde_json::to_value(InitializeParams {
client_name: "exec-server-test".to_string(),
resume_session_id: None,
})?,
)
.await?;
assert_eq!(
server.next_event().await?,
JSONRPCMessage::Error(JSONRPCError {
id: before_initialize_id,
error: JSONRPCErrorError {
code: -32600,
data: None,
message: "client must call initialize before using environment info methods"
.to_string(),
},
})
);
let JSONRPCMessage::Response(JSONRPCResponse { id, .. }) = server.next_event().await? else {
panic!("expected initialize response");
};
assert_eq!(id, initialize_id);
let before_initialized_id = server
.send_request("environment/info", serde_json::json!({}))
.await?;
server
.send_notification("initialized", serde_json::json!({}))
.await?;
assert_eq!(
server.next_event().await?,
JSONRPCMessage::Error(JSONRPCError {
id: before_initialized_id,
error: JSONRPCErrorError {
code: -32600,
data: None,
message: "client must send initialized before using environment info methods"
.to_string(),
},
})
);
server.shutdown().await?;
Ok(())
}
+293 -28
View File
@@ -1,8 +1,10 @@
#![cfg(unix)]
mod common;
use std::collections::HashMap;
use codex_exec_server::EnvironmentInfo;
use codex_exec_server::EnvironmentStatus;
use codex_exec_server::EnvironmentStatusKind;
use codex_exec_server::ExecResponse;
use codex_exec_server::InitializeParams;
use codex_exec_server::InitializeResponse;
@@ -16,11 +18,17 @@ use codex_exec_server_protocol::JSONRPCResponse;
use codex_exec_server_protocol::ProcessSandboxType;
use codex_utils_path_uri::PathUri;
use common::exec_server::exec_server;
use common::exec_server::exec_server_with_env;
use pretty_assertions::assert_eq;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let process_argv = if cfg!(windows) {
vec!["cmd.exe", "/D", "/C", "exit 0"]
} else {
vec!["true"]
};
let initialize_id = server
.send_request(
"initialize",
@@ -48,7 +56,7 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
"process/start",
serde_json::json!({
"processId": "proc-1",
"argv": ["true"],
"argv": process_argv,
"cwd": PathUri::from_host_native_path(std::env::current_dir()?)?,
"env": {},
"tty": false,
@@ -86,6 +94,20 @@ async fn exec_server_starts_process_over_websocket() -> anyhow::Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let process_argv = if cfg!(windows) {
vec!["cmd.exe", "/D", "/C", "ping -n 601 127.0.0.1 >NUL"]
} else {
vec![
"/bin/sh",
"-c",
"parent=$PPID; while kill -0 \"$parent\" 2>/dev/null; do sleep 1; done",
]
};
let process_env = if cfg!(windows) {
serde_json::json!({ "PATH": std::env::var("PATH")? })
} else {
serde_json::json!({})
};
let initialize_id = server
.send_request(
"initialize",
@@ -112,9 +134,9 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res
"process/start",
serde_json::json!({
"processId": "proc-serial-read",
"argv": ["/bin/sh", "-c", "parent=$PPID; while kill -0 \"$parent\" 2>/dev/null; do sleep 1; done"],
"argv": process_argv,
"cwd": PathUri::from_host_native_path(std::env::current_dir()?)?,
"env": {},
"env": process_env,
"tty": false,
"pipeStdin": false,
"arg0": null
@@ -149,9 +171,9 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res
"process/start",
serde_json::json!({
"processId": "proc-serial-queued",
"argv": ["/bin/sh", "-c", "parent=$PPID; while kill -0 \"$parent\" 2>/dev/null; do sleep 1; done"],
"argv": process_argv,
"cwd": PathUri::from_host_native_path(std::env::current_dir()?)?,
"env": {},
"env": process_env,
"tty": false,
"pipeStdin": false,
"arg0": null
@@ -159,18 +181,25 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res
)
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, .. }) = server.next_event().await? else {
let response = server
.wait_for_event(|event| matches!(event, JSONRPCMessage::Response(_)))
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, .. }) = response else {
panic!("expected the blocked process/read to finish before the queued process/start");
};
assert_eq!(id, read_id);
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = server.next_event().await?
else {
let response = server
.wait_for_event(|event| matches!(event, JSONRPCMessage::Response(_)))
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
panic!("expected the queued environment/info response after process/read");
};
assert_eq!(id, queued_environment_info_id);
let _: EnvironmentInfo = serde_json::from_value(result)?;
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = server.next_event().await?
else {
let response = server
.wait_for_event(|event| matches!(event, JSONRPCMessage::Response(_)))
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
panic!("expected the queued process/start response after process/read");
};
assert_eq!(id, queued_start_id);
@@ -182,6 +211,203 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res
}
);
for process_id in ["proc-serial-read", "proc-serial-queued"] {
let terminate_id = server
.send_request(
"process/terminate",
serde_json::json!({ "processId": process_id }),
)
.await?;
server
.wait_for_event(|event| {
matches!(
event,
JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &terminate_id
)
})
.await?;
}
server.shutdown().await?;
Ok(())
}
/// A long read cannot block health checks; saturated ordinary work cannot block health checks or cleanup.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_keeps_control_requests_live_during_long_reads_and_queued_requests()
-> anyhow::Result<()> {
let mut server = exec_server_with_env(
std::iter::empty::<(&str, &str)>(),
&["--concurrent-requests", "32"],
)
.await?;
let process_argv = if cfg!(windows) {
vec!["cmd.exe", "/D", "/C", "ping -n 601 127.0.0.1 >NUL"]
} else {
vec![
"/bin/sh",
"-c",
"parent=$PPID; while kill -0 \"$parent\" 2>/dev/null; do sleep 1; done",
]
};
let process_env = if cfg!(windows) {
serde_json::json!({ "PATH": std::env::var("PATH")? })
} else {
serde_json::json!({})
};
let initialize_id = server
.send_request(
"initialize",
serde_json::to_value(InitializeParams {
client_name: "exec-server-test".to_string(),
resume_session_id: None,
})?,
)
.await?;
assert!(matches!(
server.next_event().await?,
JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == initialize_id
));
server
.send_notification("initialized", serde_json::json!({}))
.await?;
let process_start_id = server
.send_request(
"process/start",
serde_json::json!({
"processId": "proc-capacity",
"argv": process_argv,
"cwd": PathUri::from_host_native_path(std::env::current_dir()?)?,
"env": process_env,
"tty": false,
"pipeStdin": false,
"arg0": null
}),
)
.await?;
assert!(matches!(
server.next_event().await?,
JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == process_start_id
));
let read_params = serde_json::json!({
"processId": "proc-capacity",
"afterSeq": null,
"maxBytes": null,
"waitMs": 600_000
});
server
.send_request("process/read", read_params.clone())
.await?;
let concurrent_start_id = server
.send_request(
"process/start",
serde_json::json!({
"processId": "proc-concurrent",
"argv": process_argv,
"cwd": PathUri::from_host_native_path(std::env::current_dir()?)?,
"env": process_env,
"tty": false,
"pipeStdin": false,
"arg0": null
}),
)
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = server.next_event().await?
else {
panic!("expected process/start to finish before the pending process/read");
};
assert_eq!(id, concurrent_start_id);
assert_eq!(
serde_json::from_value::<ExecResponse>(result)?,
ExecResponse {
process_id: ProcessId::from("proc-concurrent"),
sandbox_type: Some(ProcessSandboxType::None),
}
);
for _ in 1..32 {
server
.send_request("process/read", read_params.clone())
.await?;
}
let queued_read_id = server.send_request("process/read", read_params).await?;
let environment_info_id = server
.send_request("environment/info", serde_json::json!({}))
.await?;
let response = server
.wait_for_event(|event| matches!(event, JSONRPCMessage::Response(_)))
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
panic!("expected environment/info response at regular request capacity");
};
assert_eq!(id, environment_info_id);
let _: EnvironmentInfo = serde_json::from_value(result)?;
let environment_status_id = server
.send_request("environment/status", serde_json::json!({}))
.await?;
let response = server
.wait_for_event(|event| matches!(event, JSONRPCMessage::Response(_)))
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
panic!("expected environment/status response at regular request capacity");
};
assert_eq!(id, environment_status_id);
assert_eq!(
serde_json::from_value::<EnvironmentStatus>(result)?,
EnvironmentStatus {
status: EnvironmentStatusKind::Ready,
}
);
let terminate_id = server
.send_request(
"process/terminate",
serde_json::json!({ "processId": "proc-capacity" }),
)
.await?;
let mut terminate_response = None;
let mut queued_read_completed = false;
while terminate_response.is_none() || !queued_read_completed {
match server.next_event().await? {
JSONRPCMessage::Response(JSONRPCResponse { id, result }) if id == terminate_id => {
terminate_response = Some(serde_json::from_value::<TerminateResponse>(result)?);
}
JSONRPCMessage::Response(JSONRPCResponse { id, result }) if id == queued_read_id => {
let _: ReadResponse = serde_json::from_value(result)?;
queued_read_completed = true;
}
JSONRPCMessage::Error(error) => {
anyhow::bail!("unexpected error while waiting for queued requests: {error:?}");
}
JSONRPCMessage::Request(_)
| JSONRPCMessage::Response(_)
| JSONRPCMessage::Notification(_) => {}
}
}
assert_eq!(
terminate_response,
Some(TerminateResponse { running: true })
);
let terminate_id = server
.send_request(
"process/terminate",
serde_json::json!({ "processId": "proc-concurrent" }),
)
.await?;
server
.wait_for_event(|event| {
matches!(
event,
JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &terminate_id
)
})
.await?;
server.shutdown().await?;
Ok(())
}
@@ -189,6 +415,20 @@ async fn exec_server_runs_ordinary_requests_serially_by_default() -> anyhow::Res
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let process_argv = if cfg!(windows) {
vec!["cmd.exe", "/D", "/C", "ping -n 2 127.0.0.1 >NUL"]
} else {
vec![
"/bin/sh",
"-c",
"sleep 0.3; if IFS= read -r line; then printf 'read:%s\\n' \"$line\"; else printf 'eof\\n'; fi",
]
};
let process_env = if cfg!(windows) {
serde_json::json!({ "PATH": std::env::var("PATH")? })
} else {
serde_json::json!({})
};
let initialize_id = server
.send_request(
"initialize",
@@ -216,13 +456,9 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re
"process/start",
serde_json::json!({
"processId": "proc-default-stdin",
"argv": [
"/bin/sh",
"-c",
"sleep 0.3; if IFS= read -r line; then printf 'read:%s\\n' \"$line\"; else printf 'eof\\n'; fi"
],
"argv": process_argv,
"cwd": PathUri::from_host_native_path(std::env::current_dir()?)?,
"env": {},
"env": process_env,
"tty": false,
"arg0": null
}),
@@ -284,6 +520,26 @@ async fn exec_server_defaults_omitted_pipe_stdin_to_closed_stdin() -> anyhow::Re
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_dedupes_retried_process_write_ids() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let process_argv = if cfg!(windows) {
vec![
"powershell.exe",
"-NoProfile",
"-NonInteractive",
"-Command",
"[Console]::Out.WriteLine('line:' + [Console]::In.ReadLine()); [Console]::Out.WriteLine('line:' + [Console]::In.ReadLine())",
]
} else {
vec![
"/bin/sh",
"-c",
"IFS= read -r first; printf 'line:%s\\n' \"$first\"; IFS= read -r second; printf 'line:%s\\n' \"$second\"",
]
};
let process_env = if cfg!(windows) {
serde_json::to_value(std::env::vars().collect::<HashMap<_, _>>())?
} else {
serde_json::json!({})
};
let initialize_id = server
.send_request(
"initialize",
@@ -311,13 +567,9 @@ async fn exec_server_dedupes_retried_process_write_ids() -> anyhow::Result<()> {
"process/start",
serde_json::json!({
"processId": "proc-write-id",
"argv": [
"/bin/sh",
"-c",
"IFS= read -r first; printf 'line:%s\\n' \"$first\"; IFS= read -r second; printf 'line:%s\\n' \"$second\""
],
"argv": process_argv,
"cwd": PathUri::from_host_native_path(std::env::current_dir()?)?,
"env": {},
"env": process_env,
"tty": false,
"pipeStdin": true,
"arg0": null
@@ -401,13 +653,16 @@ async fn exec_server_dedupes_retried_process_write_ids() -> anyhow::Result<()> {
.flat_map(|chunk| chunk.chunk.into_inner()),
);
after_seq = Some(read_response.next_seq.saturating_sub(1));
if read_response.closed || output.ends_with(b"line:second\n") {
if read_response.closed
|| output.ends_with(b"line:second\n")
|| output.ends_with(b"line:second\r\n")
{
break;
}
}
assert_eq!(
String::from_utf8(output)?,
String::from_utf8(output)?.replace("\r\n", "\n"),
"line:first\nline:second\n".to_string()
);
@@ -418,6 +673,16 @@ async fn exec_server_dedupes_retried_process_write_ids() -> anyhow::Result<()> {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_resumes_detached_session_without_killing_processes() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let process_argv = if cfg!(windows) {
vec!["cmd.exe", "/D", "/C", "ping -n 6 127.0.0.1 >NUL"]
} else {
vec!["/bin/sh", "-c", "sleep 5"]
};
let process_env = if cfg!(windows) {
serde_json::json!({ "PATH": std::env::var("PATH")? })
} else {
serde_json::json!({})
};
let initialize_id = server
.send_request(
"initialize",
@@ -449,9 +714,9 @@ async fn exec_server_resumes_detached_session_without_killing_processes() -> any
"process/start",
serde_json::json!({
"processId": "proc-resume",
"argv": ["/bin/sh", "-c", "sleep 5"],
"argv": process_argv,
"cwd": PathUri::from_host_native_path(std::env::current_dir()?)?,
"env": {},
"env": process_env,
"tty": false,
"pipeStdin": false,
"arg0": null