Skip to content

Commit f481f5f

Browse files
committed
fix(core): add timeout to compaction operations to prevent UI hangs
Both inline and remote compaction could hang indefinitely when the model API was unresponsive, leaving the TUI stuck on "Automatically compacting context" with no way to recover. Wrap `drain_to_completed()` and `compact_conversation_history()` with a 120-second `tokio::time::timeout`. On timeout the existing `CodexErr::Timeout` variant is returned, which `is_retryable()` treats as retryable, so the existing retry-with-backoff loop handles it gracefully instead of blocking the UI forever. Closes openai#14472
1 parent 937cb50 commit f481f5f

3 files changed

Lines changed: 78 additions & 30 deletions

File tree

‎codex-rs/core/src/compact.rs‎

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use std::sync::Arc;
2+
use std::time::Duration;
23

34
use crate::ModelProviderInfo;
45
use crate::Prompt;
@@ -26,8 +27,14 @@ use codex_utils_output_truncation::TruncationPolicy;
2627
use codex_utils_output_truncation::approx_token_count;
2728
use codex_utils_output_truncation::truncate_text;
2829
use futures::prelude::*;
30+
use tokio::time::timeout;
2931
use tracing::error;
3032

33+
/// Maximum time to wait for a compaction operation to complete before
34+
/// treating it as a timeout. This prevents the UI from hanging
35+
/// indefinitely when the model API is unresponsive.
36+
pub(crate) const COMPACTION_TIMEOUT: Duration = Duration::from_secs(120);
37+
3138
pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt.md");
3239
pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md");
3340
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
@@ -126,14 +133,21 @@ async fn run_compact_task_inner(
126133
..Default::default()
127134
};
128135
let turn_metadata_header = turn_context.turn_metadata_state.current_header_value();
129-
let attempt_result = drain_to_completed(
130-
&sess,
131-
turn_context.as_ref(),
132-
&mut client_session,
133-
turn_metadata_header.as_deref(),
134-
&prompt,
136+
let attempt_result = match timeout(
137+
COMPACTION_TIMEOUT,
138+
drain_to_completed(
139+
&sess,
140+
turn_context.as_ref(),
141+
&mut client_session,
142+
turn_metadata_header.as_deref(),
143+
&prompt,
144+
),
135145
)
136-
.await;
146+
.await
147+
{
148+
Ok(inner) => inner,
149+
Err(_elapsed) => Err(CodexErr::Timeout),
150+
};
137151

138152
match attempt_result {
139153
Ok(()) => {

‎codex-rs/core/src/compact_remote.rs‎

Lines changed: 32 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@ use codex_protocol::items::TurnItem;
2121
use codex_protocol::models::BaseInstructions;
2222
use codex_protocol::models::ResponseItem;
2323
use futures::TryFutureExt;
24+
use tokio::time::timeout;
2425
use tokio_util::sync::CancellationToken;
2526
use tracing::error;
2627
use tracing::info;
2728

29+
use crate::compact::COMPACTION_TIMEOUT;
30+
2831
pub(crate) async fn run_inline_remote_auto_compact_task(
2932
sess: Arc<Session>,
3033
turn_context: Arc<TurnContext>,
@@ -114,29 +117,35 @@ async fn run_remote_compact_task_inner_impl(
114117
output_schema: None,
115118
};
116119

117-
let mut new_history = sess
118-
.services
119-
.model_client
120-
.compact_conversation_history(
121-
&prompt,
122-
&turn_context.model_info,
123-
turn_context.reasoning_effort,
124-
turn_context.reasoning_summary,
125-
&turn_context.session_telemetry,
126-
)
127-
.or_else(|err| async {
128-
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
129-
let compact_request_log_data =
130-
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
131-
log_remote_compact_failure(
132-
turn_context,
133-
&compact_request_log_data,
134-
total_usage_breakdown,
135-
&err,
136-
);
137-
Err(err)
138-
})
139-
.await?;
120+
let mut new_history = match timeout(
121+
COMPACTION_TIMEOUT,
122+
sess.services
123+
.model_client
124+
.compact_conversation_history(
125+
&prompt,
126+
&turn_context.model_info,
127+
turn_context.reasoning_effort,
128+
turn_context.reasoning_summary,
129+
&turn_context.session_telemetry,
130+
)
131+
.or_else(|err| async {
132+
let total_usage_breakdown = sess.get_total_token_usage_breakdown().await;
133+
let compact_request_log_data =
134+
build_compact_request_log_data(&prompt.input, &prompt.base_instructions.text);
135+
log_remote_compact_failure(
136+
turn_context,
137+
&compact_request_log_data,
138+
total_usage_breakdown,
139+
&err,
140+
);
141+
Err(err)
142+
}),
143+
)
144+
.await
145+
{
146+
Ok(result) => result?,
147+
Err(_elapsed) => return Err(CodexErr::Timeout),
148+
};
140149
new_history = process_compacted_history(
141150
sess.as_ref(),
142151
turn_context.as_ref(),

‎codex-rs/core/src/compact_tests.rs‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -559,3 +559,28 @@ fn insert_initial_context_before_last_real_user_or_summary_keeps_compaction_last
559559
];
560560
assert_eq!(refreshed, expected);
561561
}
562+
563+
#[tokio::test]
564+
async fn compaction_timeout_produces_retryable_timeout_error() {
565+
use std::time::Duration;
566+
use tokio::time::timeout;
567+
568+
// Simulate a compaction call that hangs indefinitely.
569+
let hanging_future = futures::future::pending::<CodexResult<()>>();
570+
571+
let result = match timeout(Duration::from_millis(50), hanging_future).await {
572+
Ok(inner) => inner,
573+
Err(_elapsed) => Err(CodexErr::Timeout),
574+
};
575+
576+
assert!(result.is_err());
577+
let err = result.unwrap_err();
578+
assert!(
579+
matches!(err, CodexErr::Timeout),
580+
"expected CodexErr::Timeout, got {err:?}"
581+
);
582+
assert!(
583+
err.is_retryable(),
584+
"Timeout should be retryable so the compact retry loop can handle it"
585+
);
586+
}

0 commit comments

Comments
 (0)