| D-M4-13-0 |
2026-10-16 |
Base int/m1-wave27 at 3d4f0f3, branch feat/M4-13-sync-connection in its own worktree; no local merge, no push and no pull request from this build, the PR lane lands it. Brief and spec were read from main; the close code 4018 the brief relies on is on main (D-W-24) but not on the base, so its gateway part comes first as its own commit (section "Rulings from main: D-W-24"). The base names the chapters 04-sync-protokoll.md, 15-client-architektur.md and 08-identitaet.md; text this ticket adds there is English. Names that differ from the brief (D-W-100, D-W-114): the session class is GatewaySession, the backoff ReconnectBackoff, the close table lives in its own file close_codes.dart (closeCodeTable, reactTo, CloseReaction, Reconnect); AppLifecycle also has current, because 1000 reconnects only while the app is in the foreground and a mobile start in the background must not connect; GatewayConnection takes the AuthSession of auth and the SyncStateDao of local_store directly. The server gateway of M1-64 and M1-65 is not on this base, so no server behaviour could be compared with 04 6.4. |
Instructions of the launcher, which override the brief's landing step; merged names win over the brief. |
| D-M4-13-1 |
2026-10-16 |
ConnectionState(phase, reason); the reasons are none, networkUnavailable, devicePending, deviceReplaced, forbidden, rateLimited, revoked, protocolUnsupported and closedByServer(code). Transitions beyond the diagram of 15 7.2: 4016 goes to offline/devicePending and reconnects only after LoginService.pollPendingDevice reports active (a rejected device ends in signedOut); 4017 goes to offline/deviceReplaced and reconnects on the foreground or connectNow; 4018 goes to offline/forbidden and reconnects only on connectNow; 4008 goes to backoff/rateLimited and waits retry_after=<seconds> of the close reason; 4015 goes to offline and is logged as unexpected; a server 1000 backs off in the foreground and waits offline for the foreground otherwise; a drop without a close code backs off, or goes offline/networkUnavailable while the device has no network. Without a listener ready and resumed lead to live, with one to catchingUp, and the listener sets catchingUp, resyncing or live through SessionControl. |
Brief Design note 3; 15 7.2 shows the added transitions. |
| D-M4-13-2 |
2026-10-16 |
The close reactions are one map from code to CloseReaction (phase, reason, reconnect mode, identify, renewToken, signOut, unexpected), and reactTo resolves only what depends on the close reason or the app state (4008, 1000, a code outside the table). 4004 and 4011 renew the token through RefreshCoordinator.refreshNow and reconnect at once; a second renewal (4004 or 4011) before the next ready or resumed waits the backoff, so a server that keeps rejecting is not flooded; a refresh that ends the session signs out, a transient failure backs off. 4013 waits uniform in [0, sync.reconnect_backoff_base] without raising the counter. 4012 calls AuthSession.sessionRevoked, whose SignedOut(sessionRevoked) carries deleteLocalData. |
Brief Design note 4: the test iterates the table. |
| D-M4-13-3 |
2026-10-16 |
A network change and the return to the foreground start an attempt at once from backoff (and from offline with networkUnavailable; the foreground also from suspended, deviceReplaced and a server 1000 in the background) without raising the attempt counter; only a failed attempt draws the next backoff. A network loss during a backoff wait cancels it and goes offline/networkUnavailable. On mobile no path opens a socket in the background: an attempt that would start there (a backoff timer, a token renewal, an approved pending device) goes to suspended and waits for the foreground; a return to the foreground while the own background close is still running connects at once; a server close that arrived before the own close keeps its reaction (code review of M4-13). |
04 6.5, 15 7.2 Suspended. |
| D-M4-13-4 |
2026-10-16 |
Unknown ops and unknown members of known ops are ignored; client ops coming from the server are ignored like unknown ops. A server frame that is not a JSON object with a string op, or whose d does not fit the schema of its op, counts as a decode error (decodeErrors), is logged with its cause only and skipped; the socket stays. dispatch keeps the raw envelope map for decodeEnvelope of M4-14. Outgoing d drops null members recursively. |
04 6.1 (additive changes), 04 6.4 (closing with a protocol code is the server's role), 04 2.2. |
| D-M4-13-5 |
2026-10-16 |
Focus overflow: at most sync.max_focused_threads IDs are sent; the most recently shown threads win, where a thread counts as shown when it first appears in a setVisible call and IDs new in one call count in the iteration order of the set; setVisible returns the unfocused rest. Changes within one microtask send one frame; after ready the current set is sent when not empty, after resumed only a change made while disconnected; an error with validation_failed for focus restores the previous set. |
Brief Design note 6; 04 11.3 item 2 and 6.3 item 7 (the server restores the focus on resume). |
| D-M4-13-6 |
2026-10-16 |
The typing throttle is per connection as on the server: typing.start at most once per sync.typing_min_interval, typing.stop only after a start and it resets the throttle; a start for another thread inside the interval first stops the previous thread. A new socket resets the throttle; frames that cannot be sent (no session) are dropped. |
04 12 item 1: typing.stop resets the throttle of the connection. |
| D-M4-13-7 |
2026-10-16 |
disconnected_at is stored when a socket that had reached ready or resumed ends, not after a failed attempt, so failed attempts do not move the start of the resume window. resume needs a stored gateway session, a stored cursor (GatewayResume.cursor is required) and a disconnect less than resume_window_ms of the new hello ago; otherwise identify, with cursor and epoch only when a cursor is stored. The heartbeat sends the stored cursor read at ready or resumed (0 without one) until the listener sets lastProcessedSeq. |
Brief Design notes 2 and 5. |
| D-M4-13-8 |
2026-10-16 |
Frames are decoded as they arrive; heartbeat_ack and reauth_required act at once, every other frame waits in the ordered queue behind the listener steps. The bookkeeping of a disconnect (store disconnected_at, onDisconnected) cannot keep the close reaction from running. |
Red team of M4-13: an acknowledgement queued behind a long snapshot or resync step of the listener missed its interval and closed a healthy connection (04 6.3 items 5 and 6). |
| D-M4-13-9 |
2026-10-19 |
Every gateway attempt has the deadline client.sync.connect_timeout (new key of place App, default 30s, 15 7.2 and 22) from Connecting until ready or resumed. When it passes, GatewayConnection invalidates the generation of the attempt, closes an open socket with 1000 and, once the close is done (bounded by the same value again), takes the backoff of a failed connect, so no second socket opens while the first one closes; it does nothing after the session started, while a close the client started itself runs, or when the server already closed the socket (its close code decides the reaction). The dart:io transport gets no own connect timeout: the attempt deadline covers the TCP connect, the upgrade and the handshake. |
CodeRabbit on the pull request of M4-13, reproduced with tests on the fake clock: a connect or upgrade that never completed, or a gateway that never sent hello or never answered identify or resume, left the connection in connecting or identifying for good, and network changes and the foreground did not start a new attempt while it was open. The chapters named no client deadline, so the key is new. |