mirror of
https://github.com/openai/codex.git
synced 2026-10-16 16:58:46 +00:00
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:
committed by
copyberry
parent
1d952f027e
commit
eeae88d8a6
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
®istry.uri(),
|
||||
"--environment-id",
|
||||
ENVIRONMENT_ID,
|
||||
"--concurrent-requests",
|
||||
"2",
|
||||
])
|
||||
.stdin(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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?;
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user