tests: centralize in-flight turn cleanup helper (#12271)

## Why

Several tests intentionally exercise behavior while a turn is still
active. The cleanup sequence for those tests (`turn/interrupt` + waiting
for `codex/event/turn_aborted`) was duplicated across files, which made
the rationale easy to lose and the pattern easy to apply inconsistently.

This change centralizes that cleanup in one place with a single
explanatory doc comment.

## What Changed

### Added shared helper

In `codex-rs/app-server/tests/common/mcp_process.rs`:

- Added `McpProcess::interrupt_turn_and_wait_for_aborted(...)`.
- Added a doc comment explaining why explicit interrupt + terminal wait
is required for tests that intentionally leave a turn in-flight.

### Migrated call sites

Replaced duplicated interrupt/aborted blocks with the helper in:

- `codex-rs/app-server/tests/suite/v2/thread_resume.rs`
  - `thread_resume_rejects_history_when_thread_is_running`
  - `thread_resume_rejects_mismatched_path_when_thread_is_running`
- `codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs`
  - `turn_start_shell_zsh_fork_executes_command_v2`
-
`turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2`
- `codex-rs/app-server/tests/suite/v2/turn_steer.rs`
  - `turn_steer_returns_active_turn_id`

### Existing cleanup retained

In `codex-rs/app-server/tests/suite/v2/turn_start.rs`:

- `turn_start_accepts_local_image_input` continues to explicitly wait
for `turn/completed` so the turn lifecycle is fully drained before test
exit.

## Verification

- `cargo test -p codex-app-server`
This commit is contained in:
Michael Bolin
2026-02-19 17:47:34 -08:00
committed by GitHub
parent e4456840f5
commit 4fa304306b
6 changed files with 117 additions and 57 deletions

View File

@@ -61,6 +61,7 @@ use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnSteerParams;
@@ -572,6 +573,63 @@ impl McpProcess {
self.send_request("turn/interrupt", params).await
}
/// Deterministically clean up an intentionally in-flight turn.
///
/// Some tests assert behavior while a turn is still running. Returning from those tests
/// without an explicit interrupt + `codex/event/turn_aborted` wait can leave in-flight work
/// racing teardown and intermittently show up as `LEAK` in nextest.
///
/// In rare races, the turn can also fail or complete on its own after we send
/// `turn/interrupt` but before the server emits the interrupt response. The helper treats a
/// buffered matching `turn/completed` notification as sufficient terminal cleanup in that
/// case so teardown does not flap on timing.
pub async fn interrupt_turn_and_wait_for_aborted(
&mut self,
thread_id: String,
turn_id: String,
read_timeout: std::time::Duration,
) -> anyhow::Result<()> {
let interrupt_request_id = self
.send_turn_interrupt_request(TurnInterruptParams {
thread_id: thread_id.clone(),
turn_id: turn_id.clone(),
})
.await?;
match tokio::time::timeout(
read_timeout,
self.read_stream_until_response_message(RequestId::Integer(interrupt_request_id)),
)
.await
{
Ok(result) => {
result.with_context(|| "failed while waiting for turn interrupt response")?;
}
Err(err) => {
if self.pending_turn_completed_notification(&thread_id, &turn_id) {
return Ok(());
}
return Err(err).with_context(|| "timed out waiting for turn interrupt response");
}
}
match tokio::time::timeout(
read_timeout,
self.read_stream_until_notification_message("codex/event/turn_aborted"),
)
.await
{
Ok(result) => {
result.with_context(|| "failed while waiting for turn aborted notification")?;
}
Err(err) => {
if self.pending_turn_completed_notification(&thread_id, &turn_id) {
return Ok(());
}
return Err(err).with_context(|| "timed out waiting for turn aborted notification");
}
}
Ok(())
}
/// Send a `turn/steer` JSON-RPC request (v2).
pub async fn send_turn_steer_request(
&mut self,
@@ -940,6 +998,25 @@ impl McpProcess {
None
}
fn pending_turn_completed_notification(&self, thread_id: &str, turn_id: &str) -> bool {
self.pending_messages.iter().any(|message| {
let JSONRPCMessage::Notification(notification) = message else {
return false;
};
if notification.method != "turn/completed" {
return false;
}
let Some(params) = notification.params.as_ref() else {
return false;
};
let Ok(payload) = serde_json::from_value::<TurnCompletedNotification>(params.clone())
else {
return false;
};
payload.thread_id == thread_id && payload.turn.id == turn_id
})
}
fn message_request_id(message: &JSONRPCMessage) -> Option<&RequestId> {
match message {
JSONRPCMessage::Request(request) => Some(&request.id),