You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A single failed health probe is enough to report a disconnection. scheduleFailedCheck calls setIsConnected(false) on the first failure, sets previousConnectedRef to false, and the next success then fires onReconnect(false) — which paints a bogus Reconnected for two seconds (RECONNECTION_MESSAGE_DURATION_MS) over a connection that never dropped.
Because failures back off exponentially (failedPollDelayMs starts at 10–20 s and doubles to a 5 min cap), the wrong badge can also stay: the probe that would clear it is scheduled further and further out. One transient blip — a slow proxy, a dropped packet, a momentary 5xx — becomes minutes of connecting... on a working session, with nothing the user did to explain it.
This requires two consecutive failed probes before reporting a disconnection.
What this changes
File
cli/src/hooks/use-connection-status.ts
+50/−9
createProbeFailureTracker() and DISCONNECT_FAILURE_THRESHOLD = 2; the badge is now flipped by scheduleFailedCheck alone
6 tests: the isolated-blip sequence, the genuine outage, immediate recovery
The rule is one line: recordFailure() returns whether two failures have piled up. recordSuccess() clears the streak, and the streak is still what feeds failedPollDelayMs, so the backoff ladder is unchanged. So are the interval table, the jitter, the BYOK short-circuit, the onReconnect signature and getStatusIndicatorState.
checkConnection no longer does setIsConnected(connected) blindly: the disconnected branch delegates to scheduleFailedCheck, which is the only place that may report a drop. Previously the probe result and the failure path each flipped the badge, so the threshold had to cover both.
Slow to alarm, fast to clear. The threshold applies to the alarm only. One success reports connected immediately, with no minimum — recovery keeps feeling instant. That asymmetry is deliberate, and it is what the precedents do.
Why hysteresis
One probe is one sample, and a single sample can be wrong. The pattern is established for exactly this decision:
screenpipe #2188 is this same bug in a tray icon: "decide_status() immediately transitioned from Recording → Stopped on any single failure … This caused the tray icon and menu to flicker rapidly." Fixed with a consecutive-failure threshold, and "a single successful health check resets the failure counter to 0."
3x-ui #5968 — "flips an outbound's alive flag on a single failed probe — there is effectively no hysteresis", so every flap became an alert. Threshold 3, and the same asymmetry, named: "slow to alarm, fast to clear."
error-recovery.com — "only transitions after consecutive agreeing probes", with confirmations = 2 as the default: "requiring two agreeing probes costs a second or two of delay and eliminates the flicker entirely." (guide)
server-sent-events.com — the rule that keeps recovery honest: "Do not debounce the "live" transition — show it immediately so recovery feels instant." (guide)
2 is the common default for this exact threshold: Kubernetes failureThreshold: 2, NGINX fails=2, HAProxy fall 2. (health checks and failover)
Verified
Red first, and the red is a behaviour difference, not a missing import: the tracker was extracted with today's rule (threshold = 1) so the refactor itself was behaviour-preserving, then the tests were added and measured, then the constant moved to 2.
a single failed probe does not report a disconnection Expected: false Received: true
a sustained streak still reports a disconnection Expected: false Received: true
a sequence of isolated blips never flips the badge [true,true,true] vs [false,false,false]
recovery is immediate: one success clears the streak Expected: false Received: true
before
after
that file
13 pass, 4 fail
17 pass, 0 fail
full CLI suite (198 files)
3045 pass, 65 fail
3051 pass, 65 fail
failure set
—
identical (diff empty)
CLI typecheck
10 errors
10 errors, 0 in these files
The +6 are this PR's tests. The suite baseline was taken by stashing exactly these two files, not from memory. Both files pass prettier --check; the test file keeps importing the hook's exported helpers, which is the convention getNextInterval already set in this file.
Two of the tests pin the behaviour that must not regress: a sustained streak still reports a disconnection, and the streak still measures the outage for the backoff — so the fix cannot silently become "never report a drop".
How to try it
Start the CLI on a session and leave it idle, watching the status row.
Cut the network briefly (a few seconds, e.g. toggle Wi-Fi): one lost probe no longer changes the badge. On main the first probe alone painted connecting..., and the return painted Reconnected.
Keep it down for the second probe: connecting... does appear. A sustained outage still surfaces; what is gone is a single sample deciding it.
Not in this PR, on purpose
A genuine outage is now reported one probe later (10–20 s). The first failed probe schedules the next 10–20 s out, so the badge can take that long to say connecting... where it used to say it at once. That is the cost of the threshold, and it is the tradeoff the precedents accept. What is delayed is the badge; the request path is untouched.
The readout being preemptible is #1357, a separate PR with its own repro. That one is about a transient status taking the row; this one is about the badge lying.
authStatus === 'unreachable' also paints connecting... and is untouched: that is a terminal verdict from the auth layer, not a probe streak.
No time-based debounce. Both axes appear in the research (500–800 ms windows, pocketshell at 2.5 s), but our probe interval is 10 s–10 min, so counting probes is the meaningful axis; a window would add a second timer for the same outcome.
The streak is not persisted across hook remounts — it lives in the effect. A remount starts fresh, which errs toward not reporting a drop.
Good diagnosis and a clean fix. scheduleFailedCheck previously flipped isConnected/previousConnectedRef on the very first failed probe, so a single transient blip both painted a false disconnected state and, thanks to exponential backoff, could leave the CLI stuck on "connecting..." for minutes even though the connection never dropped. Making probeFailures.recordFailure() the sole gate for the state flip, while leaving consecutiveFailures (used by failedPollDelayMs) unaffected, is the right layer for this change — it doesn't touch the backoff ladder, jitter, BYOK short-circuit, or onReconnect signature.
The extracted createProbeFailureTracker() is a nice, testable unit, and the six added tests cover the important cases: single blip suppressed, sustained outage still reported, isolated repeated blips never flip the badge, immediate recovery clears the streak, and the threshold=1 case reproduces old behavior for reference. That's the right level of testing given the hook itself needs timer mocking to test end-to-end, and the existing test file already only exercised getNextInterval in isolation.
One thing worth double-checking before porting: previousConnectedRef is now only set to false once the threshold is hit, so any code elsewhere in the codebase that reads that ref between the first and second failure will still see it as connected — confirm nothing else depends on it flipping on the first failure.
The PR description is bloated with citations to unrelated third-party PRs (screenpipe, 3x-ui) — that's noise, not signal, and doesn't help the reviewer, but it doesn't detract from the diff itself, which is focused and in-scope.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A single failed health probe is enough to report a disconnection.
scheduleFailedCheckcallssetIsConnected(false)on the first failure, setspreviousConnectedReftofalse, and the next success then firesonReconnect(false)— which paints a bogus Reconnected for two seconds (RECONNECTION_MESSAGE_DURATION_MS) over a connection that never dropped.Because failures back off exponentially (
failedPollDelayMsstarts at 10–20 s and doubles to a 5 min cap), the wrong badge can also stay: the probe that would clear it is scheduled further and further out. One transient blip — a slow proxy, a dropped packet, a momentary 5xx — becomes minutes ofconnecting...on a working session, with nothing the user did to explain it.This requires two consecutive failed probes before reporting a disconnection.
What this changes
cli/src/hooks/use-connection-status.tscreateProbeFailureTracker()andDISCONNECT_FAILURE_THRESHOLD = 2; the badge is now flipped byscheduleFailedCheckalonecli/src/hooks/__tests__/use-connection-status.test.tsThe rule is one line:
recordFailure()returns whether two failures have piled up.recordSuccess()clears the streak, and the streak is still what feedsfailedPollDelayMs, so the backoff ladder is unchanged. So are the interval table, the jitter, the BYOK short-circuit, theonReconnectsignature andgetStatusIndicatorState.checkConnectionno longer doessetIsConnected(connected)blindly: the disconnected branch delegates toscheduleFailedCheck, which is the only place that may report a drop. Previously the probe result and the failure path each flipped the badge, so the threshold had to cover both.Slow to alarm, fast to clear. The threshold applies to the alarm only. One success reports connected immediately, with no minimum — recovery keeps feeling instant. That asymmetry is deliberate, and it is what the precedents do.
Why hysteresis
One probe is one sample, and a single sample can be wrong. The pattern is established for exactly this decision:
decide_status()immediately transitioned fromRecording→Stoppedon any single failure … This caused the tray icon and menu to flicker rapidly." Fixed with a consecutive-failure threshold, and "a single successful health check resets the failure counter to 0."aliveflag on a single failed probe — there is effectively no hysteresis", so every flap became an alert. Threshold 3, and the same asymmetry, named: "slow to alarm, fast to clear."confirmations = 2as the default: "requiring two agreeing probes costs a second or two of delay and eliminates the flicker entirely." (guide)"live"transition — show it immediately so recovery feels instant." (guide)failureThreshold: 2, NGINXfails=2, HAProxyfall 2. (health checks and failover)Verified
Red first, and the red is a behaviour difference, not a missing import: the tracker was extracted with today's rule (
threshold = 1) so the refactor itself was behaviour-preserving, then the tests were added and measured, then the constant moved to 2.The four reds, verbatim:
diffempty)The
+6are this PR's tests. The suite baseline was taken by stashing exactly these two files, not from memory. Both files passprettier --check; the test file keeps importing the hook's exported helpers, which is the conventiongetNextIntervalalready set in this file.Two of the tests pin the behaviour that must not regress: a sustained streak still reports a disconnection, and the streak still measures the outage for the backoff — so the fix cannot silently become "never report a drop".
How to try it
mainthe first probe alone paintedconnecting..., and the return painted Reconnected.connecting...does appear. A sustained outage still surfaces; what is gone is a single sample deciding it.Not in this PR, on purpose
connecting...where it used to say it at once. That is the cost of the threshold, and it is the tradeoff the precedents accept. What is delayed is the badge; the request path is untouched.authStatus === 'unreachable'also paintsconnecting...and is untouched: that is a terminal verdict from the auth layer, not a probe streak.pocketshellat 2.5 s), but our probe interval is 10 s–10 min, so counting probes is the meaningful axis; a window would add a second timer for the same outcome.