buzz-pair-relay closes the socket at 120s before the desktop's 130s timeout, so pairing shows "relay connection closed" instead of the expired state
- Dominant language
- Rust
- Stars
- 32.7k
- Forks
- 4.3k
- Avg merge
- 1d 13h
- Merged PRs (30d)
- 253
Description
## Summary
`buzz-pair-relay` closes every pairing WebSocket at exactly **120 s** (`CONN_TIMEOUT`), but Buzz Desktop's own pairing timeout — the one that produces the friendly "expired, create a new code" state — is **130 s**. The sidecar always wins by 10 seconds, so the desktop never reaches its own timeout branch. A user who simply leaves the QR on screen gets the bare transport string **`relay connection closed`** in an error state, instead of the expired state the code was written to show.
This affects any deployment using the sidecar, which is upstream's own documented architecture (Helm `pairingRelay`, and the compose wiring in flight across #2734 / #3842 / #4082 / #5715).
## The 10-second gap
**Sidecar** — `crates/buzz-pair-relay/src/lib.rs`:
```rust
/// Hard per-connection lifetime. `pub(crate)` for test access.
pub(crate) const CONN_TIMEOUT: Duration = Duration::from_secs(120);
...
let deadline = tokio::time::sleep(CONN_TIMEOUT);
tokio::pin!(deadline);
_ = &mut deadline => break 'conn, // socket closed, no close reason sent
```
**Desktop** — `desktop/src-tauri/src/commands/pairing.rs`:
```rust
let hard_timeout = tokio::time::sleep(Duration::from_secs(130));
...
_ = &mut hard_timeout => {
let _ = app.emit("pairing-error", PairingErrorPayload {
message: "Session timed out".into(), // never reached behind the sidecar
});
}
```
Because the socket dies first, the read loop takes the transport path instead:
```rust
msg = read.next() => {
let Some(msg) = msg else {
return Err("relay connection closed".into());
};
```
which is emitted verbatim as `pairing-error`.
## Why it surfaces raw
`MobilePairingCard.tsx` has a clean expired state, but only recognises one string:
```ts
function isPairingSessionTimeout(message: string) {
return message.toLowerCase().includes("session timed out");
}
```
`"relay connection closed"` fails that check, then falls through to `pairingErrorMessage`, which only special-cases `"timeout waiting for eose"` — so the raw message reaches the UI:
```ts
if (isPairingSessionTimeout(event.payload.message)) {
setStep("expired"); // unreachable behind the sidecar
return;
}
setError(pairingErrorMessage(event.payload.message)); // -> "relay connection closed"
setStep("error");
```
## Reproduction
1. Deploy the relay with `buzz-pair-relay` as a sidecar, reverse proxy routing `/pair` to it (WebSocket upgrade verified: `101`).
2. Desktop → Settings → Mobile → start pairing.
3. Leave the QR on screen and do nothing.
4. At ~120 s the card enters the error state showing `relay connection closed`.
Expected: the `expired` state ("code expired, create a new one"), which the desktop already implements.
## Server-side evidence
Sidecar log from a self-hosted deployment (desktop connects, nothing else happens):
```
06:36:38.846 conn opened conn_id=6 active=1
06:38:38.848 conn closed conn_id=6 active=0 # 120.002 s
```
And a run where the phone did join — the desktop connection is still cut at exactly 120 s:
```
07:11:28.343 conn opened conn_id=7 active=1 # desktop
07:12:20.019 conn opened conn_id=8 active=2 # phone
07:13:09.173 conn closed conn_id=8 active=1
07:13:28.344 conn closed conn_id=7 active=0 # 120.001 s
```
Environment: self-hosted relay (compose bundle + sidecar wired manually), relay revision `d8281b9`, Buzz Desktop 0.5.11, macOS.
## Suggested fix
Either side alone closes it:
- Give the desktop's timeout headroom under the sidecar's cap (or the sidecar a lifetime above the desktop's), so the side that owns the user-facing message is the one that fires first; or
- Treat a peer-initiated close during an unconsumed pairing session as expiry — e.g. also match `"relay connection closed"` in `isPairingSessionTimeout`.
The first is preferable: matching on transport strings is what makes this class of bug recur (cf. #3779 and #5340, same symptom from a 404 rather than a timeout).
## A note on the 120 s budget
Related, and worth considering together: the 120 s starts at **session creation**, not when the phone connects. While QR decoding is broken (#3514) the only working path is copy-paste of the pairing code, and that transfer alone consumed **52 s** of the budget in one of my runs — leaving ~68 s for two people to compare six digits and confirm on *both* devices. A timeout sized for instant QR capture is tight when the fallback path is the only one available.
Contributor guide
Research direction
Compare the timeout and close-handling paths in crates/buzz-pair-relay/src/lib.rs, desktop/src-tauri/src/commands/pairing.rs, and MobilePairingCard.tsx. Reproduce an idle pairing session, then verify that the desktop reaches its existing expired state rather than displaying the raw "relay connection closed" error, while preserving the intended timeout ordering.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust, typescript
- Domain
- backend, desktop, frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 65/100