Files
8b047a35e0 perf(desktop): move the local-archive subscription into Rust
The renderer owned the whole archive path: it listed saved subscriptions,
opened a live REQ per scope, buffered inbound frames, batched them, and
handed each batch back over IPC to be written to SQLite. Every archived
frame therefore made a round trip into JS for no reason other than
history — nothing in that path is a rendering concern.

`archive::sync` now runs the whole pipeline natively. It subscribes from
the saved subscriptions, buffers, flushes on the same thresholds the JS
manager used (FLUSH_BATCH_SIZE 25 / 2000ms deadline — parity, not a
retune), archives via SQLite directly, and emits
`archive-agent-metrics-changed` when a batch actually persisted metric
rows. `archiveSyncManager.ts` is deleted.

`native_relay_client` is the shared piece underneath: one authenticated
socket per (relay, pubkey), multiplexed subscriptions, declarative
`set_subscriptions` reconciliation, and exponential reconnect backoff.
A 30s read timeout is idle, not failure, and is discriminated by error
variant rather than message text so a reworded error cannot turn every
quiet period into a reconnect storm.

A relay CLOSED drives its own recovery: per-id retry state lives next to
`open` in the connection (not in `desired`, which is reloaded from
SQLite and would resurrect a deletion), a dedicated select! deadline arm
fires the reopen and is disabled when nothing is scheduled, and the
CLOSED message is classified terminal / rate-limited / retryable with
the same prefixes the renderer used, `auth-required:` deliberately
retryable. Rate-limited arms the shared relay_admission gate and waits
max(backoff, hint); backoff is 1s→30s saturating; attempts reset on
EVENT or EOSE. Terminal suppression is per-socket by design: a
reconnect retries a terminal id once through the normal path, because
relay policy can change and one REQ per reconnect is bounded.

The sync lifecycle is owned, not raced. The renderer allocates a
monotonic lease synchronously in effect order — intent order, which IPC
completion order is not — and Rust ignores any start/stop older than
the highest (epoch, lease) mark it has seen; a stop advances the mark,
so a delayed start cannot resurrect a stopped task. Above the lease,
Rust mints a realm epoch, published atomically with minting under the
same lock that orders lifecycle calls: announcing IS what supersedes
the previous realm, so a separately-held counter would leave a window
in which a dead realm's delayed calls still win. The renderer awaits
the epoch before its first lifecycle command. Ownership is
main-window-only via the established huddleWindowChannelId() exclusion:
a huddle companion mounts the same tree in a concurrent realm, and
concurrent owners cannot be ordered by any newest-wins clock.

What stays in JS is the start gate, deliberately. Kind 24200 is
relay-ephemeral, so frames emitted before the listener opens are lost
permanently, and only the renderer knows when observer reconciliation
finished seeding 24200 into the saved subscription. The backend task is
therefore not self-starting: `useArchiveSync` starts it once
reconciliation resolves and stops it on unmount.

`RelaySession.revision` was documented as rejecting stale in-flight
reconciliation but never did — removed rather than repaired:
declarative reconciliation re-reads the desired set every pass, so for
the open set there is no generation to guard. That argument does not
extend to CLOSED retry state, whose validity depends on the id having
been continuously desired — history that coalesced wakes erase. So
departures are recorded at write time: set_subscriptions diffs old
against new desired under the one SessionState lock and reconcile
snapshots the desired set and drains those departures in a single
acquisition, pruning the retry entries they invalidate. Without this,
deleting and recreating the same saved subscription (byte-identical id
by construction) inherited the old terminal latch and was suppressed
for the life of the socket. Two stale-frame races are closed alongside:
a CLOSED for an id not in `open` is stale and mints nothing (our own
CLOSE raced it, same defense the EVENT arm already had), and an EOSE
for an id not in `open` wakes a reconcile — EOSE is the only ordered
fence on the wire, and without that wake a stale terminal CLOSED
against a recreated id blackholes a live subscription with no timer or
wake left to recover it. A subscription id's filter is immutable for
the life of a session (a CLOSED carries only the id, so a rejection of
the old filter is indistinguishable from one of the new); the write-time
diff detects violations and logs them, with post-violation behavior
deliberately unspecified. The retries doc carries the full eviction
table, including the deliberately omitted absent-from-snapshot prune
and the inductive argument for why it is unreachable.

Tests: archive/sync_tests.rs drives the real run_sync body through a
fake IO seam (filter parity, flush thresholds, failure isolation) plus
the ownership contract (out-of-order start/stop both directions,
announcement supersedes a dead realm's delayed calls before any new
lifecycle call, publish-(epoch,0) does not lock out the announcing
realm). native_relay_client's stub-relay test completes the NIP-42
handshake over a real TCP socket, injects CLOSED with the desired set
unchanged, and proves the REQ is retried by the deadline arm; its
lifecycle suite (split into native_relay_client_tests.rs to stay under
the file-size ratchet) pins the retry-eviction contract with six
mutation-controlled tests, including the coalesced delete-recreate
that discriminates write-time recording from any observe-time prune,
and the stale-CLOSED/EOSE-heal schedules — the
relay-backed #[ignore] test additionally proves the REQ shape against a
real relay. useArchiveSync.test.mjs owns the start gate, realm
ownership, and post-reload realm supersession via fresh module
instances. observer-archive-policy.spec.ts owns wiring, with payload
receipts that announce precedes start and start carries a numeric
epoch. Every claim was mutation-checked; the vacuous first drafts
(wake-masked reopen, policy re-implementation, precondition-rebuilding
supersession, same-scope no-op escape) were each caught by their
mutants and rewritten.

Includes one move-only hunk that is not archive work: the push-to-talk
global-shortcut handler moves from lib.rs into `ptt_shortcut::install`,
mirroring the existing `app_menu::install` seam, paying the 1000-line
ratchet budget in the module that owns the registration lifecycle. The
handler body is proven token-identical with a mutated-body negative
control. lib.rs is 917 lines.

Co-authored-by: Tyler Longwell <tlongwell@squareup.com>
Signed-off-by: Tyler Longwell <tlongwell@squareup.com>
2026-08-15 22:50:17 -04:00
..