Files
codex/codex-rs/exec-server/tests/websocket.rs
T
adams-oai 28cade4f5a Cache empty remote skill discovery with filesystem watches (#57598)
## Why

Repeated repository skill discovery performs remote metadata requests even when no `.agents/skills` directories exist. Reuse verified empty results to avoid those round trips while watching for changes that require a new scan.

## What changed

- Add the Linux executor capability `fs_watch_missing`, the `fs/watchMissing` request, and `fs/missingChanged` notifications.
- Register watches in the background and reuse empty results only after the executor acknowledges coverage. Key reuse by filesystem, working directory, and project root markers.
- Invalidate observations on relevant filesystem or mount changes, watch supersession, or connection loss. Clear cached observations on forced reloads and cache resets.
- Retain live discovery while registration is pending or watching is unsupported, unavailable, or cannot verify the observation.

## Testing

Add tests for empty-result reuse and invalidation, skill directory creation before watch acknowledgement, unsupported executors, idle WebSocket notifications, reconnection, case aliases, and bind mounts and unmounts. Include an ignored manual latency comparison for repeated empty remote scans.

GitOrigin-RevId: 3ad499decefb184a99c068afa01d0dcdd8605c7f
2026-10-27 23:53:09 +00:00

252 lines
9.1 KiB
Rust

#![cfg(unix)]
mod common;
#[cfg(target_os = "linux")]
use anyhow::Context;
#[cfg(target_os = "linux")]
use codex_exec_server::Environment;
use codex_exec_server::InitializeParams;
use codex_exec_server::InitializeResponse;
use codex_exec_server_protocol::JSONRPCError;
use codex_exec_server_protocol::JSONRPCMessage;
use codex_exec_server_protocol::JSONRPCResponse;
#[cfg(target_os = "linux")]
use codex_utils_absolute_path::AbsolutePathBuf;
#[cfg(target_os = "linux")]
use codex_utils_path_uri::PathUri;
use common::exec_server::exec_server;
use pretty_assertions::assert_eq;
#[cfg(target_os = "linux")]
use std::time::Duration;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Error as WebSocketError;
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 uuid::Uuid;
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_reports_malformed_websocket_json_and_keeps_running() -> anyhow::Result<()> {
let mut server = exec_server().await?;
server.send_raw_text("not-json").await?;
let response = server
.wait_for_event(|event| matches!(event, JSONRPCMessage::Error(_)))
.await?;
let JSONRPCMessage::Error(JSONRPCError { id, error }) = response else {
panic!("expected malformed-message error response");
};
assert_eq!(id, codex_exec_server_protocol::RequestId::Integer(-1));
assert_eq!(error.code, -32600);
assert!(
error
.message
.starts_with("failed to parse websocket JSON-RPC message from exec-server websocket"),
"unexpected malformed-message error: {}",
error.message
);
let initialize_id = server
.send_request(
"initialize",
serde_json::to_value(InitializeParams {
client_name: "exec-server-test".to_string(),
resume_session_id: None,
})?,
)
.await?;
let response = server
.wait_for_event(|event| {
matches!(
event,
JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &initialize_id
)
})
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
panic!("expected initialize response after malformed input");
};
assert_eq!(id, initialize_id);
let initialize_response: InitializeResponse = serde_json::from_value(result)?;
Uuid::parse_str(&initialize_response.session_id)?;
server.shutdown().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_accepts_binary_websocket_json() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let initialize_id = codex_exec_server_protocol::RequestId::Integer(1);
let initialize = JSONRPCMessage::Request(codex_exec_server_protocol::JSONRPCRequest {
id: initialize_id.clone(),
method: "initialize".to_string(),
params: Some(serde_json::to_value(InitializeParams {
client_name: "exec-server-binary-test".to_string(),
resume_session_id: None,
})?),
trace: None,
});
server
.send_raw_binary(serde_json::to_vec(&initialize)?)
.await?;
let response = server
.wait_for_event(|event| {
matches!(
event,
JSONRPCMessage::Response(JSONRPCResponse { id, .. }) if id == &initialize_id
)
})
.await?;
let JSONRPCMessage::Response(JSONRPCResponse { id, result }) = response else {
panic!("expected initialize response for binary input");
};
assert_eq!(id, initialize_id);
let initialize_response: InitializeResponse = serde_json::from_value(result)?;
Uuid::parse_str(&initialize_response.session_id)?;
server.shutdown().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_echoes_request_id_in_websocket_handshake() -> anyhow::Result<()> {
let mut server = exec_server().await?;
for request_id in [Some(HeaderValue::from_static("opaque-request-id")), None] {
let mut request = server.websocket_url().into_client_request()?;
if let Some(request_id) = &request_id {
request
.headers_mut()
.insert("x-request-id", request_id.clone());
}
let (stream, response) = connect_async(request).await?;
assert_eq!(response.headers().get("x-request-id"), request_id.as_ref());
drop(stream);
}
server.shutdown().await?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn exec_server_rejects_browser_origin_websocket_handshake() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let mut request = server.websocket_url().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);
server.shutdown().await?;
Ok(())
}
#[cfg(target_os = "linux")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn missing_metadata_watch_invalidates_on_idle_websocket_and_recovery() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let mut proxy = server.disconnectable_websocket_proxy().await?;
let environment = Environment::create_for_tests(Some(proxy.websocket_url().to_owned()))?;
let fs = environment.get_filesystem();
let repo = tempfile::tempdir()?;
let root = AbsolutePathBuf::try_from(repo.path().canonicalize()?)?;
let skills = root.join(".agents/skills");
let marker = root.join(".git");
std::fs::write(&marker, "")?;
let watch = || {
fs.watch_missing(
vec![PathUri::from_abs_path(&skills)],
Some(PathUri::from_abs_path(&marker)),
)
};
let superseded = watch().await?.context("first watch")?;
let mut invalidated = watch()
.await?
.context("executor accepted empty observation")?;
assert!(superseded.await.is_err());
std::fs::write(root.join(".agents-backup"), "")?;
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut invalidated)
.await
.is_err()
);
std::fs::create_dir_all(&skills)?;
// The executor pushes this across the already open socket, without another RPC.
tokio::time::timeout(Duration::from_secs(5), invalidated).await??;
assert!(watch().await?.is_none());
std::fs::remove_dir(&skills)?;
let invalidated = watch().await?.context("watch after deletion")?;
std::fs::remove_file(&marker)?;
tokio::time::timeout(Duration::from_secs(5), invalidated).await??;
assert!(watch().await?.is_none());
std::fs::write(&marker, "")?;
let old = watch().await?.context("watch before disconnect")?;
proxy.pause_and_disconnect().await?;
assert!(
tokio::time::timeout(Duration::from_secs(5), old)
.await?
.is_err()
);
std::fs::create_dir(&skills)?;
proxy.resume()?;
assert!(watch().await?.is_none());
std::fs::remove_dir(&skills)?;
let new = watch()
.await?
.context("new watch on recovered connection")?;
std::fs::create_dir(&skills)?;
tokio::time::timeout(Duration::from_secs(5), new).await??;
server.shutdown().await?;
Ok(())
}
#[cfg(target_os = "linux")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn missing_metadata_watch_invalidates_on_possible_casefold_aliases() -> anyhow::Result<()> {
let mut server = exec_server().await?;
let environment = Environment::create_for_tests(Some(server.websocket_url().to_owned()))?;
let fs = environment.get_filesystem();
// U+017F (long s) folds to ASCII s in a casefolded ext4 directory.
for (parent, missing, created) in [
("", ".agents/skills", ".Agents"),
(".agents", "skills", "Skills"),
(".agents", "skills", "\u{017f}kills"),
(".agents", "\u{017f}kills", "skills"),
] {
let repo = tempfile::tempdir()?;
let root = AbsolutePathBuf::try_from(repo.path().canonicalize()?)?;
let parent = root.join(parent);
std::fs::create_dir_all(&parent)?;
let missing = PathUri::from_abs_path(&parent.join(missing));
let invalidated = fs
.watch_missing(vec![missing], None)
.await?
.context("executor accepted empty observation")?;
std::fs::create_dir(parent.join(created))?;
tokio::time::timeout(Duration::from_secs(5), invalidated)
.await
.with_context(|| format!("watch missed {created}"))??;
}
server.shutdown().await?;
Ok(())
}