Remove WebSocket transport from code-mode hosts (#40692)

## What changed

- Accept only `http://` and `https://` gRPC endpoints for app server
  `--code-mode-host` connections.
- Limit the standalone code-mode host to stdio and gRPC listeners.
- Remove the WebSocket session provider, dual-WebSocket negotiation, transport
  lanes, and related dependencies and tests.
- Keep coverage for shared and prewarmed gRPC hosts and reject WebSocket host
  URLs in CLI parsing tests.

GitOrigin-RevId: 8792604e79ffd54ce776074a4dc3f7d55e63752a
This commit is contained in:
Ruslan Nigmatullin authored and copyberry committed 2026-08-25 20:37:15 +00:00
1 parent 9be8d6e1c3
commit 725b3a44f8
31 files changed
+140 -2753

No files matched your search

-3
View File
@@ -2573,7 +2573,6 @@ dependencies = [
"codex-http-client", "codex-http-client",
"codex-install-context", "codex-install-context",
"codex-protocol", "codex-protocol",
"codex-websocket-client",
"futures", "futures",
"http-body-util", "http-body-util",
"pretty_assertions", "pretty_assertions",
@@ -2581,7 +2580,6 @@ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde_json", "serde_json",
"tokio", "tokio",
"tokio-tungstenite",
"tokio-util", "tokio-util",
"tonic", "tonic",
"tower", "tower",
@@ -2608,7 +2606,6 @@ dependencies = [
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-stream", "tokio-stream",
"tokio-tungstenite",
"tokio-util", "tokio-util",
"tonic", "tonic",
"tracing", "tracing",
+1 -1
View File
@@ -36,7 +36,7 @@ When running with `--listen ws://IP:PORT`, the same listener also serves basic H
Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads. Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads.
Pass `--code-mode-host URL` to connect this app-server process to a remote code-mode host instead of starting a local host. Use `ws://` or `wss://` for the WebSocket protocol, or a root `http://` or `https://` URL without a path or query for gRPC. Remote hosts require the `code_mode_host` feature. This outbound connection is independent of `--listen` and is shared by the process's threads. Pass `--code-mode-host URL` to connect this app-server process to a remote code-mode host instead of starting a local host. Use a root `http://` or `https://` URL without a path or query for gRPC. Remote hosts require the `code_mode_host` feature. This outbound connection is independent of `--listen` and is shared by the process's threads.
The unix socket transport is intended for local app-server control-plane clients. `codex app-server proxy` The unix socket transport is intended for local app-server control-plane clients. `codex app-server proxy`
opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-server-control.sock` opens exactly one raw stream connection to `$CODEX_HOME/app-server-control/app-server-control.sock`
+3 -11
View File
@@ -23,8 +23,6 @@ pub enum CodeModeHostTransport {
/// Start and own the default local code-mode host. /// Start and own the default local code-mode host.
#[default] #[default]
Local, Local,
/// Share a connection to the specified remote code-mode host.
WebSocket(Url),
/// Share an HTTP/2 gRPC connection to the specified remote code-mode host. /// Share an HTTP/2 gRPC connection to the specified remote code-mode host.
Grpc(Url), Grpc(Url),
} }
@@ -32,8 +30,7 @@ pub enum CodeModeHostTransport {
impl From<AppServerCodeModeHostArgs> for CodeModeHostTransport { impl From<AppServerCodeModeHostArgs> for CodeModeHostTransport {
fn from(args: AppServerCodeModeHostArgs) -> Self { fn from(args: AppServerCodeModeHostArgs) -> Self {
match args.code_mode_host { match args.code_mode_host {
Some(url) if matches!(url.scheme(), "http" | "https") => Self::Grpc(url), Some(url) => Self::Grpc(url),
Some(url) => Self::WebSocket(url),
None => Self::Local, None => Self::Local,
} }
} }
@@ -66,23 +63,18 @@ impl TypedValueParser for RedactedHostUrlParser {
fn parse_host_url(value: &str) -> Result<Url, String> { fn parse_host_url(value: &str) -> Result<Url, String> {
let url = Url::parse(value).map_err(|error| format!("invalid code-mode host URL: {error}"))?; let url = Url::parse(value).map_err(|error| format!("invalid code-mode host URL: {error}"))?;
if !matches!(url.scheme(), "ws" | "wss" | "http" | "https") || url.host_str().is_none() { if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err( return Err("code-mode host URL must use http:// or https:// with a host".to_string());
"code-mode host URL must use ws://, wss://, http://, or https:// with a host"
.to_string(),
);
} }
if url.fragment().is_some() { if url.fragment().is_some() {
return Err("code-mode host URL must not contain a fragment".to_string()); return Err("code-mode host URL must not contain a fragment".to_string());
} }
if matches!(url.scheme(), "http" | "https") {
if !url.username().is_empty() || url.password().is_some() { if !url.username().is_empty() || url.password().is_some() {
return Err("gRPC code-mode host URL must not contain credentials".to_string()); return Err("gRPC code-mode host URL must not contain credentials".to_string());
} }
if url.path() != "/" || url.query().is_some() { if url.path() != "/" || url.query().is_some() {
return Err("gRPC code-mode host URL must not contain a path or query".to_string()); return Err("gRPC code-mode host URL must not contain a path or query".to_string());
} }
}
Ok(url) Ok(url)
} }
@@ -4,21 +4,6 @@ use super::parse_host_url;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use url::Url; use url::Url;
#[test]
fn websocket_host_accepts_local_and_secure_endpoints() {
for endpoint in [
"ws://127.0.0.1:8765",
"wss://example.test/code-mode",
"ws://alice:secret@example.test/code-mode",
"wss://alice:secret@example.test/code-mode",
] {
assert_eq!(
parse_host_url(endpoint),
Ok(Url::parse(endpoint).expect("test endpoint should parse"))
);
}
}
#[test] #[test]
fn grpc_host_accepts_local_and_secure_endpoints() { fn grpc_host_accepts_local_and_secure_endpoints() {
for endpoint in ["http://127.0.0.1:8765", "https://example.test"] { for endpoint in ["http://127.0.0.1:8765", "https://example.test"] {
@@ -50,8 +35,13 @@ fn code_mode_host_rejects_invalid_endpoints() {
for endpoint in [ for endpoint in [
"ftp://127.0.0.1:8765", "ftp://127.0.0.1:8765",
"ws://", "ws://",
"not a host endpoint", "ws://127.0.0.1:8765",
"wss://example.test/code-mode",
"ws://alice:secret@example.test/code-mode",
"wss://alice:secret@example.test/code-mode",
"wss://example.test/code-mode#fragment", "wss://example.test/code-mode#fragment",
"http://",
"not a host endpoint",
"https://example.test/code-mode#fragment", "https://example.test/code-mode#fragment",
"https://example.test/code-mode", "https://example.test/code-mode",
"http://example.test/?token=secret", "http://example.test/?token=secret",
@@ -64,25 +54,13 @@ fn code_mode_host_rejects_invalid_endpoints() {
} }
#[test] #[test]
fn omitted_websocket_host_selects_local_transport() { fn omitted_host_selects_local_transport() {
assert_eq!( assert_eq!(
CodeModeHostTransport::from(AppServerCodeModeHostArgs::default()), CodeModeHostTransport::from(AppServerCodeModeHostArgs::default()),
CodeModeHostTransport::Local CodeModeHostTransport::Local
); );
} }
#[test]
fn explicit_websocket_host_selects_remote_transport() {
let url = Url::parse("wss://example.test/code-mode").expect("test endpoint should parse");
assert_eq!(
CodeModeHostTransport::from(AppServerCodeModeHostArgs {
code_mode_host: Some(url.clone()),
}),
CodeModeHostTransport::WebSocket(url)
);
}
#[test] #[test]
fn explicit_grpc_host_selects_remote_transport() { fn explicit_grpc_host_selects_remote_transport() {
let url = Url::parse("https://example.test").expect("test endpoint should parse"); let url = Url::parse("https://example.test").expect("test endpoint should parse");
-15
View File
@@ -4,7 +4,6 @@
use codex_arg0::Arg0DispatchPaths; use codex_arg0::Arg0DispatchPaths;
use codex_code_mode::CodeModeSessionProvider; use codex_code_mode::CodeModeSessionProvider;
use codex_code_mode::GrpcCodeModeSessionProvider; use codex_code_mode::GrpcCodeModeSessionProvider;
use codex_code_mode::WebSocketCodeModeSessionProvider;
use codex_config::LoaderOverrides; use codex_config::LoaderOverrides;
use codex_config::NoopThreadConfigLoader; use codex_config::NoopThreadConfigLoader;
use codex_core::config::Config; use codex_core::config::Config;
@@ -552,20 +551,6 @@ pub async fn run_main_with_transport_options(
let code_mode_session_provider: Option<Arc<dyn CodeModeSessionProvider>> = let code_mode_session_provider: Option<Arc<dyn CodeModeSessionProvider>> =
match &runtime_options.code_mode_host_transport { match &runtime_options.code_mode_host_transport {
CodeModeHostTransport::Local => None, CodeModeHostTransport::Local => None,
CodeModeHostTransport::WebSocket(url) => {
if !config.features.enabled(Feature::CodeModeHost) {
return Err(std::io::Error::new(
ErrorKind::InvalidInput,
"remote code-mode host requires the code_mode_host feature to be enabled",
));
}
Some(Arc::new(
WebSocketCodeModeSessionProvider::with_http_client_factory(
url.to_string(),
config.http_client_factory(),
),
))
}
CodeModeHostTransport::Grpc(url) => { CodeModeHostTransport::Grpc(url) => {
if !config.features.enabled(Feature::CodeModeHost) { if !config.features.enabled(Feature::CodeModeHost) {
return Err(std::io::Error::new( return Err(std::io::Error::new(
+6 -19
View File
@@ -38,25 +38,6 @@ fn app_server_accepts_cli_config_overrides() {
); );
} }
#[test]
fn app_server_accepts_process_scoped_code_mode_host() {
let args = AppServerArgs::try_parse_from([
"codex-app-server",
"--code-mode-host",
"wss://example.test/code-mode",
"--listen",
"off",
])
.expect("parse app-server args");
assert_eq!(
args.code_mode_host.code_mode_host,
Some(Url::parse("wss://example.test/code-mode").expect("test endpoint should parse"))
);
assert_eq!(args.listen, AppServerTransport::Off);
assert_eq!(args.config_overrides.raw_overrides, Vec::<String>::new());
}
#[test] #[test]
fn app_server_accepts_process_scoped_grpc_code_mode_host() { fn app_server_accepts_process_scoped_grpc_code_mode_host() {
let args = AppServerArgs::try_parse_from([ let args = AppServerArgs::try_parse_from([
@@ -80,7 +61,13 @@ fn app_server_rejects_invalid_code_mode_host() {
for endpoint in [ for endpoint in [
"ftp://127.0.0.1:8765", "ftp://127.0.0.1:8765",
"ws://", "ws://",
"ws://127.0.0.1:8765",
"wss://example.test/code-mode",
"ws://alice:secret@example.test/code-mode",
"wss://alice:secret@example.test/code-mode",
"wss://example.test/code-mode#fragment", "wss://example.test/code-mode#fragment",
"http://",
"https://example.test/#fragment",
"https://example.test/code-mode", "https://example.test/code-mode",
"http://alice:secret@example.test", "http://alice:secret@example.test",
"https://alice:secret@example.test", "https://alice:secret@example.test",
@@ -24,49 +24,11 @@ const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 60);
#[cfg(not(any(target_os = "macos", windows)))] #[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10); const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_server_shares_flag_selected_code_mode_host_across_threads() -> Result<()> {
assert_shared_remote_code_mode_host("ws://127.0.0.1:0").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_server_shares_flag_selected_grpc_code_mode_host_across_threads() -> Result<()> { async fn app_server_shares_flag_selected_grpc_code_mode_host_across_threads() -> Result<()> {
assert_shared_remote_code_mode_host("grpc://127.0.0.1:0").await
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_server_prewarms_flag_selected_code_mode_host_before_first_turn() -> Result<()> {
let model_server = responses::start_mock_server().await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&model_server.uri())
.enable_feature(Feature::CodeModePrewarm)
.write(codex_home.path())?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let host_url = format!("ws://{}", listener.local_addr()?);
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_args(&["--code-mode-host", &host_url])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
app_server
.start_thread(ThreadStartParams::default())
.await?;
let (_stalled_connection, _) = timeout(DEFAULT_READ_TIMEOUT, listener.accept())
.await
.context("code-mode host was not contacted before the first turn")??;
let status = timeout(Duration::from_secs(5), app_server.shutdown_gracefully())
.await
.context("stalled code-mode prewarm blocked thread shutdown")??;
assert!(status.success(), "app-server did not exit successfully");
Ok(())
}
async fn assert_shared_remote_code_mode_host(listen_url: &str) -> Result<()> {
let host_program = codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?; let host_program = codex_utils_cargo_bin::cargo_bin("codex-code-mode-host")?;
let mut code_mode_host = Command::new(host_program) let mut code_mode_host = Command::new(host_program)
.args(["--listen", listen_url]) .args(["--listen", "grpc://127.0.0.1:0"])
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::inherit()) .stderr(Stdio::inherit())
@@ -174,3 +136,32 @@ async fn assert_shared_remote_code_mode_host(listen_url: &str) -> Result<()> {
Ok(()) Ok(())
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn app_server_prewarms_flag_selected_grpc_code_mode_host_before_first_turn() -> Result<()> {
let model_server = responses::start_mock_server().await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&model_server.uri())
.enable_feature(Feature::CodeModePrewarm)
.write(codex_home.path())?;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
let host_url = format!("http://{}", listener.local_addr()?);
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_args(&["--code-mode-host", &host_url])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
app_server
.start_thread(ThreadStartParams::default())
.await?;
let (_stalled_connection, _) = timeout(DEFAULT_READ_TIMEOUT, listener.accept())
.await
.context("code-mode host was not contacted before the first turn")??;
let status = timeout(Duration::from_secs(5), app_server.shutdown_gracefully())
.await
.context("stalled code-mode prewarm blocked thread shutdown")??;
assert!(status.success(), "app-server did not exit successfully");
Ok(())
}
+6 -29
View File
@@ -4487,35 +4487,6 @@ mod tests {
assert!(err.to_string().contains("is empty")); assert!(err.to_string().contains("is empty"));
} }
#[test]
fn app_server_code_mode_host_url_parses_independently_of_listen_transport() {
let app_server = app_server_from_args(
[
"codex",
"app-server",
"--code-mode-host",
"wss://example.test/code-mode",
"--listen",
"ws://127.0.0.1:4500",
]
.as_ref(),
);
assert_eq!(
app_server.code_mode_host.code_mode_host,
Some(
url::Url::parse("wss://example.test/code-mode")
.expect("test endpoint should parse")
)
);
assert_eq!(
app_server.listen,
codex_app_server::AppServerTransport::WebSocket {
bind_address: "127.0.0.1:4500".parse().expect("valid socket address"),
}
);
}
#[test] #[test]
fn app_server_grpc_code_mode_host_url_parses_independently_of_listen_transport() { fn app_server_grpc_code_mode_host_url_parses_independently_of_listen_transport() {
let app_server = app_server_from_args( let app_server = app_server_from_args(
@@ -4541,7 +4512,13 @@ mod tests {
for endpoint in [ for endpoint in [
"ftp://127.0.0.1:8765", "ftp://127.0.0.1:8765",
"ws://", "ws://",
"ws://127.0.0.1:8765",
"wss://example.test/code-mode",
"ws://alice:secret@example.test/code-mode",
"wss://alice:secret@example.test/code-mode",
"wss://example.test/code-mode#fragment", "wss://example.test/code-mode#fragment",
"http://",
"https://example.test/#fragment",
"https://example.test/code-mode", "https://example.test/code-mode",
"http://alice:secret@example.test", "http://alice:secret@example.test",
"https://alice:secret@example.test", "https://alice:secret@example.test",
+1 -2
View File
@@ -18,7 +18,7 @@ workspace = true
[dependencies] [dependencies]
anyhow = { workspace = true } anyhow = { workspace = true }
axum = { workspace = true, features = ["http1", "tokio", "ws"] } axum = { workspace = true, features = ["http1", "tokio"] }
clap = { workspace = true, features = ["derive"] } clap = { workspace = true, features = ["derive"] }
codex-code-mode-protocol = { workspace = true } codex-code-mode-protocol = { workspace = true }
codex-code-mode-runtime = { workspace = true } codex-code-mode-runtime = { workspace = true }
@@ -39,4 +39,3 @@ codex-code-mode = { workspace = true }
codex-utils-cargo-bin = { workspace = true } codex-utils-cargo-bin = { workspace = true }
pretty_assertions = { workspace = true } pretty_assertions = { workspace = true }
tempfile = { workspace = true } tempfile = { workspace = true }
tokio-tungstenite = { workspace = true }
+1 -111
View File
@@ -13,7 +13,6 @@ use codex_code_mode_protocol::host::Capability;
use codex_code_mode_protocol::host::CapabilitySet; use codex_code_mode_protocol::host::CapabilitySet;
use codex_code_mode_protocol::host::ClientHello; use codex_code_mode_protocol::host::ClientHello;
use codex_code_mode_protocol::host::ClientToHost; use codex_code_mode_protocol::host::ClientToHost;
use codex_code_mode_protocol::host::DUAL_WEBSOCKET_CAPABILITY;
use codex_code_mode_protocol::host::EncodedFrame; use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::FramedReader; use codex_code_mode_protocol::host::FramedReader;
use codex_code_mode_protocol::host::FramedWriter; use codex_code_mode_protocol::host::FramedWriter;
@@ -36,7 +35,6 @@ use tokio::sync::mpsc;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tokio_util::task::TaskTracker; use tokio_util::task::TaskTracker;
use uuid::Uuid;
use super::HostLimits; use super::HostLimits;
use super::HostState; use super::HostState;
@@ -45,12 +43,8 @@ use super::MAX_RECENT_REQUEST_IDS;
use super::RequestKind; use super::RequestKind;
use super::RequestRegistry; use super::RequestRegistry;
use super::SeenSessionIds; use super::SeenSessionIds;
use super::negotiate;
use super::peer::HostPeer; use super::peer::HostPeer;
use super::run; use super::run;
use super::transport::BulkConnectionRegistry;
use super::transport::ConnectionReader;
use super::transport::ConnectionWriter;
fn client_hello( fn client_hello(
versions: impl IntoIterator<Item = ProtocolVersion>, versions: impl IntoIterator<Item = ProtocolVersion>,
@@ -260,111 +254,8 @@ impl AsyncWrite for BlockingWriter {
} }
} }
#[tokio::test]
async fn failed_host_hello_removes_the_bulk_pairing_reservation() {
let (host_reader, client_writer) = tokio::io::duplex(/*max_buf_size*/ 4096);
let capability = Capability::new(DUAL_WEBSOCKET_CAPABILITY).expect("dual websocket capability");
let hello = ClientToHost::ClientHello(
ClientHello::new(
SupportedProtocolVersions::try_new([ProtocolVersion::V1]).expect("supported versions"),
CapabilitySet::empty(),
CapabilitySet::try_new([capability]).expect("optional capabilities"),
)
.expect("client hello"),
);
FramedWriter::new(client_writer)
.write(&hello)
.await
.expect("write client hello");
let bytes = Arc::new(Mutex::new(Vec::new()));
let registry = BulkConnectionRegistry::default();
let mut reader = ConnectionReader::from_reader(host_reader);
let mut writer = ConnectionWriter::from_writer(FailingHandshakeWriter {
bytes: Arc::clone(&bytes),
});
let result = negotiate(&mut reader, &mut writer, Some(&registry)).await;
assert!(result.is_err());
let message = EncodedFrame::decode_framed::<HostToClient>(
&bytes.lock().unwrap_or_else(PoisonError::into_inner),
)
.expect("decode failed host hello");
let HostToClient::HostHello(hello) = message else {
panic!("expected a host hello");
};
let token = hello
.bulk_connection_token()
.expect("dual websocket pairing token");
let token = Uuid::parse_str(token).expect("UUID pairing token");
assert!(registry.remove(token).is_none());
}
struct FailingHandshakeWriter {
bytes: Arc<Mutex<Vec<u8>>>,
}
impl AsyncWrite for FailingHandshakeWriter {
fn poll_write(
self: Pin<&mut Self>,
_cx: &mut Context<'_>,
bytes: &[u8],
) -> Poll<std::io::Result<usize>> {
self.bytes
.lock()
.unwrap_or_else(PoisonError::into_inner)
.extend_from_slice(bytes);
Poll::Ready(Ok(bytes.len()))
}
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Err(std::io::Error::other("host hello write failed")))
}
fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
Poll::Ready(Ok(()))
}
}
#[tokio::test]
async fn optional_dual_websocket_capability_falls_back_to_a_single_connection() {
let (host_stream, client_stream) = tokio::io::duplex(/*max_buf_size*/ 1024);
let (host_reader, host_writer) = tokio::io::split(host_stream);
let (client_reader, client_writer) = tokio::io::split(client_stream);
let host = tokio::spawn(run(host_reader, host_writer));
let mut reader = FramedReader::new(client_reader);
let mut writer = FramedWriter::new(client_writer);
let capability = Capability::new(DUAL_WEBSOCKET_CAPABILITY).expect("dual websocket capability");
writer
.write(&ClientToHost::ClientHello(
ClientHello::new(
SupportedProtocolVersions::try_new([ProtocolVersion::V1])
.expect("supported versions"),
CapabilitySet::empty(),
CapabilitySet::try_new([capability]).expect("optional capabilities"),
)
.expect("client hello"),
))
.await
.expect("write hello");
assert_eq!(
reader.read::<HostToClient>().await.expect("host hello"),
Some(HostToClient::HostHello(HostHello::new(
ProtocolVersion::V1,
CapabilitySet::empty(),
)))
);
drop(writer);
drop(reader);
host.await.expect("host task").expect("host connection");
}
#[tokio::test] #[tokio::test]
async fn session_resource_limits_are_negotiated_when_optional_or_required() { async fn session_resource_limits_are_negotiated_when_optional_or_required() {
let dual_capability =
Capability::new(DUAL_WEBSOCKET_CAPABILITY).expect("dual websocket capability");
let resource_limits_capability = let resource_limits_capability =
Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY).expect("resource limits capability"); Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY).expect("resource limits capability");
let resource_limits = let resource_limits =
@@ -373,8 +264,7 @@ async fn session_resource_limits_are_negotiated_when_optional_or_required() {
for (required, optional) in [ for (required, optional) in [
( (
CapabilitySet::empty(), CapabilitySet::empty(),
CapabilitySet::try_new([dual_capability, resource_limits_capability]) CapabilitySet::try_new([resource_limits_capability]).expect("optional capabilities"),
.expect("optional capabilities"),
), ),
(resource_limits.clone(), CapabilitySet::empty()), (resource_limits.clone(), CapabilitySet::empty()),
] { ] {
+24 -124
View File
@@ -14,20 +14,19 @@ use codex_code_mode_protocol::CodeModeSessionCellExecutionLimits;
use codex_code_mode_protocol::host::Capability; use codex_code_mode_protocol::host::Capability;
use codex_code_mode_protocol::host::CapabilitySet; use codex_code_mode_protocol::host::CapabilitySet;
use codex_code_mode_protocol::host::ClientToHost; use codex_code_mode_protocol::host::ClientToHost;
use codex_code_mode_protocol::host::DUAL_WEBSOCKET_CAPABILITY;
use codex_code_mode_protocol::host::EncodedFrame; use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::FramedReader;
use codex_code_mode_protocol::host::FramedWriter;
use codex_code_mode_protocol::host::HandshakeRejectReason; use codex_code_mode_protocol::host::HandshakeRejectReason;
use codex_code_mode_protocol::host::HostHello; use codex_code_mode_protocol::host::HostHello;
use codex_code_mode_protocol::host::HostRequest; use codex_code_mode_protocol::host::HostRequest;
use codex_code_mode_protocol::host::HostResponse; use codex_code_mode_protocol::host::HostResponse;
use codex_code_mode_protocol::host::HostToClient; use codex_code_mode_protocol::host::HostToClient;
use codex_code_mode_protocol::host::MAX_PENDING_DELEGATE_CALLS;
use codex_code_mode_protocol::host::ProtocolVersion; use codex_code_mode_protocol::host::ProtocolVersion;
use codex_code_mode_protocol::host::RequestId; use codex_code_mode_protocol::host::RequestId;
use codex_code_mode_protocol::host::SESSION_RESOURCE_LIMITS_CAPABILITY; use codex_code_mode_protocol::host::SESSION_RESOURCE_LIMITS_CAPABILITY;
use codex_code_mode_protocol::host::SessionId; use codex_code_mode_protocol::host::SessionId;
use codex_code_mode_protocol::host::SupportedProtocolVersions; use codex_code_mode_protocol::host::SupportedProtocolVersions;
use codex_code_mode_protocol::host::TransportLane;
use codex_code_mode_runtime::InProcessCodeModeSession; use codex_code_mode_runtime::InProcessCodeModeSession;
use tokio::io::AsyncRead; use tokio::io::AsyncRead;
use tokio::io::AsyncWrite; use tokio::io::AsyncWrite;
@@ -40,10 +39,6 @@ use tokio_util::task::TaskTracker;
use self::delegate::RemoteDelegate; use self::delegate::RemoteDelegate;
use self::peer::HostPeer; use self::peer::HostPeer;
use self::transport::BulkConnectionRegistration;
use self::transport::BulkConnectionRegistry;
use self::transport::ConnectionReader;
use self::transport::ConnectionWriter;
pub use self::grpc::GrpcCodeModeHost; pub use self::grpc::GrpcCodeModeHost;
pub use self::transport::DEFAULT_LISTEN_URL; pub use self::transport::DEFAULT_LISTEN_URL;
@@ -60,12 +55,10 @@ const MAX_RECENT_REQUEST_IDS: usize = 4096;
const MAX_RECENT_SESSION_IDS: usize = 4096; const MAX_RECENT_SESSION_IDS: usize = 4096;
const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
const OUTGOING_CHANNEL_CAPACITY: usize = 128; const OUTGOING_CHANNEL_CAPACITY: usize = 128;
const BULK_PAIRING_TIMEOUT: Duration = Duration::from_secs(10);
enum NegotiatedConnection { enum NegotiatedConnection {
Rejected, Rejected,
Single, Accepted,
Dual(BulkConnectionRegistration),
} }
struct HostLimits { struct HostLimits {
@@ -90,7 +83,7 @@ impl HostLimits {
} }
} }
/// Runs the code-mode host on its configured stdio or WebSocket transport. /// Runs the code-mode host on its configured stdio or gRPC transport.
pub async fn run_main(listen_url: &str) -> Result<()> { pub async fn run_main(listen_url: &str) -> Result<()> {
transport::run_transport(listen_url).await transport::run_transport(listen_url).await
} }
@@ -106,55 +99,17 @@ where
R: AsyncRead + Send + Unpin + 'static, R: AsyncRead + Send + Unpin + 'static,
W: AsyncWrite + Send + Unpin + 'static, W: AsyncWrite + Send + Unpin + 'static,
{ {
run_connection( let mut reader = FramedReader::new(reader);
ConnectionReader::from_reader(reader), let mut writer = FramedWriter::new(writer);
ConnectionWriter::from_writer(writer), match negotiate(&mut reader, &mut writer).await? {
Arc::new(HostLimits::new()),
/*bulk_connections*/ None,
)
.await
}
async fn run_connection(
mut reader: ConnectionReader,
mut writer: ConnectionWriter,
limits: Arc<HostLimits>,
bulk_connections: Option<BulkConnectionRegistry>,
) -> Result<()> {
let negotiated = negotiate(&mut reader, &mut writer, bulk_connections.as_ref()).await?;
let bulk_connection = match negotiated {
NegotiatedConnection::Rejected => return Ok(()), NegotiatedConnection::Rejected => return Ok(()),
NegotiatedConnection::Single => None, NegotiatedConnection::Accepted => {}
NegotiatedConnection::Dual(mut registration) => {
match tokio::time::timeout(BULK_PAIRING_TIMEOUT, registration.receive()).await {
Ok(Ok(connection)) => Some(connection),
Ok(Err(_)) => {
anyhow::bail!("code-mode host bulk websocket pairing was abandoned");
} }
Err(_) => {
anyhow::bail!("timed out pairing code-mode host bulk websocket");
}
}
}
};
let (mut bulk_reader, bulk_writer) = match bulk_connection {
Some(connection) => (Some(connection.reader), Some(connection.writer)),
None => (None, None),
};
let (outgoing_tx, outgoing_rx) = mpsc::channel::<EncodedFrame>(OUTGOING_CHANNEL_CAPACITY); let (outgoing_tx, outgoing_rx) = mpsc::channel::<EncodedFrame>(OUTGOING_CHANNEL_CAPACITY);
let (bulk_tx, bulk_rx) = if bulk_writer.is_some() { let peer = Arc::new(HostPeer::new(outgoing_tx));
let (sender, receiver) = mpsc::channel::<EncodedFrame>(MAX_PENDING_DELEGATE_CALLS);
(Some(sender), Some(receiver))
} else {
(None, None)
};
let peer = match bulk_tx {
Some(sender) => Arc::new(HostPeer::new(outgoing_tx).with_bulk_sender(sender)),
None => Arc::new(HostPeer::new(outgoing_tx)),
};
let state = Arc::new(HostState { let state = Arc::new(HostState {
sessions: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()),
limits, limits: Arc::new(HostLimits::new()),
seen_session_ids: Mutex::new(SeenSessionIds::default()), seen_session_ids: Mutex::new(SeenSessionIds::default()),
requests: Mutex::new(RequestRegistry::default()), requests: Mutex::new(RequestRegistry::default()),
request_tasks: TaskTracker::new(), request_tasks: TaskTracker::new(),
@@ -162,17 +117,8 @@ async fn run_connection(
peer: Arc::clone(&peer), peer: Arc::clone(&peer),
}); });
let writer_disconnected = peer.disconnection_token(); let writer_disconnected = peer.disconnection_token();
let writer_task = tokio::spawn(async move { let writer_task =
if let (Some(bulk_writer), Some(bulk_rx)) = (bulk_writer, bulk_rx) { tokio::spawn(async move { drive_writer(writer, outgoing_rx, writer_disconnected).await });
tokio::try_join!(
drive_writer(writer, outgoing_rx, writer_disconnected.clone()),
drive_writer(bulk_writer, bulk_rx, writer_disconnected)
)?;
Ok(())
} else {
drive_writer(writer, outgoing_rx, writer_disconnected).await
}
});
let writer_peer = Arc::clone(&peer); let writer_peer = Arc::clone(&peer);
let writer_supervisor = tokio::spawn(async move { let writer_supervisor = tokio::spawn(async move {
match writer_task.await { match writer_task.await {
@@ -191,30 +137,14 @@ async fn run_connection(
let input_result = async { let input_result = async {
loop { loop {
let (message, lane) = tokio::select! { let message = tokio::select! {
// Session operations and shutdown must make progress even when bulk callbacks are ready.
biased; biased;
_ = peer.disconnected() => break, _ = peer.disconnected() => break,
message = reader.read() => ( message = reader.read() => message.context("failed to read code-mode client message")?,
message.context("failed to read code-mode client control message")?,
TransportLane::Control,
),
message = async {
match &mut bulk_reader {
Some(reader) => reader.read().await,
None => Ok(None),
}
}, if bulk_reader.is_some() => (
message.context("failed to read code-mode client bulk message")?,
TransportLane::Bulk,
),
}; };
let Some(message) = message else { let Some(message) = message else {
break; break;
}; };
if bulk_reader.is_some() && !message.allows_transport_lane(lane) {
anyhow::bail!("code-mode client sent a message on the wrong websocket lane");
}
match message { match message {
ClientToHost::ClientHello(_) => { ClientToHost::ClientHello(_) => {
anyhow::bail!("received a second code-mode client hello"); anyhow::bail!("received a second code-mode client hello");
@@ -255,8 +185,8 @@ async fn run_connection(
Ok(()) Ok(())
} }
async fn drive_writer( async fn drive_writer<W: AsyncWrite + Unpin>(
mut writer: ConnectionWriter, mut writer: FramedWriter<W>,
mut outgoing: mpsc::Receiver<EncodedFrame>, mut outgoing: mpsc::Receiver<EncodedFrame>,
disconnected: CancellationToken, disconnected: CancellationToken,
) -> Result<()> { ) -> Result<()> {
@@ -269,7 +199,7 @@ async fn drive_writer(
}; };
tokio::select! { tokio::select! {
_ = disconnected.cancelled() => return Ok(()), _ = disconnected.cancelled() => return Ok(()),
result = writer.write_frame(frame) => { result = writer.write_frame(&frame) => {
result.context("failed to write code-mode host message")?; result.context("failed to write code-mode host message")?;
} }
} }
@@ -278,10 +208,9 @@ async fn drive_writer(
} }
} }
async fn negotiate( async fn negotiate<R: AsyncRead + Unpin, W: AsyncWrite + Unpin>(
reader: &mut ConnectionReader, reader: &mut FramedReader<R>,
writer: &mut ConnectionWriter, writer: &mut FramedWriter<W>,
bulk_connections: Option<&BulkConnectionRegistry>,
) -> Result<NegotiatedConnection> { ) -> Result<NegotiatedConnection> {
let Some(first_message) = reader let Some(first_message) = reader
.read() .read()
@@ -316,18 +245,6 @@ async fn negotiate(
return Ok(NegotiatedConnection::Rejected); return Ok(NegotiatedConnection::Rejected);
} }
let dual_capability = Capability::new(DUAL_WEBSOCKET_CAPABILITY)?;
let dual_requested = client_hello
.required_capabilities()
.contains(&dual_capability)
|| client_hello
.optional_capabilities()
.contains(&dual_capability);
let registration = if dual_requested {
bulk_connections.and_then(BulkConnectionRegistry::reserve)
} else {
None
};
let resource_limits_capability = Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY)?; let resource_limits_capability = Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY)?;
let resource_limits_requested = client_hello let resource_limits_requested = client_hello
.required_capabilities() .required_capabilities()
@@ -335,14 +252,8 @@ async fn negotiate(
|| client_hello || client_hello
.optional_capabilities() .optional_capabilities()
.contains(&resource_limits_capability); .contains(&resource_limits_capability);
let host_capabilities = CapabilitySet::try_new( let host_capabilities =
[ CapabilitySet::try_new(resource_limits_requested.then_some(resource_limits_capability))?;
registration.is_some().then_some(dual_capability),
resource_limits_requested.then_some(resource_limits_capability),
]
.into_iter()
.flatten(),
)?;
if let Some(capability) = client_hello if let Some(capability) = client_hello
.required_capabilities() .required_capabilities()
.iter() .iter()
@@ -359,23 +270,12 @@ async fn negotiate(
return Ok(NegotiatedConnection::Rejected); return Ok(NegotiatedConnection::Rejected);
} }
let (hello, negotiated) = if let Some(registration) = registration { let hello = HostHello::new(ProtocolVersion::V1, host_capabilities);
(
HostHello::new(ProtocolVersion::V1, host_capabilities)
.with_bulk_connection_token(registration.token().to_string()),
NegotiatedConnection::Dual(registration),
)
} else {
(
HostHello::new(ProtocolVersion::V1, host_capabilities),
NegotiatedConnection::Single,
)
};
writer writer
.write(&HostToClient::HostHello(hello)) .write(&HostToClient::HostHello(hello))
.await .await
.context("failed to write code-mode host hello")?; .context("failed to write code-mode host hello")?;
Ok(negotiated) Ok(NegotiatedConnection::Accepted)
} }
struct HostState { struct HostState {
+1 -1
View File
@@ -2,7 +2,7 @@ use clap::Parser;
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
struct Cli { struct Cli {
/// Transport endpoint: `stdio`, `stdio://`, `ws://IP:PORT`, or `grpc://IP:PORT`. /// Transport endpoint: `stdio`, `stdio://`, or `grpc://IP:PORT`.
#[arg( #[arg(
long, long,
value_name = "URL", value_name = "URL",
+3 -16
View File
@@ -16,7 +16,6 @@ use codex_code_mode_protocol::host::HostToClient;
use codex_code_mode_protocol::host::MAX_PENDING_DELEGATE_CALLS; use codex_code_mode_protocol::host::MAX_PENDING_DELEGATE_CALLS;
use codex_code_mode_protocol::host::RequestId; use codex_code_mode_protocol::host::RequestId;
use codex_code_mode_protocol::host::SessionId; use codex_code_mode_protocol::host::SessionId;
use codex_code_mode_protocol::host::TransportLane;
use codex_code_mode_protocol::host::WireResult; use codex_code_mode_protocol::host::WireResult;
use tokio::sync::Mutex; use tokio::sync::Mutex;
use tokio::sync::Notify; use tokio::sync::Notify;
@@ -30,7 +29,6 @@ const CELL_MESSAGE_CAPACITY: usize = 128;
pub(super) struct HostPeer { pub(super) struct HostPeer {
outgoing_tx: mpsc::Sender<EncodedFrame>, outgoing_tx: mpsc::Sender<EncodedFrame>,
bulk_tx: Option<mpsc::Sender<EncodedFrame>>,
pending: Mutex<HashMap<DelegateRequestId, PendingDelegate>>, pending: Mutex<HashMap<DelegateRequestId, PendingDelegate>>,
delegate_permits: Arc<Semaphore>, delegate_permits: Arc<Semaphore>,
cell_routes: StdMutex<HashMap<(SessionId, CellId), CellRoute>>, cell_routes: StdMutex<HashMap<(SessionId, CellId), CellRoute>>,
@@ -64,7 +62,6 @@ impl HostPeer {
pub(super) fn new(outgoing_tx: mpsc::Sender<EncodedFrame>) -> Self { pub(super) fn new(outgoing_tx: mpsc::Sender<EncodedFrame>) -> Self {
Self { Self {
outgoing_tx, outgoing_tx,
bulk_tx: None,
pending: Mutex::new(HashMap::new()), pending: Mutex::new(HashMap::new()),
delegate_permits: Arc::new(Semaphore::new(MAX_PENDING_DELEGATE_CALLS)), delegate_permits: Arc::new(Semaphore::new(MAX_PENDING_DELEGATE_CALLS)),
cell_routes: StdMutex::new(HashMap::new()), cell_routes: StdMutex::new(HashMap::new()),
@@ -75,16 +72,10 @@ impl HostPeer {
} }
} }
pub(super) fn with_bulk_sender(mut self, bulk_tx: mpsc::Sender<EncodedFrame>) -> Self {
self.bulk_tx = Some(bulk_tx);
self
}
pub(super) fn send(&self, message: HostToClient) -> Result<(), PeerSendError> { pub(super) fn send(&self, message: HostToClient) -> Result<(), PeerSendError> {
let frame = EncodedFrame::encode(&message) let frame = EncodedFrame::encode(&message)
.map_err(|err| PeerSendError::Payload(err.to_string()))?; .map_err(|err| PeerSendError::Payload(err.to_string()))?;
let lane = message.transport_lane(); self.send_frame(frame)
self.send_frame(frame, lane)
} }
pub(super) fn respond( pub(super) fn respond(
@@ -398,12 +389,8 @@ impl HostPeer {
}); });
} }
fn send_frame(&self, frame: EncodedFrame, lane: TransportLane) -> Result<(), PeerSendError> { fn send_frame(&self, frame: EncodedFrame) -> Result<(), PeerSendError> {
let sender = match lane { match self.outgoing_tx.try_send(frame) {
TransportLane::Control => &self.outgoing_tx,
TransportLane::Bulk => self.bulk_tx.as_ref().unwrap_or(&self.outgoing_tx),
};
match sender.try_send(frame) {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(mpsc::error::TrySendError::Full(_)) => { Err(mpsc::error::TrySendError::Full(_)) => {
self.disconnect(); self.disconnect();
+1 -318
View File
@@ -1,211 +1,22 @@
use std::collections::HashMap;
use std::io;
use std::io::Write as _;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
use anyhow::Context; use anyhow::Context;
use anyhow::Result; use anyhow::Result;
use axum::Router;
use axum::body::Body;
use axum::extract::ConnectInfo;
use axum::extract::Path;
use axum::extract::State;
use axum::extract::ws::Message;
use axum::extract::ws::WebSocket;
use axum::extract::ws::WebSocketUpgrade;
use axum::http::Request;
use axum::http::StatusCode;
use axum::http::header::ORIGIN;
use axum::middleware;
use axum::middleware::Next;
use axum::response::IntoResponse;
use axum::response::Response;
use axum::routing::any;
use axum::routing::get;
use axum::serve::Listener;
use axum::serve::ListenerExt;
use axum::serve::TapIo;
use codex_code_mode_protocol::host::ClientToHost;
use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::FramedReader;
use codex_code_mode_protocol::host::FramedWriter;
use codex_code_mode_protocol::host::HostToClient;
use codex_code_mode_protocol::host::MAX_FRAME_BYTES;
use futures::SinkExt;
use futures::StreamExt;
use futures::stream::SplitSink;
use futures::stream::SplitStream;
use tokio::io::AsyncRead;
use tokio::io::AsyncWrite;
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::sync::oneshot;
use tracing::info;
use tracing::warn;
use uuid::Uuid;
use crate::HostLimits;
use crate::MAX_IN_FLIGHT_REQUESTS;
use crate::grpc_transport; use crate::grpc_transport;
/// The default transport retains the standalone host's original stdio behavior. /// The default transport retains the standalone host's original stdio behavior.
pub const DEFAULT_LISTEN_URL: &str = "stdio"; pub const DEFAULT_LISTEN_URL: &str = "stdio";
const MAX_WEBSOCKET_FRAME_BYTES: usize = MAX_FRAME_BYTES + std::mem::size_of::<u32>();
const MAX_PENDING_BULK_CONNECTIONS: usize = MAX_IN_FLIGHT_REQUESTS;
type BoxedReader = Box<dyn AsyncRead + Send + Unpin>;
type BoxedWriter = Box<dyn AsyncWrite + Send + Unpin>;
#[derive(Debug, Clone, Eq, PartialEq)] #[derive(Debug, Clone, Eq, PartialEq)]
enum ListenTransport { enum ListenTransport {
Stdio, Stdio,
WebSocket(SocketAddr),
Grpc(SocketAddr), Grpc(SocketAddr),
} }
pub(crate) enum ConnectionReader {
Framed(FramedReader<BoxedReader>),
WebSocket(SplitStream<WebSocket>),
}
pub(crate) enum ConnectionWriter {
Framed(FramedWriter<BoxedWriter>),
WebSocket(SplitSink<WebSocket, Message>),
}
#[derive(Clone)]
struct WebSocketListenerState {
limits: Arc<HostLimits>,
bulk_connections: BulkConnectionRegistry,
}
pub(crate) struct BulkConnection {
pub(crate) reader: ConnectionReader,
pub(crate) writer: ConnectionWriter,
}
#[derive(Clone, Default)]
pub(crate) struct BulkConnectionRegistry {
pending: Arc<Mutex<HashMap<Uuid, oneshot::Sender<BulkConnection>>>>,
}
pub(crate) struct BulkConnectionRegistration {
registry: BulkConnectionRegistry,
token: Uuid,
receiver: oneshot::Receiver<BulkConnection>,
}
impl BulkConnectionRegistry {
pub(crate) fn reserve(&self) -> Option<BulkConnectionRegistration> {
let mut pending = self.pending.lock().unwrap_or_else(PoisonError::into_inner);
if pending.len() >= MAX_PENDING_BULK_CONNECTIONS {
return None;
}
let token = Uuid::new_v4();
let (sender, receiver) = oneshot::channel();
pending.insert(token, sender);
Some(BulkConnectionRegistration {
registry: self.clone(),
token,
receiver,
})
}
pub(crate) fn remove(&self, token: Uuid) -> Option<oneshot::Sender<BulkConnection>> {
self.pending
.lock()
.unwrap_or_else(PoisonError::into_inner)
.remove(&token)
}
}
impl BulkConnectionRegistration {
pub(crate) fn token(&self) -> Uuid {
self.token
}
pub(crate) async fn receive(&mut self) -> Result<BulkConnection, oneshot::error::RecvError> {
(&mut self.receiver).await
}
}
impl Drop for BulkConnectionRegistration {
fn drop(&mut self) {
self.registry.remove(self.token);
}
}
impl ConnectionReader {
pub(crate) fn from_reader<R>(reader: R) -> Self
where
R: AsyncRead + Send + Unpin + 'static,
{
Self::Framed(FramedReader::new(Box::new(reader)))
}
pub(crate) async fn read(&mut self) -> io::Result<Option<ClientToHost>> {
match self {
Self::Framed(reader) => reader.read().await,
Self::WebSocket(reader) => loop {
match reader.next().await {
Some(Ok(Message::Binary(bytes))) => {
return EncodedFrame::decode_framed(&bytes).map(Some);
}
Some(Ok(Message::Text(_))) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"code-mode websocket messages must be binary framed messages",
));
}
Some(Ok(Message::Ping(_) | Message::Pong(_))) => {}
Some(Ok(Message::Close(_))) | None => return Ok(None),
Some(Err(err)) => {
return Err(io::Error::other(format!(
"failed to read code-mode websocket message: {err}"
)));
}
}
},
}
}
}
impl ConnectionWriter {
pub(crate) fn from_writer<W>(writer: W) -> Self
where
W: AsyncWrite + Send + Unpin + 'static,
{
Self::Framed(FramedWriter::new(Box::new(writer)))
}
pub(crate) async fn write(&mut self, message: &HostToClient) -> io::Result<()> {
self.write_frame(EncodedFrame::encode(message)?).await
}
pub(crate) async fn write_frame(&mut self, frame: EncodedFrame) -> io::Result<()> {
match self {
Self::Framed(writer) => writer.write_frame(&frame).await,
Self::WebSocket(writer) => writer
.send(Message::Binary(frame.into_framed_bytes().into()))
.await
.map_err(|err| {
io::Error::other(format!(
"failed to write code-mode websocket message: {err}"
))
}),
}
}
}
pub(crate) async fn run_transport(listen_url: &str) -> Result<()> { pub(crate) async fn run_transport(listen_url: &str) -> Result<()> {
match parse_listen_url(listen_url)? { match parse_listen_url(listen_url)? {
ListenTransport::Stdio => crate::run_stdio().await, ListenTransport::Stdio => crate::run_stdio().await,
ListenTransport::WebSocket(bind_address) => run_websocket_listener(bind_address).await,
ListenTransport::Grpc(bind_address) => grpc_transport::run_tcp_listener(bind_address).await, ListenTransport::Grpc(bind_address) => grpc_transport::run_tcp_listener(bind_address).await,
} }
} }
@@ -215,15 +26,6 @@ fn parse_listen_url(listen_url: &str) -> Result<ListenTransport> {
return Ok(ListenTransport::Stdio); return Ok(ListenTransport::Stdio);
} }
if let Some(socket_addr) = listen_url.strip_prefix("ws://") {
return socket_addr
.parse::<SocketAddr>()
.map(ListenTransport::WebSocket)
.with_context(|| {
format!("invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`")
});
}
if let Some(socket_addr) = listen_url.strip_prefix("grpc://") { if let Some(socket_addr) = listen_url.strip_prefix("grpc://") {
return socket_addr return socket_addr
.parse::<SocketAddr>() .parse::<SocketAddr>()
@@ -234,129 +36,10 @@ fn parse_listen_url(listen_url: &str) -> Result<ListenTransport> {
} }
anyhow::bail!( anyhow::bail!(
"unsupported --listen URL `{listen_url}`; expected `ws://IP:PORT`, `grpc://IP:PORT`, `stdio`, or `stdio://`" "unsupported --listen URL `{listen_url}`; expected `grpc://IP:PORT`, `stdio`, or `stdio://`"
); );
} }
async fn bind_websocket_listener(
bind_address: SocketAddr,
) -> Result<TapIo<TcpListener, impl FnMut(&mut TcpStream) + Send + 'static>> {
let listener = TcpListener::bind(bind_address)
.await
.with_context(|| format!("failed to bind code-mode host websocket to {bind_address}"))?;
Ok(listener.tap_io(|stream| {
if let Err(error) = stream.set_nodelay(/*nodelay*/ true) {
warn!(%error, "failed to enable TCP_NODELAY for code-mode host connection");
}
}))
}
async fn run_websocket_listener(bind_address: SocketAddr) -> Result<()> {
let listener = bind_websocket_listener(bind_address).await?;
let local_addr = listener
.local_addr()
.context("failed to read code-mode host websocket listen address")?;
let state = WebSocketListenerState {
limits: Arc::new(HostLimits::new()),
bulk_connections: BulkConnectionRegistry::default(),
};
info!("codex-code-mode-host listening on ws://{local_addr}");
println!("ws://{local_addr}");
io::stdout()
.flush()
.context("failed to publish code-mode host websocket listen address")?;
let router = Router::new()
.route("/", any(websocket_upgrade_handler))
.route("/bulk/{token}", any(bulk_websocket_upgrade_handler))
.route("/readyz", get(readiness_handler))
.layer(middleware::from_fn(reject_requests_with_origin_header))
.with_state(state);
axum::serve(
listener,
router.into_make_service_with_connect_info::<SocketAddr>(),
)
.await
.context("code-mode host websocket listener failed")
}
async fn readiness_handler() -> StatusCode {
StatusCode::OK
}
async fn reject_requests_with_origin_header(
request: Request<Body>,
next: Next,
) -> Result<Response, StatusCode> {
if request.headers().contains_key(ORIGIN) {
warn!(
method = %request.method(),
uri = %request.uri(),
"rejecting code-mode host websocket request with Origin header"
);
Err(StatusCode::FORBIDDEN)
} else {
Ok(next.run(request).await)
}
}
async fn websocket_upgrade_handler(
websocket: WebSocketUpgrade,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
State(state): State<WebSocketListenerState>,
) -> impl IntoResponse {
websocket
.max_frame_size(MAX_WEBSOCKET_FRAME_BYTES)
.max_message_size(MAX_WEBSOCKET_FRAME_BYTES)
.on_upgrade(move |stream| async move {
info!(%peer_addr, "code-mode host websocket client connected");
let (writer, reader) = stream.split();
if let Err(err) = crate::run_connection(
ConnectionReader::WebSocket(reader),
ConnectionWriter::WebSocket(writer),
state.limits,
Some(state.bulk_connections),
)
.await
{
warn!(%peer_addr, "code-mode host session failed: {err:#}");
}
})
}
async fn bulk_websocket_upgrade_handler(
websocket: WebSocketUpgrade,
Path(token): Path<String>,
ConnectInfo(peer_addr): ConnectInfo<SocketAddr>,
State(state): State<WebSocketListenerState>,
) -> Response {
let Ok(token) = Uuid::parse_str(&token) else {
return StatusCode::NOT_FOUND.into_response();
};
let Some(pending) = state.bulk_connections.remove(token) else {
return StatusCode::NOT_FOUND.into_response();
};
websocket
.max_frame_size(MAX_WEBSOCKET_FRAME_BYTES)
.max_message_size(MAX_WEBSOCKET_FRAME_BYTES)
.on_upgrade(move |stream| async move {
info!(%peer_addr, "code-mode host bulk websocket client connected");
let (writer, reader) = stream.split();
if pending
.send(BulkConnection {
reader: ConnectionReader::WebSocket(reader),
writer: ConnectionWriter::WebSocket(writer),
})
.is_err()
{
warn!(%peer_addr, "code-mode host bulk websocket pairing expired");
}
})
.into_response()
}
#[cfg(test)] #[cfg(test)]
#[path = "transport_tests.rs"] #[path = "transport_tests.rs"]
mod tests; mod tests;
+3 -75
View File
@@ -1,40 +1,11 @@
use std::net::SocketAddr;
use axum::serve::Listener;
use futures::StreamExt; use futures::StreamExt;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use super::BulkConnectionRegistry;
use super::ListenTransport; use super::ListenTransport;
use super::MAX_PENDING_BULK_CONNECTIONS;
use super::bind_websocket_listener;
use super::parse_listen_url; use super::parse_listen_url;
use crate::grpc_transport::bind_tcp_listener; use crate::grpc_transport::bind_tcp_listener;
#[tokio::test]
async fn websocket_listener_disables_nagle() {
let bind_address = "127.0.0.1:0"
.parse()
.expect("websocket test listener should have a valid bind address");
let mut listener = bind_websocket_listener(bind_address)
.await
.expect("websocket test listener should bind");
let local_addr = listener
.local_addr()
.expect("websocket test listener should have a local address");
let _client = TcpStream::connect(local_addr)
.await
.expect("websocket test client should connect");
let (stream, _) = listener.accept().await;
let stream = stream
.nodelay()
.expect("accepted websocket socket should expose TCP_NODELAY");
assert!(stream);
}
#[tokio::test] #[tokio::test]
async fn grpc_listener_disables_nagle() { async fn grpc_listener_disables_nagle() {
let bind_address = "127.0.0.1:0" let bind_address = "127.0.0.1:0"
@@ -60,29 +31,6 @@ async fn grpc_listener_disables_nagle() {
assert!(nodelay); assert!(nodelay);
} }
#[test]
fn bulk_connection_registration_cleans_up_when_dropped() {
let registry = BulkConnectionRegistry::default();
let registration = registry.reserve().expect("bulk connection registration");
let token = registration.token();
drop(registration);
assert!(registry.remove(token).is_none());
}
#[test]
fn bulk_connection_registrations_are_bounded_and_released() {
let registry = BulkConnectionRegistry::default();
let mut registrations = (0..MAX_PENDING_BULK_CONNECTIONS)
.map(|_| registry.reserve().expect("bulk connection registration"))
.collect::<Vec<_>>();
assert!(registry.reserve().is_none());
drop(registrations.pop());
assert!(registry.reserve().is_some());
}
#[test] #[test]
fn parse_listen_url_accepts_stdio_transports() { fn parse_listen_url_accepts_stdio_transports() {
assert_eq!( assert_eq!(
@@ -95,34 +43,14 @@ fn parse_listen_url_accepts_stdio_transports() {
); );
} }
#[test]
fn parse_listen_url_accepts_websocket_addresses() {
assert_eq!(
parse_listen_url("ws://127.0.0.1:0").expect("websocket listen URL should parse"),
ListenTransport::WebSocket(
"127.0.0.1:0"
.parse::<SocketAddr>()
.expect("valid socket address")
)
);
assert_eq!(
parse_listen_url("ws://[::1]:9000").expect("IPv6 websocket listen URL should parse"),
ListenTransport::WebSocket(
"[::1]:9000"
.parse::<SocketAddr>()
.expect("valid IPv6 socket address")
)
);
}
#[test] #[test]
fn parse_listen_url_rejects_invalid_transports() { fn parse_listen_url_rejects_invalid_transports() {
let invalid_address = parse_listen_url("ws://localhost:9000") let invalid_address = parse_listen_url("grpc://localhost:9000")
.expect_err("websocket listener requires an IP address"); .expect_err("gRPC listener requires an IP address");
assert!( assert!(
invalid_address invalid_address
.to_string() .to_string()
.contains("expected `ws://IP:PORT`") .contains("expected `grpc://IP:PORT`")
); );
let unsupported = let unsupported =
-842
View File
@@ -1,842 +0,0 @@
use std::sync::Arc;
use std::time::Duration;
use anyhow::Context;
use anyhow::Result;
use codex_code_mode::CellId;
use codex_code_mode::CodeModeSessionCellExecutionLimits;
use codex_code_mode::CodeModeSessionProvider;
use codex_code_mode::CodeModeToolKind;
use codex_code_mode::ExecuteRequest;
use codex_code_mode::FunctionCallOutputContentItem;
use codex_code_mode::NoopCodeModeSessionDelegate;
use codex_code_mode::RuntimeResponse;
use codex_code_mode::ToolDefinition;
use codex_code_mode::WebSocketCodeModeSessionProvider;
use codex_code_mode_protocol::host::Capability;
use codex_code_mode_protocol::host::CapabilitySet;
use codex_code_mode_protocol::host::ClientHello;
use codex_code_mode_protocol::host::ClientToHost;
use codex_code_mode_protocol::host::DUAL_WEBSOCKET_CAPABILITY;
use codex_code_mode_protocol::host::DelegateRequest;
use codex_code_mode_protocol::host::DelegateResponse;
use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::HostHello;
use codex_code_mode_protocol::host::HostRequest;
use codex_code_mode_protocol::host::HostResponse;
use codex_code_mode_protocol::host::HostToClient;
use codex_code_mode_protocol::host::MAX_FRAME_BYTES;
use codex_code_mode_protocol::host::ProtocolVersion;
use codex_code_mode_protocol::host::RequestId;
use codex_code_mode_protocol::host::SESSION_RESOURCE_LIMITS_CAPABILITY;
use codex_code_mode_protocol::host::SessionId;
use codex_code_mode_protocol::host::SupportedProtocolVersions;
use codex_code_mode_protocol::host::WireContentItem;
use codex_code_mode_protocol::host::WireExecuteRequest;
use codex_code_mode_protocol::host::WireResult;
use codex_code_mode_protocol::host::WireRuntimeResponse;
use codex_code_mode_protocol::host::WireToolDefinition;
use codex_code_mode_protocol::host::WireToolKind;
use codex_code_mode_protocol::host::WireToolName;
use codex_code_mode_protocol::host::WireWaitRequest;
use codex_protocol::ToolName;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::net::TcpStream;
use tokio::sync::Semaphore;
use tokio::time::timeout;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::connect_async_with_config;
use tokio_tungstenite::tungstenite::Error as WebSocketError;
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use tokio_tungstenite::tungstenite::http::StatusCode;
use tokio_tungstenite::tungstenite::http::header::ORIGIN;
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use uuid::Uuid;
#[path = "support/host.rs"]
mod host;
#[path = "support/large_tool_delegate.rs"]
mod large_tool_delegate;
use host::HostHarness;
use large_tool_delegate::LargeToolResultDelegate;
const TEST_TIMEOUT: Duration = Duration::from_secs(10);
const MAX_WEBSOCKET_FRAME_BYTES: usize = MAX_FRAME_BYTES + std::mem::size_of::<u32>();
struct HostClient {
websocket: WebSocketStream<MaybeTlsStream<TcpStream>>,
}
impl HostHarness {
async fn connect(&self) -> Result<HostClient> {
let config = WebSocketConfig::default()
.max_frame_size(Some(MAX_WEBSOCKET_FRAME_BYTES))
.max_message_size(Some(MAX_WEBSOCKET_FRAME_BYTES));
let (websocket, _) = timeout(
TEST_TIMEOUT,
connect_async_with_config(
self.endpoint.as_str(),
Some(config),
/*disable_nagle*/ false,
),
)
.await
.context("timed out connecting to code-mode host websocket")??;
Ok(HostClient { websocket })
}
}
impl HostClient {
async fn send(&mut self, message: &ClientToHost) -> Result<()> {
let frame = EncodedFrame::encode(message)?;
self.send_binary(frame.into_framed_bytes()).await
}
async fn send_binary(&mut self, bytes: Vec<u8>) -> Result<()> {
timeout(
TEST_TIMEOUT,
self.websocket.send(Message::Binary(bytes.into())),
)
.await
.context("timed out writing code-mode websocket message")?
.context("failed to write code-mode websocket message")
}
async fn read(&mut self) -> Result<HostToClient> {
loop {
let message = timeout(TEST_TIMEOUT, self.websocket.next())
.await
.context("timed out waiting for code-mode websocket message")?
.context("code-mode websocket closed before returning a message")?
.context("failed to read code-mode websocket message")?;
match message {
Message::Binary(bytes) => {
return EncodedFrame::decode_framed(&bytes)
.context("failed to decode code-mode websocket frame");
}
Message::Ping(_) | Message::Pong(_) => {}
Message::Close(frame) => {
anyhow::bail!("code-mode websocket closed unexpectedly: {frame:?}");
}
Message::Text(text) => {
anyhow::bail!("code-mode host returned a text websocket message: {text}");
}
Message::Frame(_) => {
anyhow::bail!("code-mode host returned an unexpected raw websocket frame");
}
}
}
}
async fn negotiate(&mut self, optional_capabilities: CapabilitySet) -> Result<()> {
let hello = ClientHello::new(
SupportedProtocolVersions::try_new([ProtocolVersion::V1])?,
CapabilitySet::empty(),
optional_capabilities,
)?;
self.send(&ClientToHost::ClientHello(hello)).await?;
assert_eq!(
self.read().await?,
HostToClient::HostHello(HostHello::new(ProtocolVersion::V1, CapabilitySet::empty()))
);
Ok(())
}
async fn negotiate_dual(&mut self, websocket_url: &str) -> Result<HostClient> {
let capability = Capability::new(DUAL_WEBSOCKET_CAPABILITY)?;
let resource_limits = Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY)?;
let hello = ClientHello::new(
SupportedProtocolVersions::try_new([ProtocolVersion::V1])?,
CapabilitySet::empty(),
CapabilitySet::try_new([capability.clone(), resource_limits.clone()])?,
)?;
self.send(&ClientToHost::ClientHello(hello)).await?;
let HostToClient::HostHello(hello) = self.read().await? else {
anyhow::bail!("expected code-mode host hello");
};
assert!(hello.capabilities().contains(&capability));
assert!(hello.capabilities().contains(&resource_limits));
let token = hello
.bulk_connection_token()
.context("dual websocket handshake omitted its pairing token")?;
let bulk_url = format!("{}/bulk/{token}", websocket_url.trim_end_matches('/'));
let config = WebSocketConfig::default()
.max_frame_size(Some(MAX_WEBSOCKET_FRAME_BYTES))
.max_message_size(Some(MAX_WEBSOCKET_FRAME_BYTES));
let (websocket, _) = timeout(
TEST_TIMEOUT,
connect_async_with_config(bulk_url, Some(config), /*disable_nagle*/ false),
)
.await
.context("timed out connecting to code-mode host bulk websocket")??;
Ok(HostClient { websocket })
}
async fn open_session(&mut self, session_id: SessionId) -> Result<()> {
let id = RequestId::new(/*value*/ 1);
self.send(&ClientToHost::Request {
id,
request: HostRequest::OpenSession {
session_id: session_id.clone(),
cell_execution_limits: None,
},
})
.await?;
assert_eq!(
self.read().await?,
HostToClient::Response {
id,
result: WireResult::Ok {
value: HostResponse::SessionReady { session_id },
},
}
);
Ok(())
}
}
#[tokio::test]
async fn websocket_listener_serves_readiness_endpoint() -> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let address = host
.endpoint
.strip_prefix("ws://")
.context("code-mode host websocket URL should use ws://")?;
let response = timeout(TEST_TIMEOUT, async {
let mut stream = TcpStream::connect(address)
.await
.context("failed to connect to code-mode host readiness endpoint")?;
let request =
format!("GET /readyz HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n\r\n");
stream
.write_all(request.as_bytes())
.await
.context("failed to request code-mode host readiness")?;
let mut response = String::new();
stream
.read_to_string(&mut response)
.await
.context("failed to read code-mode host readiness response")?;
Ok::<_, anyhow::Error>(response)
})
.await
.context("timed out requesting code-mode host readiness")??;
let status_line = response
.lines()
.next()
.context("code-mode host readiness response is missing a status line")?;
assert_eq!(status_line, "HTTP/1.1 200 OK");
Ok(())
}
#[tokio::test]
async fn websocket_listener_executes_cells_and_forwards_tool_callbacks() -> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let mut client = host.connect().await?;
client.negotiate(CapabilitySet::empty()).await?;
let session_id = SessionId::new("websocket-session")?;
client.open_session(session_id.clone()).await?;
let execute_id = RequestId::new(/*value*/ 2);
client
.send(&ClientToHost::Request {
id: execute_id,
request: HostRequest::Execute {
session_id: session_id.clone(),
request: WireExecuteRequest {
tool_call_id: "websocket-call".to_string(),
enabled_tools: vec![WireToolDefinition {
name: "echo".to_string(),
tool_name: WireToolName {
name: "echo".to_string(),
namespace: None,
},
description: String::new(),
kind: WireToolKind::Function,
input_schema: None,
output_schema: None,
}],
source:
r#"const result = await tools.echo({ value: "ping" }); text(result.value);"#
.to_string(),
yield_time_ms: Some(5_000),
max_output_tokens: Some(1_000),
},
},
})
.await?;
let started = client.read().await?;
let HostToClient::Response {
id,
result:
WireResult::Ok {
value: HostResponse::ExecutionStarted { cell_id },
},
} = started
else {
anyhow::bail!("expected execution-started response, got {started:?}");
};
assert_eq!(id, execute_id);
let callback = client.read().await?;
let HostToClient::DelegateRequest {
id: delegate_id,
session_id: callback_session_id,
request: DelegateRequest::InvokeTool { invocation },
} = callback
else {
anyhow::bail!("expected tool callback, got {callback:?}");
};
assert_eq!(callback_session_id, session_id);
assert_eq!(invocation.input, Some(json!({ "value": "ping" })));
client
.send(&ClientToHost::DelegateResponse {
id: delegate_id,
result: WireResult::Ok {
value: DelegateResponse::ToolResult {
result: json!({ "value": "pong" }),
},
},
})
.await?;
assert_eq!(
client.read().await?,
HostToClient::InitialResponse {
id: execute_id,
result: WireResult::Ok {
value: WireRuntimeResponse::Result {
cell_id,
content_items: vec![WireContentItem::InputText {
text: "pong".to_string(),
}],
error_text: None,
},
},
}
);
Ok(())
}
#[tokio::test]
async fn production_websocket_client_runs_nested_tools_while_other_sessions_progress() -> Result<()>
{
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let provider = WebSocketCodeModeSessionProvider::new(host.endpoint.clone());
let delegate = Arc::new(LargeToolResultDelegate {
started: Semaphore::new(/*permits*/ 0),
release: Semaphore::new(/*permits*/ 0),
});
let slow_session = provider
.create_session(delegate.clone())
.await
.map_err(anyhow::Error::msg)?;
let fast_session = provider
.create_session_with_limits(
Arc::new(NoopCodeModeSessionDelegate),
CodeModeSessionCellExecutionLimits {
max_yield_time_ms: Some(5_000),
max_heap_size_bytes: None,
},
)
.await
.map_err(anyhow::Error::msg)?;
let slow_cell = slow_session
.execute(ExecuteRequest {
tool_call_id: "large-tool".to_string(),
enabled_tools: vec![ToolDefinition {
name: "large".to_string(),
tool_name: ToolName::plain("large"),
description: String::new(),
kind: CodeModeToolKind::Function,
input_schema: None,
output_schema: None,
}],
source: r#"const result = await tools.large({ value: "request" }); text(String(result.value.length));"#
.to_string(),
yield_time_ms: Some(20_000),
max_output_tokens: Some(1_000),
})
.await
.map_err(anyhow::Error::msg)?;
let started = timeout(TEST_TIMEOUT, delegate.started.acquire())
.await
.context("large tool callback did not start")??;
started.forget();
let fast_response = timeout(TEST_TIMEOUT, async {
fast_session
.execute(ExecuteRequest {
tool_call_id: "fast-before-transfer".to_string(),
enabled_tools: Vec::new(),
source: r#"text("fast-before");"#.to_string(),
yield_time_ms: Some(5_000),
max_output_tokens: Some(1_000),
})
.await
.map_err(anyhow::Error::msg)?
.initial_response()
.await
.map_err(anyhow::Error::msg)
})
.await
.context("unrelated execution was blocked by the pending tool callback")??;
assert_eq!(
fast_response,
RuntimeResponse::Result {
cell_id: CellId::new("1".to_string()),
content_items: vec![FunctionCallOutputContentItem::InputText {
text: "fast-before".to_string(),
}],
error_text: None,
}
);
delegate.release.add_permits(1);
let slow_response = async {
timeout(TEST_TIMEOUT, slow_cell.initial_response())
.await
.context("large tool result did not finish")?
.map_err(anyhow::Error::msg)
};
let concurrent_response = async {
timeout(TEST_TIMEOUT, async {
fast_session
.execute(ExecuteRequest {
tool_call_id: "fast-during-transfer".to_string(),
enabled_tools: Vec::new(),
source: r#"text("fast-during");"#.to_string(),
yield_time_ms: Some(5_000),
max_output_tokens: Some(1_000),
})
.await
.map_err(anyhow::Error::msg)?
.initial_response()
.await
.map_err(anyhow::Error::msg)
})
.await
.context("unrelated execution was blocked by the large tool transfer")?
};
tokio::pin!(slow_response);
tokio::pin!(concurrent_response);
let concurrent_response = tokio::select! {
response = &mut concurrent_response => response?,
response = &mut slow_response => {
response?;
anyhow::bail!("large tool result completed before the unrelated control response");
}
};
let slow_response = slow_response.await?;
assert_eq!(
slow_response,
RuntimeResponse::Result {
cell_id: CellId::new("1".to_string()),
content_items: vec![FunctionCallOutputContentItem::InputText {
text: "8388608".to_string(),
}],
error_text: None,
}
);
assert_eq!(
concurrent_response,
RuntimeResponse::Result {
cell_id: CellId::new("2".to_string()),
content_items: vec![FunctionCallOutputContentItem::InputText {
text: "fast-during".to_string(),
}],
error_text: None,
}
);
slow_session.shutdown().await.map_err(anyhow::Error::msg)?;
fast_session.shutdown().await.map_err(anyhow::Error::msg)?;
Ok(())
}
#[tokio::test]
async fn websocket_dual_connections_route_notifications_and_tool_callbacks_to_separate_lanes()
-> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let mut control = host.connect().await?;
let mut bulk = control.negotiate_dual(&host.endpoint).await?;
let session_id = SessionId::new("dual-websocket-session")?;
control.open_session(session_id.clone()).await?;
let execute_id = RequestId::new(/*value*/ 2);
control
.send(&ClientToHost::Request {
id: execute_id,
request: HostRequest::Execute {
session_id: session_id.clone(),
request: WireExecuteRequest {
tool_call_id: "dual-websocket-call".to_string(),
enabled_tools: vec![WireToolDefinition {
name: "echo".to_string(),
tool_name: WireToolName {
name: "echo".to_string(),
namespace: None,
},
description: String::new(),
kind: WireToolKind::Function,
input_schema: None,
output_schema: None,
}],
source: r#"notify("important"); const result = await tools.echo({ value: "ping" }); text(result.value);"#
.to_string(),
yield_time_ms: Some(5_000),
max_output_tokens: Some(1_000),
},
},
})
.await?;
let started = control.read().await?;
let HostToClient::Response {
id,
result:
WireResult::Ok {
value: HostResponse::ExecutionStarted { cell_id },
},
} = started
else {
anyhow::bail!("expected execution-started response on control lane, got {started:?}");
};
assert_eq!(id, execute_id);
let notification = control.read().await?;
let HostToClient::DelegateRequest {
id: notification_id,
..
} = &notification
else {
anyhow::bail!("expected notification on control lane, got {notification:?}");
};
let notification_id = *notification_id;
assert_eq!(
notification,
HostToClient::DelegateRequest {
id: notification_id,
session_id: session_id.clone(),
request: DelegateRequest::Notify {
call_id: "dual-websocket-call".to_string(),
cell_id: cell_id.clone(),
text: "important".to_string(),
},
}
);
control
.send(&ClientToHost::DelegateResponse {
id: notification_id,
result: WireResult::Ok {
value: DelegateResponse::NotificationDelivered,
},
})
.await?;
let callback = bulk.read().await?;
let HostToClient::DelegateRequest {
id: delegate_id,
session_id: callback_session_id,
request: DelegateRequest::InvokeTool { invocation },
} = callback
else {
anyhow::bail!("expected tool callback on bulk lane, got {callback:?}");
};
assert_eq!(callback_session_id, session_id);
assert_eq!(invocation.input, Some(json!({ "value": "ping" })));
bulk.send(&ClientToHost::DelegateResponse {
id: delegate_id,
result: WireResult::Ok {
value: DelegateResponse::ToolResult {
result: json!({ "value": "pong" }),
},
},
})
.await?;
assert_eq!(
control.read().await?,
HostToClient::InitialResponse {
id: execute_id,
result: WireResult::Ok {
value: WireRuntimeResponse::Result {
cell_id: cell_id.clone(),
content_items: vec![WireContentItem::InputText {
text: "pong".to_string(),
}],
error_text: None,
},
},
}
);
assert_eq!(
control.read().await?,
HostToClient::CellClosed {
session_id: session_id.clone(),
cell_id,
}
);
let shutdown_id = RequestId::new(/*value*/ 3);
control
.send(&ClientToHost::Request {
id: shutdown_id,
request: HostRequest::ShutdownSession {
session_id: session_id.clone(),
},
})
.await?;
assert_eq!(
control.read().await?,
HostToClient::Response {
id: shutdown_id,
result: WireResult::Ok {
value: HostResponse::SessionClosed { session_id },
},
}
);
Ok(())
}
#[tokio::test]
async fn websocket_control_operations_bypass_an_incomplete_bulk_frame() -> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let mut control = host.connect().await?;
let mut bulk = control.negotiate_dual(&host.endpoint).await?;
let session_id = SessionId::new("bulk-priority-session")?;
control.open_session(session_id.clone()).await?;
let bulk_message = ClientToHost::DelegateResponse {
id: codex_code_mode_protocol::host::DelegateRequestId::new(/*value*/ 999),
result: WireResult::Ok {
value: DelegateResponse::ToolResult {
result: json!({ "image": "x".repeat(1024 * 1024) }),
},
},
};
let payload = EncodedFrame::encode(&bulk_message)?.into_framed_bytes();
let mask = [0x13_u8, 0x37, 0xc0, 0xde];
let mut partial_frame = vec![0x82_u8, 0xff];
partial_frame.extend_from_slice(&(payload.len() as u64).to_be_bytes());
partial_frame.extend_from_slice(&mask);
partial_frame.extend(
payload[..64 * 1024]
.iter()
.enumerate()
.map(|(index, byte)| byte ^ mask[index % mask.len()]),
);
bulk.websocket
.get_mut()
.write_all(&partial_frame)
.await
.context("failed to start the intentionally incomplete bulk frame")?;
let request_id = RequestId::new(/*value*/ 42);
control
.send(&ClientToHost::Request {
id: request_id,
request: HostRequest::Wait {
session_id: session_id.clone(),
request: WireWaitRequest {
cell_id: codex_code_mode_protocol::host::WireCellId::new("missing-cell"),
yield_time_ms: 10,
},
},
})
.await?;
let response = timeout(TEST_TIMEOUT, control.read())
.await
.context("control wait was blocked behind the incomplete bulk frame")??;
assert!(matches!(
response,
HostToClient::Response {
id,
result: WireResult::Ok {
value: HostResponse::WaitCompleted { .. },
},
} if id == request_id
));
let execute_id = RequestId::new(/*value*/ 43);
control
.send(&ClientToHost::Request {
id: execute_id,
request: HostRequest::Execute {
session_id,
request: WireExecuteRequest {
tool_call_id: "control-execute".to_string(),
enabled_tools: Vec::new(),
source: r#"text("fast");"#.to_string(),
yield_time_ms: Some(5_000),
max_output_tokens: Some(1_000),
},
},
})
.await?;
for _ in 0..2 {
let response = timeout(TEST_TIMEOUT, control.read())
.await
.context("control execute was blocked behind the incomplete bulk frame")??;
assert!(matches!(
response,
HostToClient::Response { id, .. } | HostToClient::InitialResponse { id, .. }
if id == execute_id
));
}
Ok(())
}
#[tokio::test]
async fn websocket_bulk_lane_rejects_control_messages() -> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let mut control = host.connect().await?;
let mut bulk = control.negotiate_dual(&host.endpoint).await?;
let session_id = SessionId::new("wrong-lane-session")?;
control.open_session(session_id.clone()).await?;
bulk.send(&ClientToHost::Request {
id: RequestId::new(/*value*/ 42),
request: HostRequest::Wait {
session_id,
request: WireWaitRequest {
cell_id: codex_code_mode_protocol::host::WireCellId::new("missing-cell"),
yield_time_ms: 10,
},
},
})
.await?;
let result = timeout(TEST_TIMEOUT, control.websocket.next())
.await
.context("wrong-lane control message did not disconnect the paired sockets")?;
assert!(
matches!(result, None | Some(Ok(Message::Close(_))) | Some(Err(_))),
"wrong-lane message unexpectedly returned {result:?}"
);
Ok(())
}
#[tokio::test]
async fn websocket_bulk_pairing_rejects_unknown_tokens() -> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let unknown_token = Uuid::new_v4();
let url = format!("{}/bulk/{unknown_token}", host.endpoint);
let error = match connect_async(url).await {
Ok(_) => anyhow::bail!("unknown bulk pairing token should be rejected"),
Err(error) => error,
};
let WebSocketError::Http(response) = error else {
anyhow::bail!("bulk pairing failed unexpectedly: {error}");
};
assert_eq!(response.status(), StatusCode::NOT_FOUND);
Ok(())
}
#[tokio::test]
async fn websocket_listener_accepts_frames_larger_than_default_websocket_limit() -> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let mut client = host.connect().await?;
let capability = Capability::new("x".repeat((16 * 1024 * 1024) + 1))?;
client
.negotiate(CapabilitySet::try_new([capability])?)
.await
}
#[tokio::test]
async fn websocket_listener_keeps_connections_and_session_ids_isolated() -> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let mut first = host.connect().await?;
let mut second = host.connect().await?;
first.negotiate(CapabilitySet::empty()).await?;
second.negotiate(CapabilitySet::empty()).await?;
let session_id = SessionId::new("shared-session-name")?;
first.open_session(session_id.clone()).await?;
second.open_session(session_id).await?;
Ok(())
}
#[tokio::test]
async fn malformed_websocket_frame_does_not_stop_the_listener() -> Result<()> {
let mut host = HostHarness::start("ws://127.0.0.1:0").await?;
let stderr = host
._child
.stderr
.take()
.context("code-mode host stderr was not captured")?;
let mut stderr_lines = BufReader::new(stderr).lines();
let mut malformed = host.connect().await?;
malformed.send_binary(vec![1, 0, 0, 0, b'{']).await?;
let close = timeout(TEST_TIMEOUT, malformed.websocket.next())
.await
.context("timed out waiting for malformed websocket connection to close")?;
if let Some(Ok(message)) = close
&& !matches!(message, Message::Close(_))
{
anyhow::bail!("malformed websocket returned an unexpected message: {message:?}");
}
let diagnostic = timeout(TEST_TIMEOUT, async {
loop {
let line = stderr_lines
.next_line()
.await?
.context("code-mode host exited before reporting the malformed frame")?;
if line.contains("code-mode host session failed") {
return Ok::<_, anyhow::Error>(line);
}
}
})
.await
.context("timed out waiting for the malformed websocket diagnostic")??;
assert!(
diagnostic.contains("failed to read code-mode client hello"),
"unexpected malformed websocket diagnostic: {diagnostic}",
);
let mut recovered = host.connect().await?;
recovered.negotiate(CapabilitySet::empty()).await
}
#[tokio::test]
async fn websocket_listener_rejects_browser_origin_handshakes() -> Result<()> {
let host = HostHarness::start("ws://127.0.0.1:0").await?;
let mut request = host.endpoint.as_str().into_client_request()?;
request
.headers_mut()
.insert(ORIGIN, HeaderValue::from_static("https://evil.example"));
let error = match connect_async(request).await {
Ok(_) => anyhow::bail!("browser-origin websocket handshake should be rejected"),
Err(error) => error,
};
let WebSocketError::Http(response) = error else {
anyhow::bail!("browser-origin websocket handshake failed unexpectedly: {error}");
};
assert_eq!(response.status(), StatusCode::FORBIDDEN);
Ok(())
}
@@ -22,7 +22,6 @@ use super::ProtocolVersion;
use super::RequestId; use super::RequestId;
use super::SessionId; use super::SessionId;
use super::SupportedProtocolVersions; use super::SupportedProtocolVersions;
use super::TransportLane;
use super::WireCellId; use super::WireCellId;
use super::WireContentItem; use super::WireContentItem;
use super::WireExecuteRequest; use super::WireExecuteRequest;
@@ -75,130 +74,6 @@ where
); );
} }
#[test]
fn dual_websocket_hello_preserves_the_pairing_token() {
assert_wire_round_trip(
HostToClient::HostHello(
HostHello::new(
ProtocolVersion::V1,
CapabilitySet::try_new([capability("dual-websocket-v1")])
.expect("valid capabilities"),
)
.with_bulk_connection_token("pairing-token".to_string()),
),
json!({
"type": "connection/ready",
"selectedVersion": 1,
"capabilities": ["dual-websocket-v1"],
"bulkConnectionToken": "pairing-token",
}),
);
}
#[test]
fn message_families_use_dedicated_transport_lanes() {
for (message, lane) in [
(
ClientToHost::CancelRequest {
id: request_id(/*value*/ 1),
},
TransportLane::Control,
),
(
ClientToHost::DelegateResponse {
id: delegate_request_id(/*value*/ 1),
result: WireResult::Ok {
value: DelegateResponse::NotificationDelivered,
},
},
TransportLane::Control,
),
(
ClientToHost::DelegateResponse {
id: delegate_request_id(/*value*/ 2),
result: WireResult::Ok {
value: DelegateResponse::ToolResult {
result: json!({ "value": "tool result" }),
},
},
},
TransportLane::Bulk,
),
(
ClientToHost::DelegateResponse {
id: delegate_request_id(/*value*/ 3),
result: WireResult::Err {
message: "delegate failed".to_string(),
},
},
TransportLane::Bulk,
),
] {
assert_eq!(message.transport_lane(), lane);
assert!(message.allows_transport_lane(lane));
assert!(!message.allows_transport_lane(match lane {
TransportLane::Control => TransportLane::Bulk,
TransportLane::Bulk => TransportLane::Control,
}));
}
for (message, lane) in [
(
HostToClient::Response {
id: request_id(/*value*/ 1),
result: WireResult::Err {
message: "x".repeat(128 * 1024),
},
},
TransportLane::Control,
),
(
HostToClient::DelegateRequest {
id: delegate_request_id(/*value*/ 1),
session_id: session_id(),
request: DelegateRequest::Notify {
call_id: "call-1".to_string(),
cell_id: cell_id("cell-1"),
text: "important".to_string(),
},
},
TransportLane::Control,
),
(
HostToClient::DelegateRequest {
id: delegate_request_id(/*value*/ 2),
session_id: session_id(),
request: DelegateRequest::InvokeTool {
invocation: WireNestedToolCall {
cell_id: cell_id("cell-1"),
runtime_tool_call_id: "runtime-call-1".to_string(),
tool_name: WireToolName {
name: "tool".to_string(),
namespace: None,
},
tool_kind: WireToolKind::Function,
input: None,
},
},
},
TransportLane::Bulk,
),
(
HostToClient::CancelDelegateRequest {
id: delegate_request_id(/*value*/ 1),
},
TransportLane::Bulk,
),
] {
assert_eq!(message.transport_lane(), lane);
assert!(message.allows_transport_lane(lane));
assert!(!message.allows_transport_lane(match lane {
TransportLane::Control => TransportLane::Bulk,
TransportLane::Bulk => TransportLane::Control,
}));
}
}
fn execute_request() -> WireExecuteRequest { fn execute_request() -> WireExecuteRequest {
WireExecuteRequest { WireExecuteRequest {
tool_call_id: "call-1".to_string(), tool_call_id: "call-1".to_string(),
@@ -12,7 +12,6 @@ use super::ProtocolVersion;
use super::RequestId; use super::RequestId;
use super::SessionId; use super::SessionId;
use super::SupportedProtocolVersions; use super::SupportedProtocolVersions;
use super::TransportLane;
use super::WireCellId; use super::WireCellId;
use super::WireExecuteRequest; use super::WireExecuteRequest;
use super::WireNestedToolCall; use super::WireNestedToolCall;
@@ -107,8 +106,6 @@ impl std::error::Error for ClientHelloError {}
pub struct HostHello { pub struct HostHello {
selected_version: ProtocolVersion, selected_version: ProtocolVersion,
capabilities: CapabilitySet, capabilities: CapabilitySet,
#[serde(default, skip_serializing_if = "Option::is_none")]
bulk_connection_token: Option<String>,
} }
impl HostHello { impl HostHello {
@@ -116,15 +113,9 @@ impl HostHello {
Self { Self {
selected_version, selected_version,
capabilities, capabilities,
bulk_connection_token: None,
} }
} }
pub fn with_bulk_connection_token(mut self, token: String) -> Self {
self.bulk_connection_token = Some(token);
self
}
pub fn selected_version(&self) -> ProtocolVersion { pub fn selected_version(&self) -> ProtocolVersion {
self.selected_version self.selected_version
} }
@@ -132,10 +123,6 @@ impl HostHello {
pub fn capabilities(&self) -> &CapabilitySet { pub fn capabilities(&self) -> &CapabilitySet {
&self.capabilities &self.capabilities
} }
pub fn bulk_connection_token(&self) -> Option<&str> {
self.bulk_connection_token.as_deref()
}
} }
/// Messages sent from a client to the code-mode host. /// Messages sent from a client to the code-mode host.
@@ -155,30 +142,6 @@ pub enum ClientToHost {
}, },
} }
impl ClientToHost {
/// Keeps notification acknowledgments with control traffic and tool results on the bulk lane.
pub fn transport_lane(&self) -> TransportLane {
match self {
Self::DelegateResponse {
result:
WireResult::Ok {
value: DelegateResponse::NotificationDelivered,
},
..
}
| Self::ClientHello(_)
| Self::Request { .. }
| Self::CancelRequest { .. } => TransportLane::Control,
Self::DelegateResponse { .. } => TransportLane::Bulk,
}
}
/// Validates the message families accepted by each paired socket.
pub fn allows_transport_lane(&self, lane: TransportLane) -> bool {
self.transport_lane() == lane
}
}
/// Messages sent from the code-mode host to a client. /// Messages sent from the code-mode host to a client.
#[derive(Debug, Deserialize, PartialEq, Serialize)] #[derive(Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, tag = "type", rename_all_fields = "camelCase")] #[serde(deny_unknown_fields, tag = "type", rename_all_fields = "camelCase")]
@@ -212,33 +175,6 @@ pub enum HostToClient {
}, },
} }
impl HostToClient {
/// Keeps notifications with control traffic and nested-tool callbacks on the bulk lane.
pub fn transport_lane(&self) -> TransportLane {
match self {
Self::DelegateRequest {
request: DelegateRequest::InvokeTool { .. },
..
}
| Self::CancelDelegateRequest { .. } => TransportLane::Bulk,
Self::DelegateRequest {
request: DelegateRequest::Notify { .. },
..
}
| Self::HostHello(_)
| Self::HandshakeRejected { .. }
| Self::Response { .. }
| Self::InitialResponse { .. }
| Self::CellClosed { .. } => TransportLane::Control,
}
}
/// Rejects messages received on the wrong paired socket.
pub fn allows_transport_lane(&self, lane: TransportLane) -> bool {
self.transport_lane() == lane
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(deny_unknown_fields, tag = "method", rename_all_fields = "camelCase")] #[serde(deny_unknown_fields, tag = "method", rename_all_fields = "camelCase")]
pub enum HostRequest { pub enum HostRequest {
+1 -12
View File
@@ -1,8 +1,7 @@
//! Messages and framing for the code-mode host boundary. //! Messages and framing for the code-mode host boundary.
//! //!
//! Protocol version 1 multiplexes session operations and delegate callbacks by //! Protocol version 1 multiplexes session operations and delegate callbacks by
//! request ID over one ordered connection. WebSocket peers can negotiate a //! request ID over one ordered connection.
//! separate bulk connection without changing the existing inner messages.
mod codec; mod codec;
mod error; mod error;
@@ -13,19 +12,9 @@ mod types;
/// Maximum number of unresolved delegate callbacks allowed per host connection. /// Maximum number of unresolved delegate callbacks allowed per host connection.
pub const MAX_PENDING_DELEGATE_CALLS: usize = 1_024; pub const MAX_PENDING_DELEGATE_CALLS: usize = 1_024;
/// Optional second WebSocket carrying delegate callbacks and their responses.
pub const DUAL_WEBSOCKET_CAPABILITY: &str = "dual-websocket-v1";
/// Negotiated support for cell execution resource limits on `session/open`. /// Negotiated support for cell execution resource limits on `session/open`.
pub const SESSION_RESOURCE_LIMITS_CAPABILITY: &str = "session-cell-execution-resource-limits"; pub const SESSION_RESOURCE_LIMITS_CAPABILITY: &str = "session-cell-execution-resource-limits";
/// Selects one socket of a negotiated dual-WebSocket connection.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum TransportLane {
Control,
Bulk,
}
pub use codec::EncodedFrame; pub use codec::EncodedFrame;
pub use codec::FramedReader; pub use codec::FramedReader;
pub use codec::FramedWriter; pub use codec::FramedWriter;
-2
View File
@@ -17,14 +17,12 @@ codex-code-mode-protocol = { workspace = true }
codex-http-client = { workspace = true } codex-http-client = { workspace = true }
codex-install-context = { workspace = true } codex-install-context = { workspace = true }
codex-protocol = { workspace = true } codex-protocol = { workspace = true }
codex-websocket-client = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
http-body-util = "0.1.3" http-body-util = "0.1.3"
prost = "0.14.3" prost = "0.14.3"
reqwest = { workspace = true, features = ["stream"] } reqwest = { workspace = true, features = ["stream"] }
serde_json = { workspace = true } serde_json = { workspace = true }
tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] } tokio = { workspace = true, features = ["io-util", "macros", "net", "process", "rt", "sync", "time"] }
tokio-tungstenite = { workspace = true }
tokio-util = { workspace = true, features = ["rt"] } tokio-util = { workspace = true, features = ["rt"] }
tonic = { workspace = true } tonic = { workspace = true }
tower = { version = "0.5.3", features = ["util"] } tower = { version = "0.5.3", features = ["util"] }
-1
View File
@@ -6,4 +6,3 @@ pub use grpc_session::GrpcCodeModeSessionProvider;
pub use remote_session::DisabledCodeModeSessionProvider; pub use remote_session::DisabledCodeModeSessionProvider;
pub use remote_session::ProcessOwnedCodeModeSession; pub use remote_session::ProcessOwnedCodeModeSession;
pub use remote_session::ProcessOwnedCodeModeSessionProvider; pub use remote_session::ProcessOwnedCodeModeSessionProvider;
pub use remote_session::WebSocketCodeModeSessionProvider;
+5 -85
View File
@@ -18,8 +18,6 @@ use codex_code_mode_protocol::StartedCell;
use codex_code_mode_protocol::WaitOutcome; use codex_code_mode_protocol::WaitOutcome;
use codex_code_mode_protocol::WaitRequest; use codex_code_mode_protocol::WaitRequest;
use codex_code_mode_protocol::host::SessionId; use codex_code_mode_protocol::host::SessionId;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_install_context::InstallContext; use codex_install_context::InstallContext;
use tokio::sync::Semaphore; use tokio::sync::Semaphore;
use tokio::sync::watch; use tokio::sync::watch;
@@ -43,11 +41,6 @@ pub struct ProcessOwnedCodeModeSessionProvider {
#[derive(Default)] #[derive(Default)]
pub struct DisabledCodeModeSessionProvider; pub struct DisabledCodeModeSessionProvider;
/// Creates code-mode sessions backed by one shared remote WebSocket connection.
pub struct WebSocketCodeModeSessionProvider {
host: Arc<OwnedCodeModeHost>,
}
impl ProcessOwnedCodeModeSessionProvider { impl ProcessOwnedCodeModeSessionProvider {
pub fn with_host_program(host_program: PathBuf) -> Self { pub fn with_host_program(host_program: PathBuf) -> Self {
Self { Self {
@@ -68,9 +61,7 @@ impl Default for ProcessOwnedCodeModeSessionProvider {
impl CodeModeSessionProvider for ProcessOwnedCodeModeSessionProvider { impl CodeModeSessionProvider for ProcessOwnedCodeModeSessionProvider {
fn availability(&self) -> Result<(), String> { fn availability(&self) -> Result<(), String> {
let HostEndpoint::Process(host_program) = &self.host.endpoint else { let host_program = &self.host.host_program;
unreachable!("a process-owned provider always has a process endpoint");
};
if host_program.is_file() { if host_program.is_file() {
Ok(()) Ok(())
} else { } else {
@@ -119,49 +110,6 @@ impl CodeModeSessionProvider for DisabledCodeModeSessionProvider {
} }
} }
impl WebSocketCodeModeSessionProvider {
pub fn new(websocket_url: String) -> Self {
Self::with_http_client_factory(
websocket_url,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
)
}
/// Creates a remote host using the application's effective proxy and TLS policy.
pub fn with_http_client_factory(
websocket_url: String,
http_client_factory: HttpClientFactory,
) -> Self {
Self {
host: Arc::new(OwnedCodeModeHost::websocket(
websocket_url,
http_client_factory,
)),
}
}
}
impl CodeModeSessionProvider for WebSocketCodeModeSessionProvider {
fn create_session<'a>(
&'a self,
delegate: Arc<dyn CodeModeSessionDelegate>,
) -> CodeModeSessionProviderFuture<'a> {
self.create_session_with_limits(delegate, CodeModeSessionCellExecutionLimits::default())
}
fn create_session_with_limits<'a>(
&'a self,
delegate: Arc<dyn CodeModeSessionDelegate>,
limits: CodeModeSessionCellExecutionLimits,
) -> CodeModeSessionProviderFuture<'a> {
Box::pin(create_host_session(
delegate,
Arc::clone(&self.host),
limits,
))
}
}
async fn create_host_session( async fn create_host_session(
delegate: Arc<dyn CodeModeSessionDelegate>, delegate: Arc<dyn CodeModeSessionDelegate>,
host: Arc<OwnedCodeModeHost>, host: Arc<OwnedCodeModeHost>,
@@ -172,16 +120,8 @@ async fn create_host_session(
Ok(Arc::new(session)) Ok(Arc::new(session))
} }
enum HostEndpoint {
Process(PathBuf),
WebSocket {
websocket_url: String,
http_client_factory: HttpClientFactory,
},
}
struct OwnedCodeModeHost { struct OwnedCodeModeHost {
endpoint: HostEndpoint, host_program: PathBuf,
connection: StdMutex<Option<Arc<Connection>>>, connection: StdMutex<Option<Arc<Connection>>>,
connect_permit: Semaphore, connect_permit: Semaphore,
connection_generation: AtomicU64, connection_generation: AtomicU64,
@@ -192,21 +132,7 @@ struct OwnedCodeModeHost {
impl OwnedCodeModeHost { impl OwnedCodeModeHost {
fn new(host_program: PathBuf) -> Self { fn new(host_program: PathBuf) -> Self {
Self { Self {
endpoint: HostEndpoint::Process(host_program), host_program,
connection: StdMutex::new(None),
connect_permit: Semaphore::new(/*permits*/ 1),
connection_generation: AtomicU64::new(0),
last_connection_error: StdMutex::new(None),
next_session_id: AtomicU64::new(1),
}
}
fn websocket(websocket_url: String, http_client_factory: HttpClientFactory) -> Self {
Self {
endpoint: HostEndpoint::WebSocket {
websocket_url,
http_client_factory,
},
connection: StdMutex::new(None), connection: StdMutex::new(None),
connect_permit: Semaphore::new(/*permits*/ 1), connect_permit: Semaphore::new(/*permits*/ 1),
connection_generation: AtomicU64::new(0), connection_generation: AtomicU64::new(0),
@@ -238,13 +164,7 @@ impl OwnedCodeModeHost {
{ {
return Err(ConnectionError::Other(error.clone())); return Err(ConnectionError::Other(error.clone()));
} }
let connection = match &self.endpoint { let connection = Connection::spawn(&self.host_program).await;
HostEndpoint::Process(host_program) => Connection::spawn(host_program).await,
HostEndpoint::WebSocket {
websocket_url,
http_client_factory,
} => Connection::connect_websocket(websocket_url, http_client_factory).await,
};
let new_connection = match connection { let new_connection = match connection {
Ok(connection) => connection, Ok(connection) => connection,
Err(error) => { Err(error) => {
@@ -312,7 +232,7 @@ struct SessionInner {
retired_cleanups: StdMutex<Vec<SessionCleanup>>, retired_cleanups: StdMutex<Vec<SessionCleanup>>,
} }
/// A logical code-mode session assigned to a process or WebSocket host. /// A logical code-mode session assigned to a process host.
pub struct ProcessOwnedCodeModeSession { pub struct ProcessOwnedCodeModeSession {
inner: Arc<SessionInner>, inner: Arc<SessionInner>,
} }
@@ -20,32 +20,24 @@ use codex_code_mode_protocol::host::Capability;
use codex_code_mode_protocol::host::CapabilitySet; use codex_code_mode_protocol::host::CapabilitySet;
use codex_code_mode_protocol::host::ClientHello; use codex_code_mode_protocol::host::ClientHello;
use codex_code_mode_protocol::host::ClientToHost; use codex_code_mode_protocol::host::ClientToHost;
use codex_code_mode_protocol::host::DUAL_WEBSOCKET_CAPABILITY;
use codex_code_mode_protocol::host::EncodedFrame; use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::FramedReader; use codex_code_mode_protocol::host::FramedReader;
use codex_code_mode_protocol::host::FramedWriter; use codex_code_mode_protocol::host::FramedWriter;
use codex_code_mode_protocol::host::HostToClient; use codex_code_mode_protocol::host::HostToClient;
use codex_code_mode_protocol::host::MAX_FRAME_BYTES;
use codex_code_mode_protocol::host::MAX_PENDING_DELEGATE_CALLS;
use codex_code_mode_protocol::host::ProtocolVersion; use codex_code_mode_protocol::host::ProtocolVersion;
use codex_code_mode_protocol::host::RequestId; use codex_code_mode_protocol::host::RequestId;
use codex_code_mode_protocol::host::SESSION_RESOURCE_LIMITS_CAPABILITY; use codex_code_mode_protocol::host::SESSION_RESOURCE_LIMITS_CAPABILITY;
use codex_code_mode_protocol::host::SupportedProtocolVersions; use codex_code_mode_protocol::host::SupportedProtocolVersions;
use codex_code_mode_protocol::host::TransportLane;
use codex_http_client::HttpClientFactory;
use codex_protocol::shell_environment::scrub_non_inheritable_env_vars; use codex_protocol::shell_environment::scrub_non_inheritable_env_vars;
use codex_websocket_client::WebSocketConnector;
use futures::StreamExt;
use tokio::io::AsyncBufReadExt; use tokio::io::AsyncBufReadExt;
use tokio::io::BufReader; use tokio::io::BufReader;
use tokio::process::Child; use tokio::process::Child;
use tokio::process::ChildStdin;
use tokio::process::ChildStdout;
use tokio::process::Command; use tokio::process::Command;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::oneshot; use tokio::sync::oneshot;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::Uri;
use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use tracing::debug; use tracing::debug;
use tracing::warn; use tracing::warn;
@@ -57,19 +49,14 @@ use self::driver::DriverLifecycle;
pub(super) use self::driver::RemoteSession; pub(super) use self::driver::RemoteSession;
pub(super) use self::driver::SessionCleanup; pub(super) use self::driver::SessionCleanup;
use self::reader::drive_reader; use self::reader::drive_reader;
use self::transport::ConnectionReader;
use self::transport::ConnectionWriter;
mod driver; mod driver;
mod reader; mod reader;
mod transport;
const IPC_CHANNEL_CAPACITY: usize = 128; const IPC_CHANNEL_CAPACITY: usize = 128;
const HOST_HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
const LOCAL_HOST_STARTUP_TIMEOUT: Duration = Duration::from_secs(30); const LOCAL_HOST_STARTUP_TIMEOUT: Duration = Duration::from_secs(30);
// TODO(anp) make this timeout configurable if 60 seconds is insufficient. // TODO(anp) make this timeout configurable if 60 seconds is insufficient.
const DEFAULT_HOST_WAIT_TRANSPORT_TIMEOUT: Duration = Duration::from_secs(60); const DEFAULT_HOST_WAIT_TRANSPORT_TIMEOUT: Duration = Duration::from_secs(60);
const MAX_WEBSOCKET_FRAME_BYTES: usize = MAX_FRAME_BYTES + std::mem::size_of::<u32>();
// Host spawn errors become model-visible tool output. Bound configured paths // Host spawn errors become model-visible tool output. Bound configured paths
// while preserving the executable-bearing suffix needed to diagnose failures. // while preserving the executable-bearing suffix needed to diagnose failures.
const MAX_DISPLAYED_HOST_PROGRAM_BYTES: usize = 512; const MAX_DISPLAYED_HOST_PROGRAM_BYTES: usize = 512;
@@ -80,7 +67,6 @@ pub(super) enum ConnectionError {
host_program: PathBuf, host_program: PathBuf,
error: io::Error, error: io::Error,
}, },
BulkConnectionUnavailable(String),
Other(String), Other(String),
} }
@@ -111,9 +97,7 @@ impl fmt::Display for ConnectionError {
&host_program[suffix_start..] &host_program[suffix_start..]
) )
} }
Self::BulkConnectionUnavailable(message) | Self::Other(message) => { Self::Other(message) => formatter.write_str(message),
formatter.write_str(message)
}
} }
} }
} }
@@ -133,7 +117,7 @@ struct CallerCancellation {
} }
struct ConnectionSupervisor { struct ConnectionSupervisor {
owner: ConnectionOwner, child: Child,
event_tx: mpsc::Sender<DriverEvent>, event_tx: mpsc::Sender<DriverEvent>,
cancellation: CancellationToken, cancellation: CancellationToken,
alive: Arc<AtomicBool>, alive: Arc<AtomicBool>,
@@ -143,50 +127,6 @@ struct ConnectionSupervisor {
writer_task: JoinHandle<Result<(), String>>, writer_task: JoinHandle<Result<(), String>>,
} }
enum ConnectionOwner {
Process(Box<Child>),
WebSocket,
}
struct BulkConnectionOptions {
websocket_url: String,
http_client_factory: HttpClientFactory,
}
impl BulkConnectionOptions {
async fn connect(
&self,
token: &str,
) -> Result<(ConnectionReader, ConnectionWriter), ConnectionError> {
let control_uri = self.websocket_url.parse::<Uri>().map_err(|error| {
ConnectionError::Other(format!(
"failed to build code-mode host bulk websocket URL: {error}"
))
})?;
let bulk_path = format!("{}/bulk/{token}", control_uri.path().trim_end_matches('/'));
let bulk_path_and_query = match control_uri.query() {
Some(query) => format!("{bulk_path}?{query}"),
None => bulk_path,
};
let mut bulk_uri_parts = control_uri.into_parts();
bulk_uri_parts.path_and_query = Some(bulk_path_and_query.parse().map_err(|error| {
ConnectionError::Other(format!(
"failed to build code-mode host bulk websocket path: {error}"
))
})?);
let bulk_url = Uri::from_parts(bulk_uri_parts)
.map_err(|error| {
ConnectionError::Other(format!(
"failed to build code-mode host bulk websocket URL: {error}"
))
})?
.to_string();
connect_websocket_transport(&bulk_url, &self.http_client_factory)
.await
.map_err(|error| ConnectionError::BulkConnectionUnavailable(error.to_string()))
}
}
impl CallerCancellation { impl CallerCancellation {
fn new() -> Self { fn new() -> Self {
Self { Self {
@@ -253,52 +193,19 @@ impl Connection {
.take() .take()
.ok_or_else(|| ConnectionError::Other("spawned code-mode host has no stdout".into()))?; .ok_or_else(|| ConnectionError::Other("spawned code-mode host has no stdout".into()))?;
Self::establish( Self::establish(FramedReader::new(stdout), FramedWriter::new(stdin), child).await
ConnectionReader::Stdio(FramedReader::new(stdout)),
ConnectionWriter::Stdio(FramedWriter::new(stdin)),
ConnectionOwner::Process(Box::new(child)),
/*bulk_connection_options*/ None,
)
.await
}
pub(super) async fn connect_websocket(
websocket_url: &str,
http_client_factory: &HttpClientFactory,
) -> Result<Self, ConnectionError> {
let (reader, writer) =
connect_websocket_transport(websocket_url, http_client_factory).await?;
Self::establish(
reader,
writer,
ConnectionOwner::WebSocket,
Some(BulkConnectionOptions {
websocket_url: websocket_url.to_string(),
http_client_factory: http_client_factory.clone(),
}),
)
.await
} }
async fn establish( async fn establish(
mut reader: ConnectionReader, mut reader: FramedReader<ChildStdout>,
mut writer: ConnectionWriter, mut writer: FramedWriter<ChildStdin>,
mut owner: ConnectionOwner, mut child: Child,
bulk_connection_options: Option<BulkConnectionOptions>,
) -> Result<Self, ConnectionError> { ) -> Result<Self, ConnectionError> {
let handshake = async { let handshake = async {
let dual_capability =
Capability::new(DUAL_WEBSOCKET_CAPABILITY).map_err(|error| error.to_string())?;
let session_limits_capability = Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY) let session_limits_capability = Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let optional_capabilities = if bulk_connection_options.is_some() { let optional_capabilities = CapabilitySet::try_new([session_limits_capability])
CapabilitySet::try_new([dual_capability.clone(), session_limits_capability]) .map_err(|error| error.to_string())?;
.map_err(|error| error.to_string())?
} else {
CapabilitySet::try_new([session_limits_capability])
.map_err(|error| error.to_string())?
};
let hello = ClientHello::new( let hello = ClientHello::new(
SupportedProtocolVersions::try_new([ProtocolVersion::V1]) SupportedProtocolVersions::try_new([ProtocolVersion::V1])
.map_err(|err| err.to_string())?, .map_err(|err| err.to_string())?,
@@ -318,24 +225,7 @@ impl Connection {
Some(HostToClient::HostHello(hello)) Some(HostToClient::HostHello(hello))
if hello.selected_version() == ProtocolVersion::V1 => if hello.selected_version() == ProtocolVersion::V1 =>
{ {
let capabilities = hello.capabilities().clone(); Ok(hello.capabilities().clone())
let bulk_token = if capabilities.contains(&dual_capability) {
hello
.bulk_connection_token()
.map(str::to_string)
.ok_or_else(|| {
"code-mode host advertised dual websockets without a pairing token"
.to_string()
})
.map(Some)?
} else if hello.bulk_connection_token().is_some() {
return Err(
"code-mode host returned an unexpected bulk pairing token".to_string()
);
} else {
None
};
Ok((capabilities, bulk_token))
} }
Some(HostToClient::HandshakeRejected { reason }) => { Some(HostToClient::HandshakeRejected { reason }) => {
Err(format!("code-mode host rejected the handshake: {reason:?}")) Err(format!("code-mode host rejected the handshake: {reason:?}"))
@@ -346,99 +236,42 @@ impl Connection {
None => Err("code-mode host exited during handshake".to_string()), None => Err("code-mode host exited during handshake".to_string()),
} }
}; };
let handshake_timeout = match &owner { let handshake_result =
ConnectionOwner::Process(_) => LOCAL_HOST_STARTUP_TIMEOUT, match tokio::time::timeout(LOCAL_HOST_STARTUP_TIMEOUT, handshake).await {
ConnectionOwner::WebSocket => HOST_HANDSHAKE_TIMEOUT,
};
let handshake_result = match tokio::time::timeout(handshake_timeout, handshake).await {
Ok(result) => result, Ok(result) => result,
Err(_) => { Err(_) => {
let _ = writer.close().await; kill_and_reap(&mut child).await;
owner.close().await;
return Err(ConnectionError::Other( return Err(ConnectionError::Other(
"timed out negotiating with the code-mode host".into(), "timed out negotiating with the code-mode host".into(),
)); ));
} }
}; };
let (capabilities, bulk_token) = match handshake_result { let capabilities = match handshake_result {
Ok(negotiated) => negotiated, Ok(negotiated) => negotiated,
Err(err) => { Err(err) => {
let _ = writer.close().await; kill_and_reap(&mut child).await;
owner.close().await;
return Err(ConnectionError::Other(err)); return Err(ConnectionError::Other(err));
} }
}; };
let (bulk_reader, bulk_writer) = if let Some(token) = bulk_token {
let Some(options) = bulk_connection_options else {
let _ = writer.close().await;
owner.close().await;
return Err(ConnectionError::Other(
"code-mode host negotiated an unsupported bulk websocket".to_string(),
));
};
match options.connect(&token).await {
Ok((reader, writer)) => (Some(reader), Some(writer)),
Err(error) => {
let _ = writer.close().await;
owner.close().await;
return Err(error);
}
}
} else {
(None, None)
};
let (command_tx, command_rx) = mpsc::channel(IPC_CHANNEL_CAPACITY); let (command_tx, command_rx) = mpsc::channel(IPC_CHANNEL_CAPACITY);
let (event_tx, event_rx) = mpsc::channel(IPC_CHANNEL_CAPACITY); let (event_tx, event_rx) = mpsc::channel(IPC_CHANNEL_CAPACITY);
let (outgoing_tx, outgoing_rx) = mpsc::channel::<EncodedFrame>(IPC_CHANNEL_CAPACITY); let (outgoing_tx, outgoing_rx) = mpsc::channel::<EncodedFrame>(IPC_CHANNEL_CAPACITY);
let (bulk_tx, bulk_rx) = if bulk_writer.is_some() {
let (sender, receiver) = mpsc::channel::<EncodedFrame>(MAX_PENDING_DELEGATE_CALLS);
(Some(sender), Some(receiver))
} else {
(None, None)
};
let dual_websocket = bulk_writer.is_some();
let cancellation = CancellationToken::new(); let cancellation = CancellationToken::new();
let alive = Arc::new(AtomicBool::new(true)); let alive = Arc::new(AtomicBool::new(true));
let failure = Arc::new(std::sync::Mutex::new(None)); let failure = Arc::new(std::sync::Mutex::new(None));
let writer_cancellation = cancellation.clone(); let writer_cancellation = cancellation.clone();
let writer_task = tokio::spawn(async move { let writer_task =
if let (Some(bulk_writer), Some(bulk_rx)) = (bulk_writer, bulk_rx) { tokio::spawn(
tokio::try_join!( async move { drive_writer(writer, outgoing_rx, writer_cancellation).await },
drive_writer(writer, outgoing_rx, writer_cancellation.clone()), );
drive_writer(bulk_writer, bulk_rx, writer_cancellation)
)?;
Ok(())
} else {
drive_writer(writer, outgoing_rx, writer_cancellation).await
}
});
let reader_events = event_tx.clone(); let reader_events = event_tx.clone();
let reader_cancellation = cancellation.clone(); let reader_cancellation = cancellation.clone();
let reader_task = tokio::spawn(async move { let reader_task =
let lane = dual_websocket.then_some(TransportLane::Control); tokio::spawn(
if let Some(bulk_reader) = bulk_reader { async move { drive_reader(reader, reader_events, reader_cancellation).await },
tokio::try_join!( );
drive_reader(
reader,
reader_events.clone(),
reader_cancellation.clone(),
lane,
),
drive_reader(
bulk_reader,
reader_events,
reader_cancellation,
Some(TransportLane::Bulk),
)
)?;
Ok(())
} else {
drive_reader(reader, reader_events, reader_cancellation, lane).await
}
});
let (driver, execute_claim_tx) = ConnectionDriver::new( let (driver, execute_claim_tx) = ConnectionDriver::new(
command_rx, command_rx,
@@ -451,14 +284,10 @@ impl Connection {
cancellation: cancellation.clone(), cancellation: cancellation.clone(),
}, },
); );
let driver = match bulk_tx {
Some(sender) => driver.with_bulk_sender(sender),
None => driver,
};
let driver_task = tokio::spawn(driver.run()); let driver_task = tokio::spawn(driver.run());
tokio::spawn( tokio::spawn(
ConnectionSupervisor { ConnectionSupervisor {
owner, child,
event_tx, event_tx,
cancellation: cancellation.clone(), cancellation: cancellation.clone(),
alive: Arc::clone(&alive), alive: Arc::clone(&alive),
@@ -653,65 +482,21 @@ impl Connection {
} }
} }
async fn connect_websocket_transport(
websocket_url: &str,
http_client_factory: &HttpClientFactory,
) -> Result<(ConnectionReader, ConnectionWriter), ConnectionError> {
let request = websocket_url.into_client_request().map_err(|error| {
ConnectionError::Other(format!(
"failed to build code-mode host websocket request: {error}"
))
})?;
let connector = WebSocketConnector::new(http_client_factory)
.map_err(|error| {
ConnectionError::Other(format!(
"failed to configure code-mode host websocket TLS: {error}"
))
})?
.with_tcp_nodelay();
let websocket_config = WebSocketConfig::default()
.max_frame_size(Some(MAX_WEBSOCKET_FRAME_BYTES))
.max_message_size(Some(MAX_WEBSOCKET_FRAME_BYTES));
let (websocket, _) = tokio::time::timeout(
HOST_HANDSHAKE_TIMEOUT,
connector.connect(request, websocket_config),
)
.await
.map_err(|_| {
ConnectionError::Other("timed out connecting to the code-mode host websocket".into())
})?
.map_err(|error| {
ConnectionError::Other(format!(
"failed to connect to the code-mode host websocket: {error}"
))
})?;
let (writer, reader) = websocket.split();
Ok((
ConnectionReader::WebSocket(reader),
ConnectionWriter::WebSocket(writer),
))
}
async fn drive_writer( async fn drive_writer(
mut writer: ConnectionWriter, mut writer: FramedWriter<ChildStdin>,
mut outgoing: mpsc::Receiver<EncodedFrame>, mut outgoing: mpsc::Receiver<EncodedFrame>,
cancellation: CancellationToken, cancellation: CancellationToken,
) -> Result<(), String> { ) -> Result<(), String> {
loop { loop {
tokio::select! { tokio::select! {
_ = cancellation.cancelled() => { _ = cancellation.cancelled() => return Ok(()),
return writer
.close()
.await
.map_err(|error| format!("failed to close code-mode host connection: {error}"));
}
frame = outgoing.recv() => { frame = outgoing.recv() => {
let Some(frame) = frame else { let Some(frame) = frame else {
return Err("code-mode host outgoing stream closed".to_string()); return Err("code-mode host outgoing stream closed".to_string());
}; };
tokio::select! { tokio::select! {
_ = cancellation.cancelled() => return Ok(()), _ = cancellation.cancelled() => return Ok(()),
result = writer.write_frame(frame) => { result = writer.write_frame(&frame) => {
result.map_err(|error| { result.map_err(|error| {
format!("failed to write code-mode host message: {error}") format!("failed to write code-mode host message: {error}")
})?; })?;
@@ -735,7 +520,7 @@ impl Drop for Connection {
impl ConnectionSupervisor { impl ConnectionSupervisor {
async fn run(mut self) { async fn run(mut self) {
let mut owner_exited = false; let mut child_exited = false;
let reason = tokio::select! { let reason = tokio::select! {
biased; biased;
_ = self.cancellation.cancelled() => failure_message(&self.failure), _ = self.cancellation.cancelled() => failure_message(&self.failure),
@@ -745,35 +530,19 @@ impl ConnectionSupervisor {
}, },
result = &mut self.reader_task => task_failure("reader", result), result = &mut self.reader_task => task_failure("reader", result),
result = &mut self.writer_task => task_failure("writer", result), result = &mut self.writer_task => task_failure("writer", result),
reason = self.owner.wait() => { result = self.child.wait() => {
owner_exited = true; child_exited = true;
reason match result {
Ok(status) => format!("code-mode host exited with status {status}"),
Err(error) => format!("failed waiting for code-mode host: {error}"),
}
} }
}; };
mark_connection_dead(&self.alive, &self.failure, reason.clone()); mark_connection_dead(&self.alive, &self.failure, reason.clone());
let _ = self.event_tx.try_send(DriverEvent::Failed(reason)); let _ = self.event_tx.try_send(DriverEvent::Failed(reason));
self.cancellation.cancel(); self.cancellation.cancel();
if !owner_exited { if !child_exited {
self.owner.close().await; kill_and_reap(&mut self.child).await;
}
}
}
impl ConnectionOwner {
async fn wait(&mut self) -> String {
match self {
Self::Process(child) => match child.wait().await {
Ok(status) => format!("code-mode host exited with status {status}"),
Err(error) => format!("failed waiting for code-mode host: {error}"),
},
Self::WebSocket => std::future::pending().await,
}
}
async fn close(&mut self) {
match self {
Self::Process(child) => kill_and_reap(child).await,
Self::WebSocket => {}
} }
} }
} }
@@ -9,7 +9,6 @@ use codex_code_mode_protocol::CodeModeSessionDelegate;
use codex_code_mode_protocol::host::EncodedFrame; use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::HostToClient; use codex_code_mode_protocol::host::HostToClient;
use codex_code_mode_protocol::host::RequestId; use codex_code_mode_protocol::host::RequestId;
use codex_code_mode_protocol::host::TransportLane;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
@@ -42,7 +41,6 @@ pub(super) struct ConnectionDriver {
event_tx: mpsc::Sender<DriverEvent>, event_tx: mpsc::Sender<DriverEvent>,
execute_claim_rx: mpsc::UnboundedReceiver<RequestId>, execute_claim_rx: mpsc::UnboundedReceiver<RequestId>,
outgoing_tx: mpsc::Sender<EncodedFrame>, outgoing_tx: mpsc::Sender<EncodedFrame>,
bulk_tx: Option<mpsc::Sender<EncodedFrame>>,
requests: RequestTracker, requests: RequestTracker,
deferred_host_messages: VecDeque<HostToClient>, deferred_host_messages: VecDeque<HostToClient>,
sessions: SessionRegistry, sessions: SessionRegistry,
@@ -69,7 +67,6 @@ impl ConnectionDriver {
event_tx: event_tx.clone(), event_tx: event_tx.clone(),
execute_claim_rx, execute_claim_rx,
outgoing_tx, outgoing_tx,
bulk_tx: None,
requests: RequestTracker::new(), requests: RequestTracker::new(),
deferred_host_messages: VecDeque::new(), deferred_host_messages: VecDeque::new(),
sessions: SessionRegistry::new(), sessions: SessionRegistry::new(),
@@ -137,17 +134,8 @@ impl ConnectionDriver {
} }
} }
pub(super) fn with_bulk_sender(mut self, sender: mpsc::Sender<EncodedFrame>) -> Self { fn queue_frame(&mut self, frame: EncodedFrame) -> bool {
self.bulk_tx = Some(sender); match self.outgoing_tx.try_send(frame) {
self
}
fn queue_frame(&mut self, frame: EncodedFrame, lane: TransportLane) -> bool {
let sender = match lane {
TransportLane::Control => &self.outgoing_tx,
TransportLane::Bulk => self.bulk_tx.as_ref().unwrap_or(&self.outgoing_tx),
};
match sender.try_send(frame) {
Ok(()) => true, Ok(()) => true,
Err(mpsc::error::TrySendError::Full(_)) => { Err(mpsc::error::TrySendError::Full(_)) => {
self.fail("code-mode host outgoing queue is full".to_string()); self.fail("code-mode host outgoing queue is full".to_string());
@@ -127,8 +127,7 @@ impl ConnectionDriver {
}, },
&self.event_tx, &self.event_tx,
); );
let lane = message.transport_lane(); self.queue_frame(frame)
self.queue_frame(frame, lane)
} }
fn execute( fn execute(
@@ -187,8 +186,7 @@ impl ConnectionDriver {
}, },
&self.event_tx, &self.event_tx,
); );
let lane = message.transport_lane(); self.queue_frame(frame)
self.queue_frame(frame, lane)
} }
fn wait( fn wait(
@@ -317,7 +315,6 @@ impl ConnectionDriver {
}; };
self.requests self.requests
.insert_pending(request_id, pending, &self.event_tx); .insert_pending(request_id, pending, &self.event_tx);
let lane = message.transport_lane(); self.queue_frame(frame)
self.queue_frame(frame, lane)
} }
} }
@@ -318,8 +318,7 @@ impl ConnectionDriver {
} }
} }
}; };
let lane = message.transport_lane(); self.queue_frame(frame)
self.queue_frame(frame, lane)
} }
pub(super) fn close_cell(&mut self, session_id: SessionId, cell_id: WireCellId) -> bool { pub(super) fn close_cell(&mut self, session_id: SessionId, cell_id: WireCellId) -> bool {
@@ -374,8 +374,7 @@ impl ConnectionDriver {
return false; return false;
} }
}; };
let lane = message.transport_lane(); self.queue_frame(frame)
self.queue_frame(frame, lane)
} }
fn shutdown_abandoned_session(&mut self, session: RemoteSession) -> bool { fn shutdown_abandoned_session(&mut self, session: RemoteSession) -> bool {
@@ -17,7 +17,6 @@ use codex_code_mode_protocol::host::CapabilitySet;
use codex_code_mode_protocol::host::ClientToHost; use codex_code_mode_protocol::host::ClientToHost;
use codex_code_mode_protocol::host::DelegateRequest; use codex_code_mode_protocol::host::DelegateRequest;
use codex_code_mode_protocol::host::DelegateRequestId; use codex_code_mode_protocol::host::DelegateRequestId;
use codex_code_mode_protocol::host::DelegateResponse;
use codex_code_mode_protocol::host::EncodedFrame; use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::HostRequest; use codex_code_mode_protocol::host::HostRequest;
use codex_code_mode_protocol::host::HostResponse; use codex_code_mode_protocol::host::HostResponse;
@@ -248,11 +247,6 @@ struct RecordingDelegate {
struct PanickingDelegate; struct PanickingDelegate;
struct LargeResultBurstDelegate {
started: AtomicUsize,
release: CancellationToken,
}
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
enum HeldDelegateEvent { enum HeldDelegateEvent {
Started, Started,
@@ -342,35 +336,6 @@ impl CodeModeSessionDelegate for PanickingDelegate {
fn cell_closed(&self, _cell_id: &CellId) {} fn cell_closed(&self, _cell_id: &CellId) {}
} }
impl CodeModeSessionDelegate for LargeResultBurstDelegate {
fn invoke_tool<'a>(
&'a self,
_invocation: CodeModeNestedToolCall,
cancellation_token: CancellationToken,
) -> ToolInvocationFuture<'a> {
self.started.fetch_add(1, Ordering::Release);
let release = self.release.clone();
Box::pin(async move {
tokio::select! {
_ = cancellation_token.cancelled() => Err("cancelled".to_string()),
_ = release.cancelled() => Ok("x".repeat(256 * 1024).into()),
}
})
}
fn notify<'a>(
&'a self,
_call_id: String,
_cell_id: CellId,
_text: String,
_cancellation_token: CancellationToken,
) -> NotificationFuture<'a> {
Box::pin(async { Ok(()) })
}
fn cell_closed(&self, _cell_id: &CellId) {}
}
impl CodeModeSessionDelegate for RecordingDelegate { impl CodeModeSessionDelegate for RecordingDelegate {
fn invoke_tool<'a>( fn invoke_tool<'a>(
&'a self, &'a self,
@@ -692,97 +657,6 @@ async fn delegate_cancel_is_best_effort_and_sends_no_late_response() {
assert_eq!(delegate.notifications.load(Ordering::Relaxed), 0); assert_eq!(delegate.notifications.load(Ordering::Relaxed), 0);
} }
#[tokio::test]
async fn concurrent_large_delegate_results_do_not_disconnect_a_backpressured_bulk_lane() {
const CONCURRENT_RESULTS: usize = 129;
let (command_tx, command_rx) = mpsc::channel(/*max_capacity*/ 16);
let (event_tx, event_rx) = mpsc::channel(/*max_capacity*/ 16);
let (outgoing_tx, outgoing_rx) = mpsc::channel(/*max_capacity*/ 16);
let (bulk_tx, mut bulk_rx) = mpsc::channel(MAX_PENDING_DELEGATE_CALLS);
let cancellation = CancellationToken::new();
let alive = Arc::new(AtomicBool::new(true));
let failure = Arc::new(StdMutex::new(None));
let (driver, execute_claim_tx) = ConnectionDriver::new(
command_rx,
event_rx,
event_tx.clone(),
outgoing_tx,
DriverLifecycle {
alive: Arc::clone(&alive),
failure: Arc::clone(&failure),
cancellation: cancellation.clone(),
},
);
let driver_task = tokio::spawn(driver.with_bulk_sender(bulk_tx).run());
let mut harness = DriverHarness {
command_tx,
event_tx,
execute_claim_tx,
outgoing_rx,
cancellation,
alive,
failure,
driver_task,
};
let session = remote_session();
let delegate = Arc::new(LargeResultBurstDelegate {
started: AtomicUsize::new(0),
release: CancellationToken::new(),
});
harness.open(session.clone(), delegate.clone()).await;
let _started = harness
.start_cell(session.clone(), /*request_id*/ 2, "1")
.await;
for value in 1..=CONCURRENT_RESULTS {
harness
.start_tool_delegate(&session, DelegateRequestId::new(value as i64))
.await;
}
tokio::time::timeout(Duration::from_secs(10), async {
while delegate.started.load(Ordering::Acquire) < CONCURRENT_RESULTS {
tokio::task::yield_now().await;
}
})
.await
.expect("concurrent delegate calls should all start");
delegate.release.cancel();
tokio::time::timeout(Duration::from_secs(10), async {
while bulk_rx.len() < CONCURRENT_RESULTS {
assert!(
harness.alive.load(Ordering::Acquire),
"bulk queue disconnected before accepting all concurrent tool results"
);
tokio::task::yield_now().await;
}
})
.await
.expect("concurrent large results should queue behind the blocked bulk writer");
let _unrelated = harness.start_cell(session, /*request_id*/ 3, "2").await;
assert!(harness.alive.load(Ordering::Acquire));
for _ in 0..CONCURRENT_RESULTS {
let frame = bulk_rx.recv().await.expect("queued bulk delegate result");
let message = EncodedFrame::decode_framed::<ClientToHost>(&frame.into_framed_bytes())
.expect("decode queued delegate result");
let ClientToHost::DelegateResponse {
result:
WireResult::Ok {
value: DelegateResponse::ToolResult { result },
},
..
} = message
else {
panic!("expected a successful large delegate result");
};
assert_eq!(result.as_str().map(str::len), Some(256 * 1024));
}
assert!(harness.alive.load(Ordering::Acquire));
}
#[tokio::test] #[tokio::test]
async fn delegate_limit_returns_an_error_without_disconnecting() { async fn delegate_limit_returns_an_error_without_disconnecting() {
let mut harness = DriverHarness::start(); let mut harness = DriverHarness::start();
@@ -1,15 +1,14 @@
use codex_code_mode_protocol::host::TransportLane; use codex_code_mode_protocol::host::FramedReader;
use tokio::process::ChildStdout;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
use super::driver::DriverEvent; use super::driver::DriverEvent;
use super::transport::ConnectionReader;
pub(super) async fn drive_reader( pub(super) async fn drive_reader(
mut reader: ConnectionReader, mut reader: FramedReader<ChildStdout>,
events: mpsc::Sender<DriverEvent>, events: mpsc::Sender<DriverEvent>,
cancellation: CancellationToken, cancellation: CancellationToken,
lane: Option<TransportLane>,
) -> Result<(), String> { ) -> Result<(), String> {
loop { loop {
let message = tokio::select! { let message = tokio::select! {
@@ -21,11 +20,6 @@ pub(super) async fn drive_reader(
Ok(None) => return Err("code-mode host closed its stdout".to_string()), Ok(None) => return Err("code-mode host closed its stdout".to_string()),
Err(err) => return Err(format!("failed to read code-mode host message: {err}")), Err(err) => return Err(format!("failed to read code-mode host message: {err}")),
}; };
if let Some(lane) = lane
&& !message.allows_transport_lane(lane)
{
return Err("code-mode host sent a message on the wrong websocket lane".to_string());
}
events events
.send(DriverEvent::HostMessage(message)) .send(DriverEvent::HostMessage(message))
.await .await
@@ -1,103 +0,0 @@
use std::io;
use std::time::Duration;
use codex_code_mode_protocol::host::ClientToHost;
use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::FramedReader;
use codex_code_mode_protocol::host::FramedWriter;
use codex_code_mode_protocol::host::HostToClient;
use codex_websocket_client::WebSocketConnection;
use futures::SinkExt;
use futures::StreamExt;
use futures::stream::SplitSink;
use futures::stream::SplitStream;
use tokio::process::ChildStdin;
use tokio::process::ChildStdout;
use tokio_tungstenite::tungstenite::Message;
const WEBSOCKET_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
pub(super) enum ConnectionReader {
Stdio(FramedReader<ChildStdout>),
WebSocket(SplitStream<WebSocketConnection>),
}
pub(super) enum ConnectionWriter {
Stdio(FramedWriter<ChildStdin>),
WebSocket(SplitSink<WebSocketConnection, Message>),
}
impl ConnectionReader {
pub(super) async fn read(&mut self) -> io::Result<Option<HostToClient>> {
match self {
Self::Stdio(reader) => reader.read().await,
Self::WebSocket(reader) => loop {
match reader.next().await {
Some(Ok(Message::Binary(frame))) => {
return EncodedFrame::decode_framed(&frame).map(Some);
}
Some(Ok(Message::Ping(_) | Message::Pong(_))) => {}
Some(Ok(Message::Close(_))) | None => return Ok(None),
Some(Ok(Message::Text(_))) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"code-mode host websocket messages must be binary framed messages",
));
}
Some(Ok(Message::Frame(_))) => {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"code-mode host websocket returned an unexpected raw frame",
));
}
Some(Err(error)) => {
return Err(io::Error::other(format!(
"failed to read code-mode host websocket message: {error}"
)));
}
}
},
}
}
}
impl ConnectionWriter {
pub(super) async fn write(&mut self, message: &ClientToHost) -> io::Result<()> {
self.write_frame(EncodedFrame::encode(message)?).await
}
pub(super) async fn write_frame(&mut self, frame: EncodedFrame) -> io::Result<()> {
match self {
Self::Stdio(writer) => writer.write_frame(&frame).await,
Self::WebSocket(writer) => writer
.send(Message::Binary(frame.into_framed_bytes().into()))
.await
.map_err(|error| {
io::Error::other(format!(
"failed to write code-mode host websocket message: {error}"
))
}),
}
}
pub(super) async fn close(&mut self) -> io::Result<()> {
match self {
Self::Stdio(_) => Ok(()),
Self::WebSocket(writer) => {
tokio::time::timeout(WEBSOCKET_CLOSE_TIMEOUT, writer.close())
.await
.map_err(|_| {
io::Error::new(
io::ErrorKind::TimedOut,
"timed out closing code-mode host websocket connection",
)
})?
.map_err(|error| {
io::Error::other(format!(
"failed to close code-mode host websocket connection: {error}"
))
})
}
}
}
}
@@ -1,43 +1,12 @@
use std::io; use std::io;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use codex_code_mode_protocol::CodeModeSessionCellExecutionLimits;
use codex_code_mode_protocol::CodeModeSessionProvider; use codex_code_mode_protocol::CodeModeSessionProvider;
use codex_code_mode_protocol::ExecuteRequest;
use codex_code_mode_protocol::FunctionCallOutputContentItem;
use codex_code_mode_protocol::RuntimeResponse;
use codex_code_mode_protocol::host::Capability;
use codex_code_mode_protocol::host::CapabilitySet;
use codex_code_mode_protocol::host::ClientToHost;
use codex_code_mode_protocol::host::DUAL_WEBSOCKET_CAPABILITY;
use codex_code_mode_protocol::host::EncodedFrame;
use codex_code_mode_protocol::host::HostHello;
use codex_code_mode_protocol::host::HostRequest;
use codex_code_mode_protocol::host::HostResponse;
use codex_code_mode_protocol::host::HostToClient;
use codex_code_mode_protocol::host::ProtocolVersion;
use codex_code_mode_protocol::host::SESSION_RESOURCE_LIMITS_CAPABILITY;
use codex_code_mode_protocol::host::WireCellId;
use codex_code_mode_protocol::host::WireContentItem;
use codex_code_mode_protocol::host::WireResult;
use codex_code_mode_protocol::host::WireRuntimeResponse;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use futures::SinkExt;
use futures::StreamExt;
use pretty_assertions::assert_eq; use pretty_assertions::assert_eq;
use tokio::io::AsyncReadExt;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
use tokio::time::timeout;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message;
use super::ProcessOwnedCodeModeSession; use super::ProcessOwnedCodeModeSession;
use super::ProcessOwnedCodeModeSessionProvider; use super::ProcessOwnedCodeModeSessionProvider;
use super::WebSocketCodeModeSessionProvider;
use super::connection::ConnectionError; use super::connection::ConnectionError;
use crate::NoopCodeModeSessionDelegate; use crate::NoopCodeModeSessionDelegate;
@@ -86,274 +55,6 @@ fn missing_host_error_preserves_utf8_boundaries_when_truncating_the_path() {
assert!(displayed_path.len() <= 512); assert!(displayed_path.len() <= 512);
} }
#[tokio::test]
async fn websocket_provider_executes_over_shared_connector() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("websocket test listener should bind");
let websocket_url = format!(
"ws://{}",
listener
.local_addr()
.expect("websocket test listener should have an address")
);
let server = tokio::spawn(async move {
let (stream, _) = listener
.accept()
.await
.expect("websocket test host should accept a connection");
let mut websocket = accept_async(stream)
.await
.expect("websocket test host should complete the HTTP handshake");
while let Some(message) = websocket.next().await {
let message = message.expect("websocket test host should receive a valid message");
let frame = match message {
Message::Binary(frame) => frame,
Message::Ping(_) | Message::Pong(_) => continue,
Message::Close(_) => break,
Message::Text(_) | Message::Frame(_) => {
panic!("websocket test host received an unexpected message: {message:?}");
}
};
let request = EncodedFrame::decode_framed::<ClientToHost>(&frame)
.expect("websocket test host should decode a framed protocol message");
let responses = match request {
ClientToHost::ClientHello(hello) => {
let capability = Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY)
.expect("session-limit capability");
assert!(hello.optional_capabilities().contains(&capability));
assert_eq!(hello.required_capabilities(), &CapabilitySet::empty());
vec![HostToClient::HostHello(HostHello::new(
ProtocolVersion::V1,
CapabilitySet::empty(),
))]
}
ClientToHost::Request {
id,
request: HostRequest::OpenSession { session_id, .. },
} => vec![HostToClient::Response {
id,
result: WireResult::Ok {
value: HostResponse::SessionReady { session_id },
},
}],
ClientToHost::Request {
id,
request: HostRequest::Execute { request, .. },
} => {
assert_eq!(request.source, "text('shared connector')");
let cell_id = WireCellId::new("1");
vec![
HostToClient::Response {
id,
result: WireResult::Ok {
value: HostResponse::ExecutionStarted {
cell_id: cell_id.clone(),
},
},
},
HostToClient::InitialResponse {
id,
result: WireResult::Ok {
value: WireRuntimeResponse::Result {
cell_id,
content_items: vec![WireContentItem::InputText {
text: "shared connector".to_string(),
}],
error_text: None,
},
},
},
]
}
ClientToHost::Request {
id,
request: HostRequest::ShutdownSession { session_id },
} => vec![HostToClient::Response {
id,
result: WireResult::Ok {
value: HostResponse::SessionClosed { session_id },
},
}],
request => {
panic!("websocket test host received an unexpected request: {request:?}")
}
};
for response in responses {
let frame = EncodedFrame::encode(&response)
.expect("websocket test host should encode a framed response");
websocket
.send(Message::Binary(frame.into_framed_bytes().into()))
.await
.expect("websocket test host should send its response");
}
}
});
let provider = WebSocketCodeModeSessionProvider::with_http_client_factory(
websocket_url,
HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
);
let session = provider
.create_session(Arc::new(NoopCodeModeSessionDelegate))
.await
.expect("shared websocket connector should open a code-mode session");
let error = provider
.create_session_with_limits(
Arc::new(NoopCodeModeSessionDelegate),
CodeModeSessionCellExecutionLimits {
max_yield_time_ms: Some(250),
max_heap_size_bytes: None,
},
)
.await
.err()
.expect("legacy host should reject a limited session");
assert_eq!(
error,
format!(
"code-mode host does not support session resource limits: missing `{SESSION_RESOURCE_LIMITS_CAPABILITY}` capability"
)
);
let second_session = provider
.create_session(Arc::new(NoopCodeModeSessionDelegate))
.await
.expect("rejecting limited sessions should preserve the shared legacy-host connection");
second_session
.shutdown()
.await
.expect("second unlimited session should shut down");
drop(second_session);
let response = session
.execute(ExecuteRequest {
tool_call_id: "shared-websocket".to_string(),
enabled_tools: Vec::new(),
source: "text('shared connector')".to_string(),
yield_time_ms: None,
max_output_tokens: None,
})
.await
.expect("shared websocket connector should start a cell")
.initial_response()
.await
.expect("shared websocket connector should return a cell result");
assert_eq!(
response,
RuntimeResponse::Result {
cell_id: codex_code_mode_protocol::CellId::new("1".to_string()),
content_items: vec![FunctionCallOutputContentItem::InputText {
text: "shared connector".to_string(),
}],
error_text: None,
}
);
session
.shutdown()
.await
.expect("shared websocket connector should shut down its session");
drop(session);
drop(provider);
timeout(Duration::from_secs(5), server)
.await
.expect("websocket test host should disconnect promptly")
.expect("websocket test host task should succeed");
}
#[tokio::test]
async fn websocket_provider_fails_when_a_negotiated_bulk_connection_is_unavailable() {
let listener = TcpListener::bind("127.0.0.1:0")
.await
.expect("websocket test listener should bind");
let websocket_url = format!(
"ws://{}/?access_token=shared-token",
listener
.local_addr()
.expect("websocket test listener should have an address")
);
let server = tokio::spawn(async move {
let (stream, _) = listener
.accept()
.await
.expect("websocket test host should accept the first connection");
let mut control = accept_async(stream)
.await
.expect("first control websocket should connect");
let frame = control
.next()
.await
.expect("first client hello")
.expect("first websocket frame")
.into_data();
let ClientToHost::ClientHello(hello) =
EncodedFrame::decode_framed(&frame).expect("decode first client hello")
else {
panic!("expected first client hello");
};
let capability =
Capability::new(DUAL_WEBSOCKET_CAPABILITY).expect("dual websocket capability");
assert!(hello.optional_capabilities().contains(&capability));
let session_limits_capability =
Capability::new(SESSION_RESOURCE_LIMITS_CAPABILITY).expect("session-limit capability");
assert!(
hello
.optional_capabilities()
.contains(&session_limits_capability)
);
let hello = HostToClient::HostHello(
HostHello::new(
ProtocolVersion::V1,
CapabilitySet::try_new([capability.clone()]).expect("host capabilities"),
)
.with_bulk_connection_token("fallback-token".to_string()),
);
let frame = EncodedFrame::encode(&hello).expect("encode dual host hello");
control
.send(Message::Binary(frame.into_framed_bytes().into()))
.await
.expect("send dual host hello");
let (mut bulk, _) = listener
.accept()
.await
.expect("websocket test host should accept the bulk connection");
let mut request = [0_u8; 1024];
let request_len = bulk
.read(&mut request)
.await
.expect("read bulk websocket handshake");
let request = std::str::from_utf8(&request[..request_len]).expect("bulk HTTP request");
assert!(
request.starts_with("GET /bulk/fallback-token?access_token=shared-token HTTP/1.1\r\n")
);
bulk.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n")
.await
.expect("reject unavailable bulk websocket");
drop(bulk);
drop(control);
});
let provider = WebSocketCodeModeSessionProvider::new(websocket_url);
let error = match provider
.create_session(Arc::new(NoopCodeModeSessionDelegate))
.await
{
Ok(_) => panic!("provider should reject an unavailable negotiated bulk websocket"),
Err(error) => error,
};
assert!(
error.contains("404"),
"unexpected negotiated bulk websocket error: {error}"
);
drop(provider);
timeout(Duration::from_secs(5), server)
.await
.expect("negotiated bulk websocket test host should disconnect promptly")
.expect("negotiated bulk websocket test host task should succeed");
}
#[tokio::test] #[tokio::test]
async fn provider_returns_missing_host_error() { async fn provider_returns_missing_host_error() {
let provider = ProcessOwnedCodeModeSessionProvider::with_host_program( let provider = ProcessOwnedCodeModeSessionProvider::with_host_program(