fix(client): sync running state on iOS foreground return#407
Conversation
When iOS Safari backgrounds and returns, the SSE session_end event can be silently dropped while the EventSource connection appears alive. The _foreground handler restores messages but never validated running state, leaving the stop button visible after the agent finishes. Query /api/sessions/:id/meta on foreground and set running=false when isActive is false. Independent of the reconnect path so it works even when doReconnectPost has issues. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 1 issue(s) (1 warning).
packages/client/src/store.ts
Clean, well-tested fix for a real iOS backgrounding bug. One minor race condition: syncRunningState should verify the active session hasn't changed before applying the state update.
- 🟡 unsafe_assumptions (L230): Race condition:
syncRunningStatecapturessessionIdat call time but doesn't verifysessions.activestill matches when the promise resolves. If the user switches to a new session (which resets then re-setsrunning: true) while the meta fetch for the old session is in flight,!meta.isActivefor the old session will incorrectly clearrunningfor the new session. Guard withif (store.getState().sessions.active !== sessionId) return;before applying the state change.[fixable]
| .then((meta) => { | ||
| if (!meta) return; | ||
| const { messages: msgState } = store.getState(); | ||
| if (msgState.running && !meta.isActive) { |
There was a problem hiding this comment.
🟡 unsafe_assumptions: Race condition: syncRunningState captures sessionId at call time but doesn't verify sessions.active still matches when the promise resolves. If the user switches to a new session (which resets then re-sets running: true) while the meta fetch for the old session is in flight, !meta.isActive for the old session will incorrectly clear running for the new session. Guard with if (store.getState().sessions.active !== sessionId) return; before applying the state change. [fixable]
Address Centaur review finding: check sessions.active still matches the original sessionId before applying isActive=false from the meta fetch. Prevents stale response from clearing running on a different session. Fix Prettier formatting in test file. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 3 issue(s).
packages/client/src/store.ts
Clean, well-tested fix for a real iOS issue. The session-switch race guard is correct. Only minor style and coverage suggestions — no bugs or regressions.
- 🔵 style (L219): The docstring is 4 lines — longer than the project's 'one short line max' comment convention. The session-switch guard and iOS context are already clear from the code and the PR description; this could be a single line or omitted entirely.
[fixable] - 🔵 unsafe_assumptions (L224):
syncRunningStatehas no deduplication guard (unlikefetchAndRestoreMessageswhich usesrecoveryInFlight). Rapid foreground events (e.g., quick app-switch back and forth) could fire multiple concurrent meta fetches. In practice the session-switch guard and idempotentSET_RUNNING(false)make this harmless, but it's an asymmetry worth noting.[fixable]
packages/client/__tests__/store.test.ts
Clean, well-tested fix for a real iOS issue. The session-switch race guard is correct. Only minor style and coverage suggestions — no bugs or regressions.
- 🔵 missing_tests: No test covers the case where
getSessionMetarejects (network error, 404). The implementation silently catches, which is correct, but a test asserting that running state is unchanged on fetch failure would document this contract and prevent regressions.[fixable]
| }); | ||
| } | ||
|
|
||
| /** |
There was a problem hiding this comment.
🔵 style: The docstring is 4 lines — longer than the project's 'one short line max' comment convention. The session-switch guard and iOS context are already clear from the code and the PR description; this could be a single line or omitted entirely. [fixable]
| * backgrounding drops the session_end SSE event. Independent of the | ||
| * reconnect path so it works even when doReconnectPost has issues. | ||
| */ | ||
| function syncRunningState(sessionId: string) { |
There was a problem hiding this comment.
🔵 unsafe_assumptions: syncRunningState has no deduplication guard (unlike fetchAndRestoreMessages which uses recoveryInFlight). Rapid foreground events (e.g., quick app-switch back and forth) could fire multiple concurrent meta fetches. In practice the session-switch guard and idempotent SET_RUNNING(false) make this harmless, but it's an asymmetry worth noting. [fixable]
- Trim docstring to single line - Add dedup guard (runningSyncInFlight) to prevent concurrent meta fetches on rapid foreground events - Add test for meta fetch failure (network error) preserving running state Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 2 issue(s).
packages/client/src/store.ts
Clean, well-tested fix for a real iOS edge case. The session-switch guard, the one-directional correction (only clears running, never sets it), and the four test scenarios covering happy path, still-active, stale-session, and network-error all demonstrate solid defensive thinking. Two minor suggestions on consistency.
- 🔵 unsafe_assumptions (L222): The
runningSyncInFlightguard silently drops subsequent foreground events while a meta fetch is in flight. If a rapid background→foreground→background→foreground cycle occurs and the first fetch takes long, the second foreground recovery won't sync running state. This is consistent with howrecoveryInFlightworks for message recovery, so it's a known tradeoff — but worth noting that the guard could be replaced with an AbortController pattern that cancels the stale request and starts a new one, rather than dropping it. Not a bug in practice since iOS foreground events are infrequent.[fixable] - 🔵 style (L237): Empty
catch(() => {})swallows errors silently. The parallelfetchAndRestoreMessageslogs aconsole.warnon failure. Consider adding a matchingconsole.warn('[mitzo] running state sync failed', err)for consistency and debuggability, especially since this is a new network call that could fail for various reasons on iOS.[fixable]
|
|
||
| /** Sync running state from server when session_end may have been missed. */ | ||
| function syncRunningState(sessionId: string) { | ||
| if (runningSyncInFlight) return; |
There was a problem hiding this comment.
🔵 unsafe_assumptions: The runningSyncInFlight guard silently drops subsequent foreground events while a meta fetch is in flight. If a rapid background→foreground→background→foreground cycle occurs and the first fetch takes long, the second foreground recovery won't sync running state. This is consistent with how recoveryInFlight works for message recovery, so it's a known tradeoff — but worth noting that the guard could be replaced with an AbortController pattern that cancels the stale request and starts a new one, rather than dropping it. Not a bug in practice since iOS foreground events are infrequent. [fixable]
| messages: messagesReducer(s.messages, { type: 'SET_RUNNING', running: false }), | ||
| })); | ||
| } | ||
| }) |
There was a problem hiding this comment.
🔵 style: Empty catch(() => {}) swallows errors silently. The parallel fetchAndRestoreMessages logs a console.warn on failure. Consider adding a matching console.warn('[mitzo] running state sync failed', err) for consistency and debuggability, especially since this is a new network call that could fail for various reasons on iOS. [fixable]
dimakis
left a comment
There was a problem hiding this comment.
Centaur Review
Found 2 issue(s).
packages/client/src/store.ts
Clean, well-tested fix with good race-condition guards. Two minor suggestions around atomicity and defensive boolean checks — no blocking issues.
- 🔵 unsafe_assumptions (L232): Minor TOCTOU:
msgState.runningis read from a snapshot on line 230, butsetStateon line 233 uses a freshs.messages. Ifrunningbecamefalsebetween the check and the dispatch (e.g., a normalsession_endarrived in the gap), the reducer redundantly setsrunning: false— harmless but technically unnecessary. Could collapse the guard and dispatch into a singlesetStateupdater that checkss.messages.running && !meta.isActiveatomically. Not a bug, just a minor cleanliness improvement.[fixable] - 🔵 style (L232): The condition
!meta.isActivetreatsundefinedas falsy. If the server ever returns a response without theisActivefield (e.g., older server version), this would incorrectly clearrunning. Consider an explicitmeta.isActive === falsecheck for defensive clarity, consistent with the explicit boolean semantics of theSessionMetaResponsetype.[fixable]
| // don't apply stale isActive=false to the new session's running state. | ||
| const { sessions, messages: msgState } = store.getState(); | ||
| if (sessions.active !== sessionId) return; | ||
| if (msgState.running && !meta.isActive) { |
There was a problem hiding this comment.
🔵 unsafe_assumptions: Minor TOCTOU: msgState.running is read from a snapshot on line 230, but setState on line 233 uses a fresh s.messages. If running became false between the check and the dispatch (e.g., a normal session_end arrived in the gap), the reducer redundantly sets running: false — harmless but technically unnecessary. Could collapse the guard and dispatch into a single setState updater that checks s.messages.running && !meta.isActive atomically. Not a bug, just a minor cleanliness improvement. [fixable]
| // don't apply stale isActive=false to the new session's running state. | ||
| const { sessions, messages: msgState } = store.getState(); | ||
| if (sessions.active !== sessionId) return; | ||
| if (msgState.running && !meta.isActive) { |
There was a problem hiding this comment.
🔵 style: The condition !meta.isActive treats undefined as falsy. If the server ever returns a response without the isActive field (e.g., older server version), this would incorrectly clear running. Consider an explicit meta.isActive === false check for defensive clarity, consistent with the explicit boolean semantics of the SessionMetaResponse type. [fixable]
Summary
session_endSSE event is silently dropped_foregroundevent), queries/api/sessions/:id/metaand setsrunning: falsewhenisActiveis falsedoReconnectPostissues being fixed in #fix/reconnect-on-stale-postRoot cause
The
_foregroundhandler calledfetchAndRestoreMessages()which restored messages but never validatedrunningstate. When SSEsession_endwas lost during iOS background (EventSource appeared alive, so no reconnect triggered),runningstayedtrueindefinitely, showing the stop/interrupt/queue buttons instead of the send button.Test plan
syncs running state from session meta on foreground when session_end was misseddoes not change running state when session is still active on foreground🤖 Generated with Claude Code