Cache executor metadata from initialization (#40343)

## What changed

- Include `environmentInfo` in the exec-server `initialize` response and seed the Rust client's metadata cache from it.
- Fall back to one lazy `environment/info` request when connecting to older servers that omit initialization metadata, then share the cached result across client clones and session recovery.
- Keep app-server `environment/info` requests uncached so they continue to probe the executor.

## Testing

- Cover initialization metadata, the legacy-server fallback, shared caching, accepted WebSocket environments, and repeated uncached app-server probes.

GitOrigin-RevId: 380132d9db183998d73e8c6b0954e4d70c7e8406
This commit is contained in:
cgst-oai
2026-08-24 06:43:07 +00:00
committed by copyberry
parent 068c49f075
commit 2df6705423
14 changed files with 249 additions and 13 deletions
+15 -2
View File
@@ -153,9 +153,22 @@ Request params:
Response:
```json
{}
{
"sessionId": "00000000-0000-4000-8000-000000000001",
"environmentInfo": {
"shell": { "name": "bash", "path": "/bin/bash" },
"cwd": "file:///workspace"
}
}
```
`environmentInfo` contains the same executor metadata returned by
`environment/info`, so clients can use it without a second request.
Rust clients cache this metadata for the client's lifetime, including session
resumption. If initialization omits it, the first metadata request fetches and
caches `environment/info`.
### `initialized`
Handshake acknowledgement notification sent by the client after a successful
@@ -429,7 +442,7 @@ Initialize:
```json
{"id":1,"method":"initialize","params":{"clientName":"example-client"}}
{"id":1,"result":{}}
{"id":1,"result":{"sessionId":"00000000-0000-4000-8000-000000000001","environmentInfo":{"shell":{"name":"bash","path":"/bin/bash"},"cwd":"file:///tmp"}}}
{"method":"initialized","params":{}}
```
+98 -1
View File
@@ -264,6 +264,8 @@ struct Inner {
// Keep admission shared while recovered transports finish older requests.
rpc_inbound_request_slots: Arc<Semaphore>,
session_id: OnceLock<String>,
/// Caches metadata from initialization or the first successful info request for this client's lifetime.
environment_info: OnceCell<EnvironmentInfo>,
reconnect_strategy: Option<ExecServerReconnectStrategy>,
}
@@ -765,7 +767,18 @@ impl ExecServerClient {
self.call(EXEC_METHOD, &params).await
}
/// Returns cached executor metadata, fetching it lazily if initialization omitted it.
pub async fn environment_info(&self) -> Result<EnvironmentInfo, ExecServerError> {
self.inner
.environment_info
.get_or_try_init(|| self.force_environment_info())
.await
.cloned()
}
/// Fetches executor metadata over RPC without reading or updating the cache.
// TODO: Remove after app-server migrates off this call.
pub async fn force_environment_info(&self) -> Result<EnvironmentInfo, ExecServerError> {
let rpc_client = self.rpc_client().await?;
map_rpc_call_result(
rpc_client
@@ -1117,6 +1130,7 @@ impl ExecServerClient {
http_body_stream_next_id: AtomicU64::new(1),
rpc_inbound_request_slots: Arc::new(Semaphore::new(MAX_IN_FLIGHT_SERVER_CALLS)),
session_id,
environment_info: OnceCell::new(),
reconnect_strategy,
});
let client = Self {
@@ -1127,7 +1141,13 @@ impl ExecServerClient {
// before initialize returns. Drain them immediately so a burst cannot
// fill the bounded event channel and block the initialize response.
client.spawn_rpc_reader(&rpc_client, events_rx);
client.initialize_rpc(&rpc_client, options).await?;
let initialize_response = client.initialize_rpc(&rpc_client, options).await?;
if let Some(info) = initialize_response.environment_info {
assert!(
client.inner.environment_info.set(info).is_ok(),
"new client metadata cache must be empty"
);
}
Ok(client)
}
@@ -1740,6 +1760,7 @@ mod tests {
use crate::protocol::EXEC_OUTPUT_DELTA_METHOD;
use crate::protocol::EXEC_READ_METHOD;
use crate::protocol::EXEC_WRITE_METHOD;
use crate::protocol::EnvironmentInfo;
use crate::protocol::ExecClosedNotification;
use crate::protocol::ExecExitedNotification;
use crate::protocol::ExecOutputDeltaNotification;
@@ -1796,6 +1817,7 @@ mod tests {
id: initialize.id,
result: serde_json::to_value(InitializeResponse {
session_id: "trace-test".to_string(),
environment_info: None,
})
.expect("initialize response should serialize"),
}),
@@ -1942,6 +1964,21 @@ mod tests {
websocket: &mut WebSocketStream<TcpStream>,
session_id: &str,
expected_resume_session_id: Option<&str>,
) {
complete_websocket_initialize_with_environment_info(
websocket,
session_id,
expected_resume_session_id,
/*environment_info*/ None,
)
.await;
}
async fn complete_websocket_initialize_with_environment_info(
websocket: &mut WebSocketStream<TcpStream>,
session_id: &str,
expected_resume_session_id: Option<&str>,
environment_info: Option<EnvironmentInfo>,
) {
let initialize = read_jsonrpc_websocket(websocket).await;
let request = match initialize {
@@ -1961,6 +1998,7 @@ mod tests {
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: session_id.to_string(),
environment_info,
})
.expect("initialize response should serialize"),
}),
@@ -2184,6 +2222,7 @@ mod tests {
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: "session-1".to_string(),
environment_info: None,
})
.expect("initialize response should serialize"),
}),
@@ -2329,6 +2368,7 @@ mod tests {
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: "session-1".to_string(),
environment_info: None,
})
.expect("initialize response should serialize"),
}),
@@ -2397,6 +2437,61 @@ mod tests {
server.await.expect("server task should finish");
}
#[test_case::test_case(Some(EnvironmentInfo::local()); "from_initialize")]
#[test_case::test_case(None; "legacy_server")]
#[tokio::test]
async fn environment_info_is_cached(
initial_environment_info: Option<EnvironmentInfo>,
) -> anyhow::Result<()> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let websocket_url = format!("ws://{}", listener.local_addr()?);
let expected_info = initial_environment_info
.clone()
.unwrap_or_else(EnvironmentInfo::local);
let server_info = expected_info.clone();
let server = tokio::spawn(async move {
let mut websocket = accept_websocket(&listener).await;
complete_websocket_initialize_with_environment_info(
&mut websocket,
"session-1",
/*expected_resume_session_id*/ None,
initial_environment_info.clone(),
)
.await;
if initial_environment_info.is_none() {
let JSONRPCMessage::Request(request) = read_jsonrpc_websocket(&mut websocket).await
else {
panic!("expected environment info request");
};
assert_eq!(request.method, "environment/info");
write_jsonrpc_websocket(
&mut websocket,
JSONRPCMessage::Response(JSONRPCResponse {
id: request.id,
result: serde_json::to_value(server_info)
.expect("environment info should serialize"),
}),
)
.await;
}
});
let client = ExecServerClient::connect_websocket(RemoteExecServerConnectArgs {
websocket_url,
client_name: "metadata-test".to_string(),
connect_timeout: Duration::from_secs(1),
initialize_timeout: Duration::from_secs(1),
resume_session_id: None,
http_client_factory: HttpClientFactory::new(OutboundProxyPolicy::ReqwestDefault),
})
.await?;
assert_eq!(client.environment_info().await?, expected_info);
server.await?;
// The server is gone, so a cloned client must use the shared cache.
assert_eq!(client.clone().environment_info().await?, expected_info);
Ok(())
}
#[tokio::test]
async fn remote_websocket_client_resumes_session() {
let listener = TcpListener::bind("127.0.0.1:0")
@@ -2615,6 +2710,7 @@ mod tests {
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: "session-1".to_string(),
environment_info: None,
})
.expect("initialize response should serialize"),
}),
@@ -2932,6 +3028,7 @@ mod tests {
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: "session-1".to_string(),
environment_info: None,
})
.expect("initialize response should serialize"),
}),
+15
View File
@@ -889,6 +889,7 @@ impl Environment {
}
/// Returns environment information from the selected execution/filesystem environment.
/// Remote metadata is cached for the current client's lifetime.
#[tracing::instrument(
name = "exec_server.environment.info",
skip_all,
@@ -901,6 +902,20 @@ impl Environment {
}
}
/// Fetches uncached metadata, connecting or waiting for recovery as needed.
// TODO: Remove after app-server migrates off of force_environment_info.
#[tracing::instrument(
name = "exec_server.environment.force_info",
skip_all,
fields(remote = self.is_remote())
)]
pub async fn force_info(&self) -> Result<EnvironmentInfo, ExecServerError> {
match &self.remote_client {
Some(client) => client.get().await?.force_environment_info().await,
None => Ok(EnvironmentInfo::local()),
}
}
/// Reads selected executor-local configuration fields for this environment.
pub async fn read_environment_config(
&self,
@@ -614,6 +614,7 @@ async fn complete_websocket_initialize(websocket: &mut WebSocketStream<TcpStream
id: request.id,
result: serde_json::to_value(InitializeResponse {
session_id: "session-1".to_string(),
environment_info: None,
})
.expect("initialize response should serialize"),
}),
+4 -1
View File
@@ -156,7 +156,10 @@ impl ExecServerHandler {
.session
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(session);
Ok(InitializeResponse { session_id })
Ok(InitializeResponse {
session_id,
environment_info: Some(EnvironmentInfo::local()),
})
}
pub(crate) fn initialized(&self) -> Result<(), String> {
@@ -8,6 +8,7 @@ use axum::extract::State;
use axum::extract::WebSocketUpgrade;
use axum::response::IntoResponse;
use axum::routing::any;
use codex_exec_server::EnvironmentInfo;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::EnvironmentObservedStatus;
use codex_exec_server::EnvironmentStatus;
@@ -77,6 +78,25 @@ async fn accepted_websocket_rejects_initial_resume_session_id() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn accepted_websocket_environment_info_uses_initialization_metadata() -> Result<()> {
let (websocket_url, mut accepted_sockets, server_task) = start_acceptor().await?;
let (_socket, manager) =
connect_executor(&websocket_url, &mut accepted_sockets, "session-1").await?;
let environment = manager
.default_environment()
.context("accepted environment")?;
assert_eq!(
timeout(TEST_TIMEOUT, environment.info()).await??,
EnvironmentInfo::local()
);
server_task.abort();
let _ = server_task.await;
Ok(())
}
#[tokio::test]
async fn accepted_websocket_environment_is_ready_immediately() -> Result<()> {
let (websocket_url, mut accepted_sockets, server_task) = start_acceptor().await?;
@@ -430,6 +450,7 @@ where
id,
result: serde_json::to_value(InitializeResponse {
session_id: session_id.to_string(),
environment_info: Some(EnvironmentInfo::local()),
})?,
}),
)
@@ -1440,6 +1440,7 @@ impl JsonRpcPeer {
request.id,
InitializeResponse {
session_id: "session-1".to_string(),
environment_info: None,
},
)
.await?;
+5
View File
@@ -1,5 +1,6 @@
mod common;
use codex_exec_server::EnvironmentInfo;
use codex_exec_server::InitializeParams;
use codex_exec_server::InitializeResponse;
use codex_exec_server_protocol::JSONRPCError;
@@ -31,6 +32,10 @@ async fn exec_server_accepts_initialize() -> anyhow::Result<()> {
assert_eq!(id, initialize_id);
let initialize_response: InitializeResponse = serde_json::from_value(result)?;
Uuid::parse_str(&initialize_response.session_id)?;
assert_eq!(
initialize_response.environment_info,
Some(EnvironmentInfo::local())
);
server.shutdown().await?;
Ok(())
+5 -7
View File
@@ -277,9 +277,11 @@ async fn pending_noise_environment_connects_and_reconnects_after_ready_report()
second_reconnected_websocket,
Arc::new(Mutex::new(Vec::new())),
));
let recovered_info = timeout(TEST_TIMEOUT, environment.info())
.await
.context("pending Noise environment should reconnect")??;
assert_eq!(
next_connection_state(&mut connection_state).await?,
EnvironmentConnectionState::Connected
);
let recovered_info = environment.info().await?;
assert_eq!(recovered_info, initial_info);
assert_eq!(
@@ -287,10 +289,6 @@ async fn pending_noise_environment_connects_and_reconnects_after_ready_report()
selected_capability_roots
);
assert_eq!(provider.calls(), 2);
assert_eq!(
next_connection_state(&mut connection_state).await?,
EnvironmentConnectionState::Connected
);
registry.verify().await;
second_relay.abort();