Skip to content

Commit 40f2717

Browse files
committed
fix(peer-device): recover from weak network stalls
1 parent 61e807d commit 40f2717

19 files changed

Lines changed: 768 additions & 81 deletions

File tree

docs/architecture/peer-device-mode.md

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,19 @@ FS) and must not be mixed with Peer Device Mode.
4949

5050
- Controller: `PeerDeviceTransportAdapter` wraps product `invoke` as
5151
`RemoteCommand::HostInvoke` over `account_device_rpc`.
52-
- HostInvoke on the controller is **priority-queued** (effectively unbounded
53-
concurrency, `i32::MAX` in flight). Session restore / session-list / dialog /
54-
workspace-startup commands outrank background `git_*` / `ssh_*` / `lsp_*` /
55-
`search_*` / FS / canvas / editor RPCs so hydrate is not starved into relay
56-
HTTP 504s. Terminal commands are always interactive priority, and one slot is
57-
kept free from low-priority background work so input cannot be trapped behind
58-
slow polling requests.
52+
- HostInvoke on the controller is **priority-queued** with four requests in
53+
flight. Session restore / session-list / dialog / workspace-startup commands
54+
outrank background `git_*` / `ssh_*` / `lsp_*` / `search_*` / FS / canvas /
55+
editor RPCs so hydrate is not starved into relay HTTP 504s. Terminal commands
56+
are always interactive priority, and one slot is kept free from normal and
57+
low-priority work so input cannot be trapped behind slow polling requests.
58+
- Idempotent read HostInvokes use a 10s per-attempt deadline and at most two
59+
exponential-backoff retries. Mutating commands use a 30s deadline without
60+
automatic replay because a timed-out mutation has an unknown outcome. The
61+
desktop `account_device_rpc` command enforces the requested deadline around
62+
the native HTTP future; the controller's Promise deadline is not merely a UI
63+
timer. Failed session-list loads leave the spinner and expose an explicit
64+
retry action.
5965
- While Peer Mode is active, background noise is reduced further:
6066
- controller-local SSH heartbeats and remote-workspace auto-reconnect pause
6167
- Git / FilesPanel window-focus refresh pauses
@@ -82,7 +88,10 @@ FS) and must not be mixed with Peer Device Mode.
8288
controller that attached after turn/round lifecycle events or crossed a
8389
transient relay gap. The host overlays its authoritative in-memory session
8490
state onto the persisted view so an executing turn is not misclassified as
85-
interrupted history.
91+
interrupted history. Continuous host output is checkpointed at least once
92+
per 2s coalescing window. A controller accepts an active snapshot before the
93+
stale-stream deadline only when its rounds, streams, and tools provably move
94+
forward; an older persisted snapshot cannot overwrite newer DeviceEvents.
8695
- CLI Peer Host forwards only turns submitted through Peer Host and linked
8796
child turns. A background-result follow-up inherits ownership only when its
8897
Core-internal metadata identifies the exact tracked parent and source child
@@ -105,9 +114,10 @@ FS) and must not be mixed with Peer Device Mode.
105114
enqueue attempt. An explicit disconnect still restores the local controller
106115
UI, but reports a warning when host cancellation was not confirmed. This
107116
boundary does not change the Relay envelope or add ACK or replay.
108-
- Relay `POST /api/devices/:id/rpc` waits up to **120s** for the peer response;
109-
reverse proxies in front of the relay must use a matching (or higher) read
110-
timeout or they will return 504 first.
117+
- Relay `POST /api/devices/:id/rpc` still permits up to **120s** for generic
118+
callers. Peer controllers normally cancel earlier through their per-command
119+
10s/30s deadlines; reverse proxies must still accommodate any other caller
120+
that relies on the Relay maximum.
111121

112122
## Workspace directory picking
113123

src/apps/desktop/src/api/remote_connect_api.rs

Lines changed: 85 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -3405,18 +3405,35 @@ pub async fn account_delete_device(targetDeviceId: String) -> Result<(), String>
34053405
/// which routes it to the target device's WS. The target executes it
34063406
/// and the response is returned (decrypted).
34073407
/// Returns the decrypted response JSON.
3408+
const ACCOUNT_DEVICE_RPC_DEFAULT_TIMEOUT_MS: u64 = 120_000;
3409+
const ACCOUNT_DEVICE_RPC_MIN_TIMEOUT_MS: u64 = 1_000;
3410+
3411+
fn account_device_rpc_timeout_ms(requested: Option<u64>) -> u64 {
3412+
requested
3413+
.unwrap_or(ACCOUNT_DEVICE_RPC_DEFAULT_TIMEOUT_MS)
3414+
.clamp(
3415+
ACCOUNT_DEVICE_RPC_MIN_TIMEOUT_MS,
3416+
ACCOUNT_DEVICE_RPC_DEFAULT_TIMEOUT_MS,
3417+
)
3418+
}
3419+
34083420
#[tauri::command]
34093421
pub async fn account_device_rpc(
34103422
target_device_id: String,
34113423
command_json: String,
3424+
timeout_ms: Option<u64>,
34123425
) -> Result<String, String> {
34133426
let account_generation = account_context_generation();
34143427
let (session, relay_url) = read_account_context_for_generation(account_generation).await?;
34153428
let client = AccountClient::new();
3416-
let response = client
3417-
.device_rpc(&relay_url, &session, &target_device_id, &command_json)
3418-
.await
3419-
.map_err(|e| format!("{e}"))?;
3429+
let timeout_ms = account_device_rpc_timeout_ms(timeout_ms);
3430+
let response = tokio::time::timeout(
3431+
std::time::Duration::from_millis(timeout_ms),
3432+
client.device_rpc(&relay_url, &session, &target_device_id, &command_json),
3433+
)
3434+
.await
3435+
.map_err(|_| format!("device RPC timed out after {timeout_ms}ms"))?
3436+
.map_err(|e| format!("{e}"))?;
34203437
if !account_context_matches(account_generation, &session.token).await {
34213438
return Err("account context changed".to_string());
34223439
}
@@ -3706,58 +3723,56 @@ async fn account_auto_sync_inner(
37063723
);
37073724

37083725
let completed = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
3709-
let upload_outcomes: Vec<Result<(String, String, i64), String>> =
3710-
stream::iter(pending_uploads)
3711-
.map(|(session_id, bundle_json, hash)| {
3712-
let client = AccountClient::new();
3713-
let relay_url = relay_url.clone();
3714-
let acct_session = acct_session.clone();
3715-
let completed = completed.clone();
3716-
async move {
3717-
if ensure_account_auto_sync_current(sync_operation_id).is_err() {
3718-
return Err("account sync cancelled".to_string());
3719-
}
3720-
let result = match await_account_auto_sync(
3721-
sync_operation_id,
3722-
client.upload_session(&relay_url, &acct_session, &session_id, &bundle_json),
3723-
)
3724-
.await
3725-
{
3726-
Ok(result) => result,
3727-
Err(e) => return Err(e),
3728-
};
3729-
match result {
3730-
Ok(version) => {
3731-
let done =
3732-
completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
3733-
let percent = if upload_total == 0 {
3734-
95u8
3735-
} else {
3736-
20 + ((75 * done) / upload_total) as u8
3737-
};
3738-
if ensure_account_auto_sync_current(sync_operation_id).is_err() {
3739-
return Err("account sync cancelled".to_string());
3740-
}
3741-
emit_sync_progress(
3742-
sync_operation_id,
3743-
"exporting_sessions",
3744-
percent.min(95),
3745-
Some(done),
3746-
Some(upload_total),
3747-
Some(session_id.as_str()),
3748-
);
3749-
Ok((session_id, hash, version))
3750-
}
3751-
Err(e) => {
3752-
log::warn!("Auto-sync upload {session_id} failed: {e}");
3753-
Err(format!("{session_id}: {e}"))
3726+
let upload_outcomes: Vec<Result<(String, String, i64), String>> = stream::iter(pending_uploads)
3727+
.map(|(session_id, bundle_json, hash)| {
3728+
let client = AccountClient::new();
3729+
let relay_url = relay_url.clone();
3730+
let acct_session = acct_session.clone();
3731+
let completed = completed.clone();
3732+
async move {
3733+
if ensure_account_auto_sync_current(sync_operation_id).is_err() {
3734+
return Err("account sync cancelled".to_string());
3735+
}
3736+
let result = match await_account_auto_sync(
3737+
sync_operation_id,
3738+
client.upload_session(&relay_url, &acct_session, &session_id, &bundle_json),
3739+
)
3740+
.await
3741+
{
3742+
Ok(result) => result,
3743+
Err(e) => return Err(e),
3744+
};
3745+
match result {
3746+
Ok(version) => {
3747+
let done = completed.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1;
3748+
let percent = if upload_total == 0 {
3749+
95u8
3750+
} else {
3751+
20 + ((75 * done) / upload_total) as u8
3752+
};
3753+
if ensure_account_auto_sync_current(sync_operation_id).is_err() {
3754+
return Err("account sync cancelled".to_string());
37543755
}
3756+
emit_sync_progress(
3757+
sync_operation_id,
3758+
"exporting_sessions",
3759+
percent.min(95),
3760+
Some(done),
3761+
Some(upload_total),
3762+
Some(session_id.as_str()),
3763+
);
3764+
Ok((session_id, hash, version))
3765+
}
3766+
Err(e) => {
3767+
log::warn!("Auto-sync upload {session_id} failed: {e}");
3768+
Err(format!("{session_id}: {e}"))
37553769
}
37563770
}
3757-
})
3758-
.buffer_unordered(UPLOAD_CONCURRENCY)
3759-
.collect()
3760-
.await;
3771+
}
3772+
})
3773+
.buffer_unordered(UPLOAD_CONCURRENCY)
3774+
.collect()
3775+
.await;
37613776

37623777
ensure_account_auto_sync_current(sync_operation_id)?;
37633778

@@ -4346,6 +4361,23 @@ mod sync_state_tests {
43464361
);
43474362
}
43484363

4364+
#[test]
4365+
fn device_rpc_timeout_is_bounded_for_peer_requests() {
4366+
assert_eq!(
4367+
account_device_rpc_timeout_ms(None),
4368+
ACCOUNT_DEVICE_RPC_DEFAULT_TIMEOUT_MS
4369+
);
4370+
assert_eq!(account_device_rpc_timeout_ms(Some(10_000)), 10_000);
4371+
assert_eq!(
4372+
account_device_rpc_timeout_ms(Some(100)),
4373+
ACCOUNT_DEVICE_RPC_MIN_TIMEOUT_MS
4374+
);
4375+
assert_eq!(
4376+
account_device_rpc_timeout_ms(Some(u64::MAX)),
4377+
ACCOUNT_DEVICE_RPC_DEFAULT_TIMEOUT_MS
4378+
);
4379+
}
4380+
43494381
#[test]
43504382
fn login_result_exposes_only_an_opaque_pending_owner() {
43514383
let value = serde_json::to_value(AccountLoginResult {

src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -187,12 +187,14 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
187187
nextCursor?: string;
188188
hasMore: boolean;
189189
isLoading: boolean;
190+
loadError: boolean;
190191
}>({
191192
totalTopLevelCount: null,
192193
syncedTopLevelCount: null,
193194
nextCursor: undefined,
194195
hasMore: false,
195196
isLoading: false,
197+
loadError: false,
196198
});
197199
const [openMenuSessionId, setOpenMenuSessionId] = useState<string | null>(null);
198200
const [sessionMenuPosition, setSessionMenuPosition] = useState<{ top: number; left: number } | null>(null);
@@ -291,6 +293,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
291293
nextCursor: undefined,
292294
hasMore: false,
293295
isLoading: false,
296+
loadError: false,
294297
});
295298
}, [workspaceId, workspacePath, remoteConnectionId, remoteSshHost]);
296299

@@ -302,7 +305,11 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
302305

303306
const requestId = metadataLoadRequestIdRef.current + 1;
304307
metadataLoadRequestIdRef.current = requestId;
305-
setMetadataPageState(prev => ({ ...prev, isLoading: true }));
308+
setMetadataPageState(prev => ({
309+
...prev,
310+
isLoading: true,
311+
loadError: false,
312+
}));
306313

307314
try {
308315
const page = await flowChatStore.loadSessionMetadataPage(
@@ -326,12 +333,17 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
326333
nextCursor: page.nextCursor,
327334
hasMore: page.hasMore,
328335
isLoading: false,
336+
loadError: false,
329337
});
330338
}
331339
return page;
332340
} catch (error) {
333341
if (metadataLoadRequestIdRef.current === requestId) {
334-
setMetadataPageState(prev => ({ ...prev, isLoading: false }));
342+
setMetadataPageState(prev => ({
343+
...prev,
344+
isLoading: false,
345+
loadError: true,
346+
}));
335347
}
336348
log.warn('Failed to load visible session metadata page', { error, workspacePath, cursor, limit });
337349
return null;
@@ -445,6 +457,7 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
445457
nextCursor: undefined,
446458
hasMore: false,
447459
isLoading: false,
460+
loadError: false,
448461
});
449462
if (isVisible && workspacePath) {
450463
void loadMetadataPage(SESSIONS_LEVEL_0, undefined, 'sessions_nav_post_archive');
@@ -933,6 +946,21 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
933946
</div>
934947
);
935948
}
949+
if (metadataPageState.loadError) {
950+
return (
951+
<div className="bitfun-nav-panel__inline-list">
952+
<button
953+
type="button"
954+
className="bitfun-nav-panel__inline-action"
955+
onClick={() => {
956+
void loadInitialMetadataPage('sessions_nav_manual_retry');
957+
}}
958+
>
959+
<span>{t('nav.sessions.loadFailedRetry')}</span>
960+
</button>
961+
</div>
962+
);
963+
}
936964
return null;
937965
}
938966

src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ import {
8282
scheduleModelResponseStatus,
8383
} from './RuntimeStatusModule';
8484
import { requestPeerSessionRefresh } from './PeerSessionRefreshModule';
85+
import { isPeerDeviceModeActive } from '@/infrastructure/peer-device/peerModeFlag';
8586

8687
const log = createLogger('EventHandlerModule');
8788
const TURN_COMPLETION_QUIET_WINDOW_MS = 500;
@@ -1698,7 +1699,12 @@ export function processBatchedEvents(
16981699
processNormalTextChunkInternal(context, sessionId, turnId, roundId, text, attemptId, attemptIndex);
16991700
}
17001701

1701-
debouncedSaveDialogTurn(context, sessionId, turnId, 2000);
1702+
// The executing host owns turn persistence. A Peer controller receives
1703+
// the same chunks for rendering and must not echo a save RPC for every
1704+
// checkpoint, especially on a weak link.
1705+
if (!isPeerDeviceModeActive()) {
1706+
debouncedSaveDialogTurn(context, sessionId, turnId, 2000);
1707+
}
17021708
} else if (eventType === 'tool:params') {
17031709
const { sessionId, turnId, toolEvent } = payload;
17041710
processToolParamsPartialInternal(sessionId, turnId, toolEvent);

src/web-ui/src/flow_chat/services/flow-chat-manager/PeerSessionRefreshModule.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void
120120
let inFlight = false;
121121
let queued = false;
122122
let immediateTimer: ReturnType<typeof setTimeout> | null = null;
123+
let scheduleRefresh: RefreshRequester = () => {};
123124

124125
const runRefresh = async (requestedSessionId?: string): Promise<void> => {
125126
if (disposed || inFlight || !isPeerDeviceModeActive()) {
@@ -207,7 +208,7 @@ export function installPeerSessionRefresh(context: FlowChatContext): () => void
207208
}
208209
};
209210

210-
const scheduleRefresh: RefreshRequester = (sessionId) => {
211+
scheduleRefresh = (sessionId) => {
211212
if (disposed) {
212213
return;
213214
}

src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
22
import type { DialogTurn, FlowTextItem, ModelRound } from '../../types/flow-chat';
33
import {
44
convertDialogTurnToBackendFormat,
5+
debouncedSaveDialogTurn,
56
immediateSaveDialogTurn,
67
saveDialogTurnToDisk,
78
} from './PersistenceModule';
@@ -291,6 +292,26 @@ describe('PersistenceModule', () => {
291292
expect(saveSessionTurn).toHaveBeenCalledTimes(1);
292293
});
293294

295+
it('checkpoints continuous streamed output without waiting for a quiet period', async () => {
296+
const turn = createDialogTurn('processing');
297+
const context = createContext(turn);
298+
299+
debouncedSaveDialogTurn(context, SESSION_ID, TURN_ID, 2000);
300+
await vi.advanceTimersByTimeAsync(1000);
301+
debouncedSaveDialogTurn(context, SESSION_ID, TURN_ID, 2000);
302+
await vi.advanceTimersByTimeAsync(999);
303+
expect(saveSessionTurn).not.toHaveBeenCalled();
304+
305+
await vi.advanceTimersByTimeAsync(1);
306+
await flushMicrotasks();
307+
expect(saveSessionTurn).toHaveBeenCalledTimes(1);
308+
309+
debouncedSaveDialogTurn(context, SESSION_ID, TURN_ID, 2000);
310+
await vi.advanceTimersByTimeAsync(2000);
311+
await flushMicrotasks();
312+
expect(saveSessionTurn).toHaveBeenCalledTimes(2);
313+
});
314+
294315
it('flushes terminal turn saves immediately', async () => {
295316
const turn = createDialogTurn('completed');
296317
const context = createContext(turn);

0 commit comments

Comments
 (0)