From ef61b778fccd10bdd4ef837266f47d61650d0303 Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 9 Jun 2026 01:48:20 +0200 Subject: [PATCH 1/4] Stage 17 #2: Connecting indicator + auto-retry, instead of red toasts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Connectivity failures become state, not a toast on every attempt. A global online signal (lib/connection.svelte.ts) flips on a transport unavailable / rate_limited and on the live stream's drop, driving a pure-CSS header spinner + 'Connecting…' in place of the title and softly disabling the in-game server actions (commit / exchange / pass / hint; local board/rack/reset stay live). - transport: exec auto-retries with capped exponential backoff — every op on a rate-limit (rejected before processing, safe), reads only on unavailable (a mutation is never blindly re-sent, to avoid double-applying one whose response was lost; its button is disabled while offline so the player re-issues on reconnect). A reachability watcher (profile.get probe) and any successful traffic clear the signal. - the old red error.unavailable toast is gone (handleError suppresses connection codes; the indicator replaces it). A server-data screen still opens with the spinner and fills on reconnect (global indicator + read auto-retry), so navigation is never dead. - pure retry policy unit-tested (retry.ts); a mock-only window.__conn hook drives a Chromium+WebKit e2e (indicator shows offline, the action disables, both clear on reconnect). Full suite + build green. - docs: ARCHITECTURE transport note, FUNCTIONAL (+ _ru), PLAN tracker (incl. #1 — the bot already drains all updates, no change). Also records #1 as investigated/no-change in PLAN. Other server-action buttons (chat send, profile save, …) still degrade to a safe no-op offline; visual disable is easy to extend. --- PLAN.md | 22 ++++++++++++ docs/ARCHITECTURE.md | 10 +++++- docs/FUNCTIONAL.md | 7 +++- docs/FUNCTIONAL_ru.md | 7 +++- ui/e2e/game.spec.ts | 19 ++++++++++ ui/src/components/Header.svelte | 19 +++++++++- ui/src/components/Spinner.svelte | 32 +++++++++++++++++ ui/src/game/Game.svelte | 9 ++--- ui/src/lib/app.svelte.ts | 14 ++++++-- ui/src/lib/connection.svelte.ts | 59 ++++++++++++++++++++++++++++++++ ui/src/lib/gateway.ts | 10 ++++++ ui/src/lib/i18n/en.ts | 1 + ui/src/lib/i18n/ru.ts | 1 + ui/src/lib/retry.test.ts | 50 +++++++++++++++++++++++++++ ui/src/lib/retry.ts | 53 ++++++++++++++++++++++++++++ ui/src/lib/transport.ts | 39 +++++++++++++++++---- 16 files changed, 334 insertions(+), 18 deletions(-) create mode 100644 ui/src/components/Spinner.svelte create mode 100644 ui/src/lib/connection.svelte.ts create mode 100644 ui/src/lib/retry.test.ts create mode 100644 ui/src/lib/retry.ts diff --git a/PLAN.md b/PLAN.md index d122e05..3050036 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1437,6 +1437,28 @@ provided cert) at the contour caddy; prod VPN; rollback. connector. The backend resolves the mover's display name (the score line and result are built per recipient). Covered by notify round-trip, emit, connector-render (en/ru) and routing tests. + - **No-op drain of all bot updates (#1, investigated — no change):** confirmed the Telegram bot + already long-polls and the library advances the offset for every delivered update (the default + handler no-ops anything but `/start`), so the queue never piles up. Telegram withholds only + `message_reaction` / `message_reaction_count` / `chat_member` by default, and — being + unrequested — those never queue either. Owner chose to leave `allowed_updates` at the default + (zero risk) rather than hand-maintain a full whitelist (a wrong/stale type would break + `getUpdates` entirely); a specific type will be requested when a concrete handler needs it. + - **Reconnect UX — "Connecting…" + soft-disable (#2, shipped):** connectivity failures became + **state, not toasts**. A global `online` signal (`lib/connection.svelte.ts`) flips on a + transport `unavailable` / `rate_limited` (and on the live stream's drop), driving a pure-CSS + header **spinner + "Connecting…"** in place of the title and softly disabling the in-game + server actions (commit / exchange / pass / hint; local board/rack/reset stay live). The + transport (`exec`) **auto-retries with capped backoff** — every op on a rate-limit, **reads + only** on `unavailable` (mutations are not blindly re-sent; their buttons are disabled while + offline, so the player re-issues on reconnect — the idempotency caveat the owner accepted). A + reachability **watcher** (`profile.get` probe) and any successful traffic clear the signal; the + old red `error.unavailable` toast is gone (the indicator replaces it). A server-data screen + still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry), + so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn` + hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear + on reconnect). Other server-action buttons (chat send, profile save, …) are not yet visually + disabled but degrade to a safe no-op (the suppressed toast + indicator) — easy to extend. ## Deferred TODOs (cross-stage) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7780502..d1a004a 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -89,7 +89,15 @@ dropped). Horizontal scaling is explicit future work. auth operations are unauthenticated and return the minted token. A unary operation's domain outcome rides back in `ExecuteResponse.result_code` (HTTP 200); only edge failures (rate limit, missing session, unknown type, internal) - surface as Connect error codes. + surface as Connect error codes. The client (Stage 17) treats a connectivity edge failure as + **state, not a per-call toast**: a transport `unavailable` or a `rate_limited` flips a global + `online` signal that drives a header **"Connecting…"** spinner and softly disables proactive + actions, and the transport **auto-retries with capped exponential backoff** — every op on a + rate-limit (the gateway rejected it before processing, so it is safe), but only **read-only** + ops on `unavailable` (a mutation is never blindly re-sent, to avoid double-applying one whose + response was lost — its button is disabled while offline and the player re-issues it on + reconnect). A reachability watcher (a lightweight `profile.get` probe) clears the signal when no + other traffic is in flight; the live `Subscribe` stream's drop/recovery feeds the same signal. - **Alphabet on the wire (Stage 13)**: live play exchanges **alphabet indices**, not concrete letters. The rack (`StateView.rack`), the `SubmitPlay`/`Evaluate` tiles, the `Exchange` tiles and the `CheckWord` word are `ubyte` indices into the variant's alphabet diff --git a/docs/FUNCTIONAL.md b/docs/FUNCTIONAL.md index 4d00a05..cdb87b8 100644 --- a/docs/FUNCTIONAL.md +++ b/docs/FUNCTIONAL.md @@ -46,7 +46,12 @@ request) arrive as a **Telegram notification** instead — unless the player kee notifications in the app only (a profile setting, **on by default**). The "your turn" notification names the opponent and recaps their last move — the word and the running score for a scoring play, or that they swapped or passed — and a finished game sends a "game over" -notification with your result and the final score (scores read with yours first). +notification with your result and the final score (scores read with yours first). If the +connection drops or the server is rate-limiting, the app does not nag with errors: the header +shows a quiet **"Connecting…"** spinner while it reconnects, actions that send to the server +pause until it is back (a server-data screen still opens, with the spinner, and fills in on +reconnect), and pending reads resume on their own — the interface stays usable instead of +flashing a red banner each time. ### Accounts, linking & merge *(Stage 1 / 11)* First platform contact auto-provisions a durable account. From the profile a player diff --git a/docs/FUNCTIONAL_ru.md b/docs/FUNCTIONAL_ru.md index 2472c6a..f9e1ad5 100644 --- a/docs/FUNCTIONAL_ru.md +++ b/docs/FUNCTIONAL_ru.md @@ -48,7 +48,12 @@ Mini App** авторизует по подписанным `initData` плат Уведомление «ваш ход» называет соперника и пересказывает его последний ход — слово и текущий счёт для результативного хода либо что он поменял фишки или пропустил, — а по завершении партии приходит уведомление «конец партии» с твоим результатом и финальным -счётом (счёт читается, твой первым). +счётом (счёт читается, твой первым). Если связь пропадает или сервер ограничивает частоту +запросов, приложение не донимает ошибками: в шапке тихо крутится спиннер **«Подключение…»**, +пока идёт переподключение, действия, отправляющие данные на сервер, приостанавливаются до +восстановления связи (экран с серверными данными всё равно открывается — со спиннером — и +подгружается при реконнекте), а незавершённые чтения возобновляются сами — интерфейс остаётся +рабочим вместо красного баннера каждый раз. ### Аккаунты, привязка и слияние *(Stage 1 / 11)* Первый контакт с платформы заводит постоянный аккаунт. Из профиля игрок diff --git a/ui/e2e/game.spec.ts b/ui/e2e/game.spec.ts index c82f8ac..d2c6383 100644 --- a/ui/e2e/game.spec.ts +++ b/ui/e2e/game.spec.ts @@ -16,6 +16,25 @@ async function openGame(page: Page): Promise { await expect(page.locator('.pane')).toHaveCount(1); } +test('offline shows the Connecting indicator and softly disables server actions (Stage 17)', async ({ page }) => { + await openGame(page); + // The exchange/draw tab is a server action; on my turn with tiles in the bag it is live. + const draw = page.locator('.tab').first(); + await expect(draw).toBeEnabled(); + await expect(page.getByText('Connecting…')).toHaveCount(0); + + // Drop the connection (mock-only hook): the header swaps the title for the spinner + + // "Connecting…", and the server action goes inert. + await page.evaluate(() => (window as unknown as { __conn: { offline(): void } }).__conn.offline()); + await expect(page.getByText('Connecting…')).toBeVisible(); + await expect(draw).toBeDisabled(); + + // Reconnect: the indicator clears and the action is live again. + await page.evaluate(() => (window as unknown as { __conn: { online(): void } }).__conn.online()); + await expect(page.getByText('Connecting…')).toHaveCount(0); + await expect(draw).toBeEnabled(); +}); + test('placing a tile and confirming via ✅ commits the move', async ({ page }) => { await openGame(page); await page.locator('.rack .tile').first().click(); diff --git a/ui/src/components/Header.svelte b/ui/src/components/Header.svelte index f5a5b01..844d263 100644 --- a/ui/src/components/Header.svelte +++ b/ui/src/components/Header.svelte @@ -2,6 +2,9 @@ import type { Snippet } from 'svelte'; import { navigate } from '../lib/router.svelte'; import { insideTelegram } from '../lib/telegram'; + import { connection } from '../lib/connection.svelte'; + import { t } from '../lib/i18n/index.svelte'; + import Spinner from './Spinner.svelte'; let { title, back, menu, grow = false }: { title: string; back?: string; menu?: Snippet; grow?: boolean } = $props(); @@ -20,7 +23,11 @@ {:else} {/if} -

{title}

+ {#if connection.online} +

{title}

+ {:else} +

{t('connection.connecting')}

+ {/if}
{#if menu}{@render menu()}{/if}
@@ -57,6 +64,16 @@ overflow: hidden; text-overflow: ellipsis; } + /* The "Connecting…" indicator replaces the title while offline: a spinner + muted label, + centred like the title so the bar does not shift. */ + h1.connecting { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + color: var(--text-muted); + font-weight: 500; + } .icon, .spacer, .end { diff --git a/ui/src/components/Spinner.svelte b/ui/src/components/Spinner.svelte new file mode 100644 index 0000000..42ea1ba --- /dev/null +++ b/ui/src/components/Spinner.svelte @@ -0,0 +1,32 @@ + + + + + diff --git a/ui/src/game/Game.svelte b/ui/src/game/Game.svelte index 015c3b0..8612ee2 100644 --- a/ui/src/game/Game.svelte +++ b/ui/src/game/Game.svelte @@ -10,6 +10,7 @@ import { gateway } from '../lib/gateway'; import { navigate } from '../lib/router.svelte'; import { app, handleError, showToast } from '../lib/app.svelte'; + import { connection } from '../lib/connection.svelte'; import { GatewayError } from '../lib/client'; import { t } from '../lib/i18n/index.svelte'; import type { Direction, EvalResult, MoveRecord, StateView, Tile } from '../lib/model'; @@ -720,7 +721,7 @@ /> {#if !gameOver && placement.pending.length > 0} - + {/if} {:else} @@ -730,14 +731,14 @@ {#snippet tabbar()} {#if view} - - + {#snippet trigger()}🥺{t('game.skip')}{/snippet} {#snippet popover(close)}{/snippet} - + {#snippet trigger()} 🛟{#if (view?.hintsRemaining ?? 0) > 0}{view?.hintsRemaining}{/if} {t('game.hint')} diff --git a/ui/src/lib/app.svelte.ts b/ui/src/lib/app.svelte.ts index 9784d61..f138d19 100644 --- a/ui/src/lib/app.svelte.ts +++ b/ui/src/lib/app.svelte.ts @@ -23,6 +23,8 @@ import { } from './telegram'; import { parseStartParam } from './deeplink'; import { clearSession, loadPrefs, loadSession, saveSession, savePrefs } from './session'; +import { reportOffline, reportOnline, resetConnection } from './connection.svelte'; +import { isConnectionCode } from './retry'; import { clearGameCache } from './gamecache'; import { clearLobby } from './lobbycache'; import type { BoardLabelMode } from './boardlabels'; @@ -121,6 +123,9 @@ export function handleError(err: unknown): void { void logout(); return; } + // A connectivity failure is shown by the "Connecting…" header indicator (and auto-retried), + // not a red toast on every attempt. + if (isConnectionCode(err.code)) return; showToast(t(errorKey(err.code)), 'error'); return; } @@ -132,6 +137,7 @@ function openStream(): void { streamAlive = true; unsubscribeStream = gateway.subscribe( (e) => { + reportOnline(); // a delivered event proves the gateway is reachable app.lastEvent = e; if (e.kind === 'chat_message' && e.message.senderId !== app.session?.userId) { // While the player is on that game's chat screen, neither toast nor bump the unread. @@ -155,9 +161,10 @@ function openStream(): void { }, () => { streamAlive = false; - // A background suspend drops the single-shot stream. Keep the banner hidden while - // backgrounded or just-resumed (bannerSuppressed); always schedule a retry. - if (!bannerSuppressed()) showToast(t('error.unavailable'), 'error'); + // A background suspend drops the single-shot stream. Keep the indicator hidden while + // backgrounded or just-resumed (bannerSuppressed); otherwise show "Connecting…" (the + // reachability watcher and this scheduled retry recover it). Always schedule a retry. + if (!bannerSuppressed()) reportOffline(); scheduleReconnect(); }, ); @@ -399,6 +406,7 @@ export async function loginEmail(email: string, code: string): Promise { export async function logout(): Promise { closeStream(); + resetConnection(); clearGameCache(); clearLobby(); gateway.setToken(null); diff --git a/ui/src/lib/connection.svelte.ts b/ui/src/lib/connection.svelte.ts new file mode 100644 index 0000000..97a14aa --- /dev/null +++ b/ui/src/lib/connection.svelte.ts @@ -0,0 +1,59 @@ +// Global connectivity signal (Stage 17). `online` is false while the app is actively failing to +// reach the gateway — a unary call retrying after a transport/rate-limit failure, or the live +// stream dropped. The transport and the live-stream owner report transitions; the UI reads +// `connection.online` to show the "Connecting…" indicator and to softly disable proactive +// actions. In mock mode nothing ever reports trouble, so it simply stays online. +// +// Recovery is guaranteed by a reachability watcher: while offline it periodically fires a +// registered probe (a lightweight read) until one succeeds, so the indicator clears even when no +// other traffic is in flight. + +import { backoffMs } from './retry'; + +let online = $state(true); +let watchTimer: ReturnType | null = null; +let probe: (() => Promise) | null = null; + +export const connection = { + /** online is true when the app believes it can reach the gateway. */ + get online(): boolean { + return online; + }, +}; + +/** registerProbe installs the reachability probe the watcher fires while offline. The transport + * wires a cheap authenticated read; it should reject when there is no session. */ +export function registerProbe(fn: () => Promise): void { + probe = fn; +} + +/** reportOnline marks the gateway reachable and stops the watcher. */ +export function reportOnline(): void { + online = true; + if (watchTimer) { + clearTimeout(watchTimer); + watchTimer = null; + } +} + +/** reportOffline marks the gateway unreachable and starts the reachability watcher (once). */ +export function reportOffline(): void { + online = false; + if (!watchTimer && probe) scheduleProbe(1); +} + +/** resetConnection restores the online state and stops the watcher (e.g. on logout). */ +export function resetConnection(): void { + reportOnline(); +} + +function scheduleProbe(attempt: number): void { + watchTimer = setTimeout( + () => { + watchTimer = null; + if (online || !probe) return; + probe().then(reportOnline, () => scheduleProbe(Math.min(attempt + 1, 6))); + }, + backoffMs(attempt), + ); +} diff --git a/ui/src/lib/gateway.ts b/ui/src/lib/gateway.ts index 9178fed..6927deb 100644 --- a/ui/src/lib/gateway.ts +++ b/ui/src/lib/gateway.ts @@ -5,9 +5,19 @@ import type { GatewayClient } from './client'; import { MockGateway } from './mock/client'; import { createTransport } from './transport'; +import { reportOffline, reportOnline } from './connection.svelte'; const isMock = import.meta.env.MODE === 'mock'; export const gateway: GatewayClient = isMock ? new MockGateway() : createTransport(import.meta.env.VITE_GATEWAY_URL ?? ''); + +// Mock-mode test hook (tree-shaken from a production build, where MODE !== 'mock'): the mock +// transport never exercises the connectivity indicator, so the Playwright e2e drives it directly. +if (isMock && typeof window !== 'undefined') { + (window as unknown as { __conn?: { offline(): void; online(): void } }).__conn = { + offline: reportOffline, + online: reportOnline, + }; +} diff --git a/ui/src/lib/i18n/en.ts b/ui/src/lib/i18n/en.ts index 1431634..4eef46c 100644 --- a/ui/src/lib/i18n/en.ts +++ b/ui/src/lib/i18n/en.ts @@ -4,6 +4,7 @@ export const en = { 'app.title': 'Scrabble', + 'connection.connecting': 'Connecting…', 'common.back': 'Back', 'common.cancel': 'Cancel', diff --git a/ui/src/lib/i18n/ru.ts b/ui/src/lib/i18n/ru.ts index c039d99..3bdf098 100644 --- a/ui/src/lib/i18n/ru.ts +++ b/ui/src/lib/i18n/ru.ts @@ -5,6 +5,7 @@ import type { MessageKey } from './en'; export const ru: Record = { 'app.title': 'Scrabble', + 'connection.connecting': 'Подключение…', 'common.back': 'Назад', 'common.cancel': 'Отмена', diff --git a/ui/src/lib/retry.test.ts b/ui/src/lib/retry.test.ts new file mode 100644 index 0000000..7f41a07 --- /dev/null +++ b/ui/src/lib/retry.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; +import { backoffMs, isConnectionCode, retryable } from './retry'; + +describe('retryable', () => { + it('retries any op on a rate-limit rejection (it never reached the backend)', () => { + expect(retryable('rate_limited', 'game.submit_play')).toBe(true); + expect(retryable('rate_limited', 'games.list')).toBe(true); + expect(retryable('rate_limited', 'chat.post')).toBe(true); + }); + + it('retries only read-only ops on a transport unavailable (a mutation could double-apply)', () => { + expect(retryable('unavailable', 'games.list')).toBe(true); + expect(retryable('unavailable', 'game.state')).toBe(true); + expect(retryable('unavailable', 'draft.get')).toBe(true); + // mutations are not auto-resent on a dropped connection + expect(retryable('unavailable', 'game.submit_play')).toBe(false); + expect(retryable('unavailable', 'chat.post')).toBe(false); + expect(retryable('unavailable', 'game.hide')).toBe(false); + }); + + it('never retries a domain rejection or an unknown code', () => { + expect(retryable('not_your_turn', 'game.submit_play')).toBe(false); + expect(retryable('illegal_play', 'game.submit_play')).toBe(false); + expect(retryable('not_found', 'game.state')).toBe(false); + expect(retryable('internal', 'games.list')).toBe(false); + }); +}); + +describe('isConnectionCode', () => { + it('flags the transport/rate-limit codes the indicator covers', () => { + expect(isConnectionCode('unavailable')).toBe(true); + expect(isConnectionCode('rate_limited')).toBe(true); + expect(isConnectionCode('not_your_turn')).toBe(false); + expect(isConnectionCode('internal')).toBe(false); + }); +}); + +describe('backoffMs', () => { + it('grows exponentially and caps at 8s (+ jitter under 250ms)', () => { + // attempt 1 ~ 500, 2 ~ 1000, 3 ~ 2000, 4 ~ 4000, 5 ~ 8000, then capped. + expect(backoffMs(1)).toBeGreaterThanOrEqual(500); + expect(backoffMs(1)).toBeLessThan(750); + expect(backoffMs(3)).toBeGreaterThanOrEqual(2000); + expect(backoffMs(3)).toBeLessThan(2250); + for (const n of [5, 6, 10, 20]) { + expect(backoffMs(n)).toBeGreaterThanOrEqual(8000); + expect(backoffMs(n)).toBeLessThan(8250); + } + }); +}); diff --git a/ui/src/lib/retry.ts b/ui/src/lib/retry.ts new file mode 100644 index 0000000..aef1360 --- /dev/null +++ b/ui/src/lib/retry.ts @@ -0,0 +1,53 @@ +// Retry policy for the gateway transport (Stage 17). When a unary call fails at the transport +// level the app retries it with capped exponential backoff while showing the "Connecting…" +// indicator, instead of flashing a red toast each time. +// +// Idempotency: a rate-limit rejection (ResourceExhausted) never reached the backend, so any op is +// safe to retry. A transport 'unavailable' is ambiguous for a mutation (its response could have +// been lost after the backend applied it), so only **read-only** ops are auto-retried on +// 'unavailable'; a mutation is surfaced instead (its button is disabled while offline and +// re-enables on reconnect, so the player re-issues it deliberately). + +/** READ_OPS is the set of side-effect-free message types (safe to auto-retry on any failure). */ +export const READ_OPS: ReadonlySet = new Set([ + 'profile.get', + 'games.list', + 'game.state', + 'game.history', + 'game.gcg', + 'game.evaluate', + 'game.check_word', + 'stats.get', + 'lobby.poll', + 'chat.list', + 'draft.get', + 'friends.list', + 'friends.incoming', + 'friends.outgoing', + 'blocks.list', + 'invitation.list', +]); + +/** + * retryable reports whether a failed op should be auto-retried. A rate-limit rejection is always + * safe (the gateway rejected it before processing); a transport 'unavailable' is retried only for + * read-only ops, never a mutation; every other code (a domain rejection, not-found, …) is final. + */ +export function retryable(code: string, op: string): boolean { + if (code === 'rate_limited') return true; + if (code === 'unavailable') return READ_OPS.has(op); + return false; +} + +/** isConnectionCode reports whether a code is a transport/connectivity failure the Connecting + * indicator covers (so the UI suppresses its red toast). */ +export function isConnectionCode(code: string): boolean { + return code === 'unavailable' || code === 'rate_limited'; +} + +/** backoffMs is the delay before retry attempt n (1-based): capped exponential growth plus a + * little jitter, so a fleet of clients does not retry in lockstep after an outage. */ +export function backoffMs(attempt: number): number { + const base = Math.min(8000, 500 * 2 ** Math.max(0, attempt - 1)); + return base + Math.floor(Math.random() * 250); +} diff --git a/ui/src/lib/transport.ts b/ui/src/lib/transport.ts index 02ad414..2e881ac 100644 --- a/ui/src/lib/transport.ts +++ b/ui/src/lib/transport.ts @@ -10,6 +10,11 @@ import { createConnectTransport } from '@connectrpc/connect-web'; import { Gateway } from '../gen/edge/v1/edge_pb'; import { GatewayError, type GatewayClient } from './client'; import * as codec from './codec'; +import { registerProbe, reportOffline, reportOnline } from './connection.svelte'; +import { backoffMs, isConnectionCode, retryable } from './retry'; + +const MAX_RETRIES = 6; +const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); function toGatewayError(e: unknown): GatewayError { if (e instanceof ConnectError) { @@ -38,15 +43,35 @@ export function createTransport(baseUrl: string): GatewayClient { const headers = (): Record | undefined => token ? { authorization: `Bearer ${token}` } : undefined; + // The reachability probe the connection watcher fires while offline: a cheap authenticated read + // (it must reject when there is no session, so the watcher keeps waiting rather than reporting up). + registerProbe(async () => { + if (!token) throw new Error('no session'); + await client.execute({ messageType: 'profile.get', payload: codec.empty(), requestId: '' }, { headers: headers() }); + }); + + // exec runs one unary op, auto-retrying transient transport failures with capped backoff (so a + // dropped connection or a rate-limit recovers seamlessly) and driving the global Connecting + // indicator. A successful round-trip marks the gateway reachable; a domain result_code is final. async function exec(messageType: string, payload: Uint8Array): Promise { - let res; - try { - res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() }); - } catch (e) { - throw toGatewayError(e); + for (let attempt = 0; ; attempt++) { + let res; + try { + res = await client.execute({ messageType, payload, requestId: '' }, { headers: headers() }); + } catch (e) { + const err = toGatewayError(e); + if (retryable(err.code, messageType) && attempt < MAX_RETRIES) { + reportOffline(); + await sleep(backoffMs(attempt + 1)); + continue; + } + if (isConnectionCode(err.code)) reportOffline(); + throw err; + } + reportOnline(); + if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode); + return res.payload; } - if (res.resultCode && res.resultCode !== 'ok') throw new GatewayError(res.resultCode); - return res.payload; } return { From efa1d0bd22acc4a00b04ab84afdccfbfa0de379c Mon Sep 17 00:00:00 2001 From: Ilia Denisov Date: Tue, 9 Jun 2026 07:23:32 +0200 Subject: [PATCH 2/4] Stage 17 #2: extend the offline soft-disable to all server-action buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Following the in-game bar, the Connecting indicator now also visually disables the other proactive (server-sending) controls while offline: chat send + nudge, profile save / link email|telegram / merge-confirm, friends (redeem, get-code, accept/decline, unfriend, block, unblock), New Game (auto-match variant + send-invitation) and the lobby hide ❌. Purely local controls (board/rack/reset, menus, navigation, settings, copy-code) stay live. Each reads the global connection.online signal; full e2e + check green. --- PLAN.md | 6 ++++-- ui/src/game/Chat.svelte | 5 +++-- ui/src/game/Game.svelte | 2 +- ui/src/screens/Friends.svelte | 15 ++++++++------- ui/src/screens/Lobby.svelte | 3 ++- ui/src/screens/NewGame.svelte | 5 +++-- ui/src/screens/Profile.svelte | 11 ++++++----- 7 files changed, 27 insertions(+), 20 deletions(-) diff --git a/PLAN.md b/PLAN.md index 3050036..f4b0b22 100644 --- a/PLAN.md +++ b/PLAN.md @@ -1457,8 +1457,10 @@ provided cert) at the contour caddy; prod VPN; rollback. still **opens with the spinner** and fills on reconnect (global indicator + read auto-retry), so navigation is never dead. Pure policy unit-tested (`retry.ts`); a mock-only `window.__conn` hook drives a Chromium+WebKit e2e (indicator appears offline, the action disables, both clear - on reconnect). Other server-action buttons (chat send, profile save, …) are not yet visually - disabled but degrade to a safe no-op (the suppressed toast + indicator) — easy to extend. + on reconnect). The visual soft-disable spans the server-action buttons across the app: the + game bar (commit/exchange/pass/hint/resign), chat send + nudge, profile save / link / merge, + friends (request/respond/unfriend/block/code), New Game (auto-match + invite) and the lobby + hide ❌; purely local controls (board/rack/reset, menu, navigation, settings) stay live. ## Deferred TODOs (cross-stage) diff --git a/ui/src/game/Chat.svelte b/ui/src/game/Chat.svelte index f1a6dd0..10ec271 100644 --- a/ui/src/game/Chat.svelte +++ b/ui/src/game/Chat.svelte @@ -1,6 +1,7 @@