Keep TUI reconnect attempts running until the shared deadline (#48318)

## Why

The five-attempt limit could stop reconnection before the existing 120-second budget expired, preventing recovery from longer temporary failures.

## What changed

Continue retrying with an eight-second delay after the initial backoff until reconnection succeeds or the shared deadline expires.

## Testing

Update the exhaustion test to require the full 120-second budget. Extend the integration test to recover after five failed `thread/resume` attempts and verify draft preservation and notification delivery.

GitOrigin-RevId: 321d4ad100dbec1254bfe8aafd67ff63fb9a6e93
This commit is contained in:
Eric Traut
2026-09-26 00:51:21 +00:00
committed by copyberry
parent dfdb40cd0b
commit 25270df261
3 changed files with 8 additions and 7 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ pub(super) async fn reconnect(
// Connecting already has transport deadlines. Give healthy history/inventory hydration one
// shared budget instead of repeatedly discarding its progress on a short per-attempt timer.
let deadline = tokio::time::Instant::now() + Duration::from_secs(/*secs*/ 120);
for delay in [0, 1, 2, 4, 8] {
for delay in [0, 1, 2, 4].into_iter().chain(std::iter::repeat(/*elt*/ 8)) {
let attempt = async {
tokio::time::sleep(Duration::from_secs(delay)).await;
let client = crate::app_server_connection::connect(&target).await?;
@@ -618,7 +618,7 @@ async fn reconnect_exhaustion_and_unknown_initial_thread_stay_offline() -> Resul
.is_err()
);
}
assert!((15..=65).contains(&start.elapsed().as_secs()));
assert_eq!(start.elapsed().as_secs(), 120);
app.begin_reconnect();
app.chat_widget.reconnect_failed();
assert_snapshot!(
+6 -5
View File
@@ -43,7 +43,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
"cliVersion": "0.0.0", "source": "cli", "turns": [{"id": "running", "items": [], "status": "inProgress", "error": null}]
});
// The first connection checks daemon compatibility before TUI startup.
for connection in -1..3 {
for connection in -1..7 {
let mut socket = loop {
let (stream, _) = listener.accept().await?;
// Startup probes the default daemon socket before opening its WebSocket.
@@ -69,7 +69,8 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
if connection == 1 && request.method == "initialize" {
restore_rx.take().unwrap().await?;
}
if connection == 1 && request.method == "thread/resume" {
// Keep failing past the original five-attempt limit.
if (1..=5).contains(&connection) && request.method == "thread/resume" {
socket
.send(Message::Text(
json!({"id": request.id, "error": {
@@ -131,7 +132,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
.into(),
))
.await?;
if connection == 2 && request.method == "thread/resume" {
if connection == 6 && request.method == "thread/resume" {
// Keep the recovered turn running: its output must appear without waiting
// for turn/completed or rebuilding the transcript.
socket
@@ -162,7 +163,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
"preserved-draft!",
"fresh-notification-after-reconnect",
] {
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30);
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 60);
while !terminal.screen_contains(expected) && Instant::now() < deadline {
terminal.read_output(Duration::from_millis(/*millis*/ 20))?;
}
@@ -205,7 +206,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
.iter()
.filter(|method| *method == "thread/resume")
.count(),
2
6
);
assert!(!methods.iter().any(|method| method == "turn/start"));
Ok(())