From 1cc6d81053a14cda499da6d7645b0f9d5448ef71 Mon Sep 17 00:00:00 2001 From: kNoAPP Date: Fri, 25 Sep 2026 21:51:26 -0700 Subject: [PATCH 1/4] feat(sync): drain messages and pull room history in the background Connecting no longer waits on the radio's offline message queue: the initial sync ends after channels, and the queue drains in the background as a catch-up every time. Backlogged messages are withheld from automation, which is live-only. Once the drain finishes, every room server with a remembered credential is signed in to, which makes it replay the posts stored since its last delivery. Each room gets up to three login attempts (the last flooding), and a replay that goes quiet after delivering posts is resumed with another login, up to three rounds, since a room stops pushing after three unacknowledged posts. The room view waits out a background sign-in instead of sending a second one over it. --- components/ActionBar.tsx | 8 +- components/SyncProgressView.tsx | 9 +- hooks/useMeshCore.ts | 141 ++++---------- hooks/useRepeaterAutoLogin.ts | 81 +++++--- lib/meshcore/client.ts | 75 +++----- lib/session/lifecycle.ts | 2 +- lib/session/remoteLogin.ts | 320 ++++++++++++++++++++++++++++++++ locales/de.json | 2 - locales/en.json | 2 - locales/es.json | 2 - locales/fr.json | 2 - store/meshStore.ts | 8 +- types/meshcore.ts | 2 +- 13 files changed, 442 insertions(+), 212 deletions(-) create mode 100644 lib/session/remoteLogin.ts diff --git a/components/ActionBar.tsx b/components/ActionBar.tsx index 39c4864e..cbf7b451 100644 --- a/components/ActionBar.tsx +++ b/components/ActionBar.tsx @@ -125,10 +125,10 @@ function TransientNotice() { ); } -// The connect-time drain is capped so the UI comes up promptly, and a deeper -// offline queue finishes in the background. This is the only standing sign -// that it is still running — without it the remainder arrives unannounced, -// since the arrivals themselves are collapsed into one summary at the end. +// The radio's offline queue is drained in the background after connect, so the +// UI comes up without waiting on it. This is the only standing sign that it is +// still running — without it the backlog arrives unannounced, since the +// arrivals themselves are collapsed into one summary at the end. // // Indeterminate by necessity: the companion protocol has no queue-depth query, // so neither a percentage nor a remaining count is knowable. The spinner says diff --git a/components/SyncProgressView.tsx b/components/SyncProgressView.tsx index d37d11dc..b47f4f80 100644 --- a/components/SyncProgressView.tsx +++ b/components/SyncProgressView.tsx @@ -13,7 +13,6 @@ const SYNC_STAGES: SyncProgress['stage'][] = [ 'clock', 'contacts', 'channels', - 'messages', ]; const SYNC_STAGE_KEY = { @@ -21,15 +20,9 @@ const SYNC_STAGE_KEY = { clock: 'sync.clock', contacts: 'sync.contacts', channels: 'sync.channels', - messages: 'sync.messages', } as const satisfies Record; -function syncDetail( - t: TFunction, - { stage, current, total }: SyncProgress, -): string { - if (stage === 'messages') - return current ? ` (${t('sync.received', { count: current })})` : ''; +function syncDetail(t: TFunction, { current, total }: SyncProgress): string { if (current != null && total != null) return ` (${t('sync.progress', { current, total })})`; return ''; diff --git a/hooks/useMeshCore.ts b/hooks/useMeshCore.ts index bebff183..a0fb88ef 100644 --- a/hooks/useMeshCore.ts +++ b/hooks/useMeshCore.ts @@ -5,7 +5,7 @@ import { useCallback, useRef } from 'react'; import { MeshCoreClient } from '@/lib/meshcore/client'; -import { PickerDismissedError, PushTimeoutError } from '@/lib/meshcore/errors'; +import { PickerDismissedError } from '@/lib/meshcore/errors'; import { createUSBTransport, createBLETransport, @@ -85,7 +85,11 @@ import { ADVERT_LOC_POLICY, MAX_CHANNEL_SLOTS, } from '@/lib/meshcore/constants'; -import { saveRepeaterCred } from '@/lib/meshcore/adminCreds'; +import { + isReplayingRoom, + loginNode, + signInRememberedRooms, +} from '@/lib/session/remoteLogin'; import { isErrorReply } from '@/lib/meshcore/repeaterConfig'; import { toHex, @@ -250,7 +254,6 @@ export function useMeshCore() { restoreAdvertCache, restoreAutomationRules, restorePreferences, - setAdminLogin, setRepeaterStatus, setNodeTelemetry, setActiveConvo, @@ -305,7 +308,7 @@ export function useMeshCore() { // screen reporting a radio that is already gone. The test is the // session, not the status: `init` hands back before // `setStatus('connected')`, and the key derivation in between is - // PBKDF2 at 100k iterations, so a short overflow drain can finish + // PBKDF2 at 100k iterations, so a short connect-time drain can finish // inside that window and a status test would silently drop its // summary. `c` stays the store's client from before `init` until a // teardown replaces it, which is exactly the span that should @@ -366,8 +369,9 @@ export function useMeshCore() { const visible = isConvoVisible(state, id); addMessage(id, enriched); if (state.backlogDraining) backlogDelivered++; - // `c.init()` drains the radio's backlog while the connect screen is - // still up, where a "go to this conversation" cue leads nowhere — + // The backlog drain starts before 'connected' — `c.init()` hands + // back first — and a message landing while the connect screen is + // still up has nowhere for a "go to this conversation" cue to lead; // the action bar isn't mounted either. Those messages stay unread // instead. if (state.status === 'connected') { @@ -393,7 +397,7 @@ export function useMeshCore() { // desktop banner still only cover a conversation that is off // screen — and, while the backlog drains, not even then. That // drain runs past 'connected', so without this the exemption - // above would stop at the connect-time pass and the rest of a + // above would stop at the connect screen and the rest of a // 300-message queue would take a row each, pushing every other // notice out of a 50-row drawer. One summary covers them when // the drain ends. @@ -427,7 +431,11 @@ export function useMeshCore() { } } // Emit after the store update so subscribers see a settled world. - emit({ type: 'message', msg: enriched }); + // A backlog arrival is withheld: automation is live-only, and a + // message queued on the radio can be hours old — replying to it + // now is worse than not acting (docs/design/ai-automation.md §5). + if (!state.backlogDraining) + emit({ type: 'message', msg: enriched }); } else if (msg.kind === 'direct' && msg.pubkeyPrefix) { const contact = c.lookupContact(msg.pubkeyPrefix); // A v3 frame's prefix can be longer than the one stored for the @@ -503,7 +511,11 @@ export function useMeshCore() { notifyArrival(convo, sender, msg.text); } } - emit({ type: 'message', msg: enriched }); + // Withheld during the backlog, as on the channel branch above, and + // so is a room's replay while the connect-time sync pulls it: those + // posts were written while we were away. + if (!state.backlogDraining && !(isRoom && isReplayingRoom(prefix))) + emit({ type: 'message', msg: enriched }); } }, // A drop only triggers the reconnect loop once we're fully connected; a @@ -782,6 +794,10 @@ export function useMeshCore() { // above that rejects tears the session down as a failed connect, and // the card (and the tab it pre-selects) has to survive that. setLastConnectFailure(null); + // Room history rides on a login, so each remembered room is signed in + // to once the offline queue has drained — in the background, like the + // drain itself. + void signInRememberedRooms(c, sessionAlive); return true; } catch (err) { setSyncProgress(null); @@ -1092,114 +1108,19 @@ export function useMeshCore() { ); /** - * Logs in to a repeater/room server for remote admin. Marks the session - * `pending`, then on success the granted level, or `loggedOut` on failure - * (surfaced as a notification). A room server's own reported role wins, - * because it is what decides whether the member may post; a repeater's is - * ignored in favour of the level the user selected (`kind`), since a - * blank/guest login re-uses an admin-enrolled node's stored ACL role and - * would otherwise show a guest session as admin. When `remember` is set, the - * password is persisted encrypted per-radio in the `secrets` store (never in - * the store, prefs blob, or localStorage); otherwise it is not persisted. - * - * @param quiet - suppress the *timeout* notice, for a caller that shows the - * outcome itself. An automatic retry cycle sets it on every attempt but its - * last, so one unreachable node speaks once rather than once per attempt. - * A rejection the radio reported still speaks: it ends such a cycle at once, - * so its message has no later attempt to carry it. - * @returns how the attempt ended, so a caller can retry only the transient - * shape. See {@link RepeaterLoginOutcome}. + * Logs in to a repeater/room server for remote admin, on this session's + * client. See {@link loginNode}. */ const repeaterLogin = useCallback( - async ( + ( contact: Contact, password: string, kind: LoginKind, remember: boolean, quiet = false, - ): Promise => { - if (!canTransmit(client)) return 'offline'; - setAdminLogin(contact.pubkeyPrefix, 'pending'); - try { - const { access: granted, clockSkewSecs } = await client.login( - contact, - password, - ); - // A drop during login can tear the session down; don't revive it. - if (!canTransmit(client)) return 'offline'; - // A room grants three roles and the middle one (the room password) - // is what decides whether the composer may post, so its - // server-reported role is authoritative. A repeater reflects the - // level the user chose instead: it re-uses your existing ACL role for - // a blank/guest login, so an admin-enrolled node would otherwise - // report admin even when you intended a read-only guest session. The - // node still enforces real permissions either way. - const isRoom = contact.advType === ADV_TYPE_ROOM; - setAdminLogin( - contact.pubkeyPrefix, - (isRoom && granted) || kind, - clockSkewSecs ?? undefined, - ); - // The radio stays connected to a room it logged into, so its posts - // keep arriving on later connects before any login measures the - // room again: the deferred backlog drain, and pushes landing before - // the room is reopened. Carrying the skew in the persisted advert - // cache gives `roomPostTime` a measurement for those. (The drain pass - // inside `init` runs before the cache is restored and goes without, - // but `restoreHistory` puts the saved history ahead of those posts.) - const cached = - useMeshStore.getState().advertCache[contact.pubkeyPrefix]; - if (isRoom && cached && clockSkewSecs !== null) { - cacheAdverts({ - [contact.pubkeyPrefix]: { ...cached, clockSkewSecs }, - }); - } - // Only a successful login is ever remembered, so a wrong password can't - // be persisted. The credential lives solely in the encrypted per-radio - // secrets store — never the store, prefs blob, or localStorage. - if (remember) { - void saveRepeaterCred(contact.pubkeyPrefix, { - access: kind, - password, - }); - } - return 'ok'; - } catch (err) { - // A disconnect/drop rejects the pending login and runs its own - // teardown; don't clobber that outcome with a stale login error. A full - // disconnect already cleared the slice (leave it gone); a transient - // drop keeps the entry, so just clear its `pending` spinner silently. - if (!canTransmit(client)) { - if (useMeshStore.getState().adminSessions[contact.pubkeyPrefix]) { - setAdminLogin(contact.pubkeyPrefix, 'loggedOut'); - } - return 'offline'; - } - setAdminLogin(contact.pubkeyPrefix, 'loggedOut'); - // The node never answered — the raw "Timeout waiting for push from - // " says nothing a user can act on, so name the two causes it - // actually has instead. - const timedOut = err instanceof PushTimeoutError; - // `quiet` covers only the silence a retry cycle is about to answer for - // itself. A reported rejection stops that cycle where it stands, so - // swallowing its message would lose the one thing that explains why. - if (!quiet || !timedOut) { - notify({ - level: 'error', - text: timedOut - ? i18n.t('notify.repeaterLoginTimedOut', { - name: contact.name || contact.pubkeyPrefix.slice(0, 8), - }) - : i18n.t('notify.repeaterLoginFailed', { - error: (err as Error).message, - }), - key: `repeaterLogin:${contact.pubkeyPrefix}`, - }); - } - return timedOut ? 'timeout' : 'failed'; - } - }, - [client, setAdminLogin, cacheAdverts, notify], + ): Promise => + loginNode(client, contact, password, kind, remember, quiet), + [client], ); /** diff --git a/hooks/useRepeaterAutoLogin.ts b/hooks/useRepeaterAutoLogin.ts index 8890a311..310e1650 100644 --- a/hooks/useRepeaterAutoLogin.ts +++ b/hooks/useRepeaterAutoLogin.ts @@ -5,26 +5,19 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useMeshCore } from '@/hooks/useMeshCore'; -import { useMeshStore, isAuthedLogin } from '@/store/meshStore'; +import { + useMeshStore, + isAuthedLogin, + type AdminLoginState, +} from '@/store/meshStore'; import { loadRepeaterCred } from '@/lib/meshcore/adminCreds'; import { NO_PATH } from '@/lib/meshcore/constants'; +import { + LOGIN_ATTEMPTS, + LOGIN_RETRY_BACKOFF_MS, +} from '@/lib/session/remoteLogin'; import type { Contact, LoginKind } from '@/types/meshcore'; -/** - * Sign-in attempts a credential gets — a remembered one on entering a node, or - * one just typed into the form — the initial try plus its retries. Bounded, - * because a node whose password really did change (or was mistyped) will never - * answer, and an unbounded cycle would spend a shared LoRa mesh's airtime - * proving it. - */ -export const LOGIN_ATTEMPTS = 3; - -// Breathing room between automatic attempts. A mesh that is merely busy — a -// neighbor mid-transmission, a queue backed up behind a flood — settles in -// about this long, where retrying immediately would mostly collide with -// whatever swallowed the previous attempt. -const LOGIN_RETRY_BACKOFF_MS = 3000; - /** Why a sign-in attempt ended, once one has failed. */ export type LoginFailure = 'timeout' | 'failed'; @@ -75,6 +68,28 @@ export interface RepeaterAutoLogin { const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); +// Resolves with the node's login once no attempt holds it `pending`, at once +// when none does. Another sign-in can be mid-handshake — the connect-time room +// sync, or a cycle from an earlier mount of this view — and a second login sent +// over it would race the first for the same success push. Every pending login +// settles, a teardown included, so the subscription never outlives it. +function settledLogin(prefix: string): Promise { + const read = () => + useMeshStore.getState().adminSessions[prefix]?.login ?? 'loggedOut'; + return new Promise((resolve) => { + if (read() !== 'pending') { + resolve(read()); + return; + } + const unsub = useMeshStore.subscribe(() => { + const login = read(); + if (login === 'pending') return; + unsub(); + resolve(login); + }); + }); +} + /** * Drives sign-in for one repeater or room server: probes the encrypted * `secrets` store for a remembered credential on entry and replays it. Either @@ -120,14 +135,12 @@ export function useRepeaterAutoLogin(contact: Contact): RepeaterAutoLogin { const contactRef = useRef(contact); const repeaterLoginRef = useRef(repeaterLogin); const resetPathRef = useRef(resetContactPath); - const loginRef = useRef(login); // Declared before the probe effect below so a render's values are in place // before that effect consults them. useEffect(() => { repeaterLoginRef.current = repeaterLogin; resetPathRef.current = resetContactPath; - loginRef.current = login; }); useEffect( @@ -173,10 +186,14 @@ export function useRepeaterAutoLogin(contact: Contact): RepeaterAutoLogin { } for (let i = 1; i <= total; i++) { - // A login that resolved while this cycle sat between attempts — a - // manual one, or the node answering late — is the outcome already; - // don't send over the top of it. - if (isAuthedLogin(loginRef.current)) { + // Another sign-in may have claimed the node while this cycle sat + // between attempts — the connect-time room sync starts one on any + // room it finds logged out — so wait it out rather than send over it. + // A login that resolved meanwhile, that one or the node answering + // late, is the outcome already. + const settled = await settledLogin(prefix); + if (!live()) return; + if (isAuthedLogin(settled)) { setFailure(null); break; } @@ -223,20 +240,28 @@ export function useRepeaterAutoLogin(contact: Contact): RepeaterAutoLogin { } setAttempt(0); }, - [liveContact], + [liveContact, prefix], ); // On entry, when there is no live session yet, probe the encrypted store for // a remembered credential and auto-log-in with it. Keyed by the stable // `prefix`, so it runs once per node (remounted when the selection changes). useEffect(() => { - // Only probe from a clean logged-out state. Skipping `admin`/`guest` avoids - // clobbering a live session; skipping `pending` avoids queuing a second - // login when the view remounts mid-login (e.g. switching away and back - // during a multi-hop handshake). - if (loginRef.current !== 'loggedOut') return; let cancelled = false; void (async () => { + // Only probe from a clean logged-out state. An `admin`/`guest` session is + // left alone rather than clobbered. A `pending` one — the view remounted + // mid-login, or the connect-time room sync signing in — is waited out + // instead of sent over, and a failure then probes as a clean entry would. + const settled = await settledLogin(prefix); + if (cancelled) return; + if (settled !== 'loggedOut') { + setChecking(false); + return; + } + // After a wait, the form showed disabled while the other login ran; + // cover the probe with the spinner a clean entry starts with. + setChecking(true); const cred = await loadRepeaterCred(prefix); if (cancelled) return; setChecking(false); diff --git a/lib/meshcore/client.ts b/lib/meshcore/client.ts index 09297735..4f44fb5c 100644 --- a/lib/meshcore/client.ts +++ b/lib/meshcore/client.ts @@ -194,11 +194,10 @@ const CONTACTS_FULL_NOTIFY_INTERVAL_MS = 300000; * How many messages one {@link MeshCoreClient} drain pass pulls before handing * control back. * - * @remarks Bounds how long the connect screen stays up against a large offline - * queue: the rest is drained in the background once the UI is available. The - * companion protocol has no queue-depth query, so a pass that consumes every - * slot cannot tell a drained queue from a truncated one — hence the cap is - * reported rather than inferred. + * @remarks A pass that fills every slot is what marks steady-state traffic as + * a backlog (see `drainMessages`). The companion protocol has no queue-depth + * query, so a pass that consumes every slot cannot tell a drained queue from a + * truncated one — hence the cap is reported rather than inferred. */ const MESSAGE_DRAIN_CAP = 64; @@ -292,8 +291,8 @@ export interface MeshCoreCallbacks { onSyncProgress?: (progress: SyncProgress) => void; /** * The background message drain started or finished catching up on an offline - * queue too large for the connect-time pass. Fires only on the edges, and - * only ever `false` after a `true`. + * queue — the one waiting at connect, or one too large for a single pass. + * Fires only on the edges, and only ever `false` after a `true`. * * @remarks Between the two the queue is draining as fast as the radio * answers, with no way to say how much is left — the protocol has no @@ -417,17 +416,15 @@ export class MeshCoreClient { /** * Runs the connect handshake and initial sync: `APP_START`, `DEVICE_QUERY`, - * a best-effort clock sync, full contact + channel sync, and a first message - * drain, reporting progress via {@link MeshCoreCallbacks.onSyncProgress}. - * Then starts the 5s message - * poll. Individual steps are best-effort — a timeout is swallowed so a slow - * radio still finishes connecting. + * a best-effort clock sync, and full contact + channel sync, reporting + * progress via {@link MeshCoreCallbacks.onSyncProgress}. Then starts the 5s + * message poll. Individual steps are best-effort — a timeout is swallowed so + * a slow radio still finishes connecting. * - * @remarks The message drain is capped at {@link MESSAGE_DRAIN_CAP} so a - * large offline queue can't hold the connect screen up. A queue deeper than - * that resolves with the remainder still on the radio, reported over - * {@link MeshCoreCallbacks.onBacklogDraining} and finished in the - * background. + * @remarks The radio's offline message queue is not part of the sync: it is + * drained in the background once this resolves, reported over + * {@link MeshCoreCallbacks.onBacklogDraining}, so no queue depth can hold + * the connect screen up. */ async init(): Promise { // Register the close handler before starting the read loop: a transport @@ -476,30 +473,17 @@ export class MeshCoreClient { this.reportSync('contacts', 10); await this.syncStep(() => this.syncContacts()); await this.syncStep(() => this.syncChannels()); - // The outcome has to travel back out of the step: a queue deeper than one - // pass is finished in the background, so a truncated drain must not - // report 100% as if it had completed. - let outcome: DrainOutcome = 'drained'; - await this.syncStep(async () => { - outcome = await this.pollMessages(); - }); - // Anything but a drained queue defers. `unknown` is a pass that timed out - // or never ran because another held the queue: the read loop is live from - // the top of init, so a PUSH_MSG_WAITING answered during contact - // enumeration can still be mid-drain here. A drain still running has not - // reached NO_MORE_MESSAGES, so its queue is not empty either. - const deferred = outcome !== 'drained' || this.draining; - if (!deferred) this.reportSync('messages', 100); + this.reportSync('channels', 100); this.initialSync = false; this.pollTimer = setInterval(() => void this.drainMessages(), 5000); - if (deferred) { - // Raise the flag before handing back, so the UI that mounts on - // 'connected' is already showing the catch-up rather than flashing it on - // one tick later. A drain already in flight clears it on its own way - // out; the call below is a no-op while one holds the queue. - this.setBacklogDraining(true); - void this.drainMessages(); - } + // Raise the flag before handing back, so the UI that mounts on 'connected' + // is already showing the catch-up rather than flashing it on one tick + // later. The read loop is live from the top of init, so a PUSH_MSG_WAITING + // answered during contact enumeration can still be mid-drain here: that + // drain clears the flag on its own way out, and the call below is a no-op + // while it holds the queue. + this.setBacklogDraining(true); + void this.drainMessages(); } // Verifies the link survived the step, so a mid-sync drop aborts `init` @@ -829,7 +813,7 @@ export class MeshCoreClient { const frac = Math.min(1, this.contactsSeen / this.contactsTotal); this.reportSync( 'contacts', - 10 + 35 * frac, + 10 + 50 * frac, this.contactsSeen, this.contactsTotal, ); @@ -1103,7 +1087,7 @@ export class MeshCoreClient { private async syncChannels(): Promise { const slots = this.deviceInfo?.maxChannels || MAX_CHANNEL_SLOTS; for (let i = 0; i < slots; i++) { - this.reportSync('channels', 45 + (30 * i) / slots, i + 1, slots); + this.reportSync('channels', 60 + (40 * i) / slots, i + 1, slots); try { await this.cmd(buildGetChannelInfo(i), [RESP.CHANNEL_INFO], 2000); this._unreadChannelSlots.delete(i); @@ -1179,11 +1163,6 @@ export class MeshCoreClient { let outcome: DrainOutcome = 'unknown'; try { for (let i = 0; i < MESSAGE_DRAIN_CAP; i++) { - // Queue depth is unknown ahead of time, so the bar creeps toward the - // end of its band across the whole cap. Spread over the cap rather - // than a steeper ramp that would sit pinned at the last percent for - // most of a full pass and then jump, which reads as a skipped step. - this.reportSync('messages', 75 + (24 * i) / MESSAGE_DRAIN_CAP, i); const d = await this.cmd(buildSyncNextMessage(), msgTypes, 3000); if (d[0] === RESP.NO_MORE_MESSAGES) { outcome = 'drained'; @@ -1210,8 +1189,8 @@ export class MeshCoreClient { // // The flag arms only once a pass comes back full: arriving traffic is not a // backlog until one pass can't carry it, and arming on entry would silence - // every ordinary message. `init` arms up front whenever its connect-time - // pass did not drain the queue. + // every ordinary message. `init` arms up front for the connect-time drain, + // since the offline queue behind it is of unknown depth. private async drainMessages(): Promise { if (this.draining) return; this.draining = true; diff --git a/lib/session/lifecycle.ts b/lib/session/lifecycle.ts index 01150e19..8784b898 100644 --- a/lib/session/lifecycle.ts +++ b/lib/session/lifecycle.ts @@ -55,7 +55,7 @@ export function clearSessionState(): void { store.setDeviceClock(null); store.setDeviceBattery(null); // Same reason, one surface over: the action bar's quick link names the - // newest message we know of, and the reconnect drain lands its whole + // newest message we know of, and the reconnect drain starts landing its // backlog while the status is still 'reconnecting' — too early to update // it. Retract it rather than let it name a pre-drop arrival the sidebar's // unread badges already contradict. diff --git a/lib/session/remoteLogin.ts b/lib/session/remoteLogin.ts new file mode 100644 index 00000000..aeb14d10 --- /dev/null +++ b/lib/session/remoteLogin.ts @@ -0,0 +1,320 @@ +// Required Notice: Copyright 2026 Knoban LLC. All rights reserved. +// (https://github.com/kNoAPP/MeshCore-WebAgent) + +import type { MeshCoreClient } from '@/lib/meshcore/client'; +import { PushTimeoutError } from '@/lib/meshcore/errors'; +import { ADV_TYPE_ROOM, NO_PATH } from '@/lib/meshcore/constants'; +import { + loadRepeaterCred, + saveRepeaterCred, + type RememberedCred, +} from '@/lib/meshcore/adminCreds'; +import { canTransmit } from '@/lib/session/guards'; +import { useMeshStore, isAuthedLogin, roomConvoId } from '@/store/meshStore'; +import i18n from '@/lib/i18n'; +import type { + Contact, + LoginKind, + Message, + RepeaterLoginOutcome, +} from '@/types/meshcore'; + +/** + * Sign-in attempts a credential gets — a remembered one on entering a node or + * on connect, or one just typed into the form — the initial try plus its + * retries. Bounded, because a node whose password really did change (or was + * mistyped) will never answer, and an unbounded cycle would spend a shared LoRa + * mesh's airtime proving it. + */ +export const LOGIN_ATTEMPTS = 3; + +/** + * Breathing room between automatic attempts. A mesh that is merely busy — a + * neighbor mid-transmission, a queue backed up behind a flood — settles in + * about this long, where retrying immediately would mostly collide with + * whatever swallowed the previous attempt. + */ +export const LOGIN_RETRY_BACKOFF_MS = 3000; + +/** + * Logins the connect-time sync spends pulling one room's history. + * + * @remarks A room pushes its stored posts one at a time, each waiting on our + * ACK, and stops pushing to us after three in a row go unacknowledged. Only + * another login resumes it — the companion firmware sends no keep-alive — and + * it resumes from the last post the radio received, so nothing repeats. A + * replay that went quiet after delivering posts may therefore have stalled + * rather than finished, and gets another login; one that delivers nothing ends + * the pull. + */ +const ROOM_PULL_ROUNDS = 3; + +/** + * How long a room's replay goes without a post before its round is over. + * + * @remarks Longer than a stall takes to show: the room waits 2s after a login + * before its first push, gives each push up to 12s for our ACK over flood, and + * gives up after three. + */ +const ROOM_REPLAY_QUIET_MS = 45_000; + +const delay = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + +/** + * Logs in to a repeater/room server for remote admin. Marks the session + * `pending`, then on success the granted level, or `loggedOut` on failure + * (surfaced as a notification). A room server's own reported role wins, + * because it is what decides whether the member may post; a repeater's is + * ignored in favour of the level the user selected (`kind`), since a + * blank/guest login re-uses an admin-enrolled node's stored ACL role and + * would otherwise show a guest session as admin. When `remember` is set, the + * password is persisted encrypted per-radio in the `secrets` store (never in + * the store, prefs blob, or localStorage); otherwise it is not persisted. + * + * @remarks The session goes `pending` in the same synchronous step as the + * link check, so a caller that has just read the node as `loggedOut` can + * claim it without another sign-in slipping in between. A login over a + * session that is already signed in renews it instead: the session stays up + * throughout, since the node accepted us once and a reader of its feed + * should not be put back behind the login gate, and a failed renewal leaves + * it as it was. A renewal the user logged out from meanwhile is dropped, + * credential and all. + * @param quiet - suppress the *timeout* notice, for a caller that shows the + * outcome itself. An automatic retry cycle sets it on every attempt but its + * last, so one unreachable node speaks once rather than once per attempt. + * A rejection the radio reported still speaks: it ends such a cycle at once, + * so its message has no later attempt to carry it. + * @returns how the attempt ended, so a caller can retry only the transient + * shape. See {@link RepeaterLoginOutcome}. + */ +export async function loginNode( + client: MeshCoreClient | null, + contact: Contact, + password: string, + kind: LoginKind, + remember: boolean, + quiet = false, +): Promise { + if (!canTransmit(client)) return 'offline'; + const prefix = contact.pubkeyPrefix; + const { setAdminLogin, cacheAdverts, notify } = useMeshStore.getState(); + const prior = useMeshStore.getState().adminSessions[prefix]; + const renewing = isAuthedLogin(prior?.login); + // Whether a renewal still has the session it set out to renew: a log-out + // deletes it, and a later login replaces its token. + const renewedAway = () => + renewing && + useMeshStore.getState().adminSessions[prefix]?.token !== prior?.token; + if (!renewing) setAdminLogin(prefix, 'pending'); + try { + const { access: granted, clockSkewSecs } = await client.login( + contact, + password, + ); + // A drop during login can tear the session down; don't revive it. + if (!canTransmit(client)) return 'offline'; + if (renewedAway()) return 'failed'; + // A room grants three roles and the middle one (the room password) + // is what decides whether the composer may post, so its + // server-reported role is authoritative. A repeater reflects the + // level the user chose instead: it re-uses your existing ACL role for + // a blank/guest login, so an admin-enrolled node would otherwise + // report admin even when you intended a read-only guest session. The + // node still enforces real permissions either way. + const isRoom = contact.advType === ADV_TYPE_ROOM; + setAdminLogin( + prefix, + (isRoom && granted) || kind, + clockSkewSecs ?? undefined, + ); + // The radio stays connected to a room it logged into, so its posts + // keep arriving on later connects before any login measures the + // room again: the connect-time backlog drain, and pushes landing + // before the room is signed in to again. Carrying the skew in the + // persisted advert cache gives `roomPostTime` a measurement for those. + // (Posts the drain lands before the cache is restored go without, but + // `restoreHistory` puts the saved history ahead of those posts.) + const cached = useMeshStore.getState().advertCache[prefix]; + if (isRoom && cached && clockSkewSecs !== null) { + cacheAdverts({ [prefix]: { ...cached, clockSkewSecs } }); + } + // Only a successful login is ever remembered, so a wrong password can't + // be persisted. The credential lives solely in the encrypted per-radio + // secrets store — never the store, prefs blob, or localStorage. + if (remember) { + void saveRepeaterCred(prefix, { access: kind, password }); + } + return 'ok'; + } catch (err) { + // A disconnect/drop rejects the pending login and runs its own + // teardown; don't clobber that outcome with a stale login error. A full + // disconnect already cleared the slice (leave it gone); a transient + // drop keeps the entry, so just clear its `pending` spinner silently. + if (!canTransmit(client)) { + if (!renewing && useMeshStore.getState().adminSessions[prefix]) { + setAdminLogin(prefix, 'loggedOut'); + } + return 'offline'; + } + if (renewedAway()) return 'failed'; + if (!renewing) setAdminLogin(prefix, 'loggedOut'); + // The node never answered — the raw "Timeout waiting for push from + // " says nothing a user can act on, so name the two causes it + // actually has instead. + const timedOut = err instanceof PushTimeoutError; + // `quiet` covers only the silence a retry cycle is about to answer for + // itself. A reported rejection stops that cycle where it stands, so + // swallowing its message would lose the one thing that explains why. + if (!quiet || !timedOut) { + notify({ + level: 'error', + text: timedOut + ? i18n.t('notify.repeaterLoginTimedOut', { + name: contact.name || prefix.slice(0, 8), + }) + : i18n.t('notify.repeaterLoginFailed', { + error: (err as Error).message, + }), + key: `repeaterLogin:${prefix}`, + }); + } + return timedOut ? 'timeout' : 'failed'; + } +} + +// Rooms whose history the connect-time sync is pulling right now. +const replaying = new Set(); + +/** + * Whether the connect-time sync is pulling this room's history, so a post + * from it is most likely its replay — written while we were away — rather + * than live traffic. + */ +export function isReplayingRoom(prefix: string): boolean { + return replaying.has(prefix); +} + +// Resolves once the backlog drain is not running, at once when it already +// isn't. A teardown lowers the flag too, so a session that ends mid-drain +// resolves here and leaves the caller's liveness check to stop it. +function backlogSettled(): Promise { + return new Promise((resolve) => { + if (!useMeshStore.getState().backlogDraining) { + resolve(); + return; + } + const unsub = useMeshStore.subscribe((state) => { + if (state.backlogDraining) return; + unsub(); + resolve(); + }); + }); +} + +// One login round with the remembered credential: up to LOGIN_ATTEMPTS tries, +// the last flooding after a route reset, as the room view's own cycle does. +// Gives way to a sign-in the room view has in flight, whose outcome is its to +// report. +async function signInRoom( + client: MeshCoreClient, + prefix: string, + cred: RememberedCred, + alive: () => boolean, +): Promise { + for (let i = 1; i <= LOGIN_ATTEMPTS; i++) { + if (!alive()) return false; + const stale = useMeshStore.getState().contacts[prefix]; + if (i === LOGIN_ATTEMPTS && stale && stale.outPathLen !== NO_PATH) { + // Best-effort: a failed reset leaves the path in place, and the last + // attempt still goes out on it. + await client.resetPath(stale).catch(() => undefined); + if (!alive()) return false; + } + // Read after every await, and claimed by `loginNode` in the same + // synchronous step, so the room view's cycle can't be mid-handshake when + // this one is sent. A contact deleted meanwhile ends the round. + const state = useMeshStore.getState(); + const contact = state.contacts[prefix]; + if (!contact || state.adminSessions[prefix]?.login === 'pending') { + return false; + } + const outcome = await loginNode( + client, + contact, + cred.password, + cred.access, + true, + true, + ); + if (outcome === 'ok') return true; + if (outcome !== 'timeout') return false; + if (i < LOGIN_ATTEMPTS) await delay(LOGIN_RETRY_BACKOFF_MS); + } + return false; +} + +const inboundCount = (list: Message[] | undefined): number => + list?.reduce((n, m) => (m.own ? n : n + 1), 0) ?? 0; + +// Waits until the room's feed has gone ROOM_REPLAY_QUIET_MS without an inbound +// post, or the session ends, and resolves with how many posts landed. +function replayQuiet(prefix: string, alive: () => boolean): Promise { + const id = roomConvoId(prefix); + let posts = 0; + let lastAt = Date.now(); + const unsub = useMeshStore.subscribe((state, prev) => { + const list = state.msgHistory[id]; + const before = prev.msgHistory[id]; + if (list === before) return; + const added = inboundCount(list) - inboundCount(before); + if (added <= 0) return; + posts += added; + lastAt = Date.now(); + }); + return new Promise((resolve) => { + const timer = setInterval(() => { + if (alive() && Date.now() - lastAt < ROOM_REPLAY_QUIET_MS) return; + clearInterval(timer); + unsub(); + resolve(posts); + }, 1000); + }); +} + +/** + * Pulls the history of every room server in the contact table that has a + * remembered credential, one room at a time, once the connect-time message + * drain has finished. + * + * @remarks A room replays the posts it has stored since it last delivered to + * us once we log in, so each pull is a login — up to {@link LOGIN_ATTEMPTS} + * of them — and then a wait for the replay to go quiet. A replay that + * delivered posts may have stalled rather than finished, so it is resumed + * with another login, up to {@link ROOM_PULL_ROUNDS} per room. A room the + * room view is signing in to meanwhile is left to it. + * @param alive - whether the session this sync belongs to is still up. + */ +export async function signInRememberedRooms( + client: MeshCoreClient, + alive: () => boolean, +): Promise { + await backlogSettled(); + const prefixes = Object.values(useMeshStore.getState().contacts) + .filter((c) => c.advType === ADV_TYPE_ROOM) + .map((c) => c.pubkeyPrefix); + for (const prefix of prefixes) { + if (!alive()) return; + const cred = await loadRepeaterCred(prefix); + if (!cred) continue; + replaying.add(prefix); + try { + for (let round = 1; round <= ROOM_PULL_ROUNDS; round++) { + if (!(await signInRoom(client, prefix, cred, alive))) break; + if ((await replayQuiet(prefix, alive)) === 0) break; + } + } finally { + replaying.delete(prefix); + } + } +} diff --git a/locales/de.json b/locales/de.json index af42f17e..6bb6af2a 100644 --- a/locales/de.json +++ b/locales/de.json @@ -335,8 +335,6 @@ "clock": "Synchronisiere Uhr", "contacts": "Synchronisiere Kontakte", "channels": "Synchronisiere Kanäle", - "messages": "Synchronisiere Nachrichten", - "received": "{{count}} empfangen", "progress": "{{current}} von {{total}}", "label": "Synchronisierungsfortschritt", "valueText": "{{stage}} — {{percent}} %", diff --git a/locales/en.json b/locales/en.json index 7175ebb1..e4b2e60b 100644 --- a/locales/en.json +++ b/locales/en.json @@ -334,8 +334,6 @@ "clock": "Syncing clock", "contacts": "Syncing contacts", "channels": "Syncing channels", - "messages": "Syncing messages", - "received": "{{count}} received", "progress": "{{current}} of {{total}}", "label": "Sync progress", "valueText": "{{stage}} — {{percent}}%", diff --git a/locales/es.json b/locales/es.json index ef5fb651..14556a6e 100644 --- a/locales/es.json +++ b/locales/es.json @@ -335,8 +335,6 @@ "clock": "Sincronizando reloj", "contacts": "Sincronizando contactos", "channels": "Sincronizando canales", - "messages": "Sincronizando mensajes", - "received": "{{count}} recibidos", "progress": "{{current}} de {{total}}", "label": "Progreso de la sincronización", "valueText": "{{stage}} — {{percent}} %", diff --git a/locales/fr.json b/locales/fr.json index 79022098..01f4a250 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -335,8 +335,6 @@ "clock": "Synchronisation de l'horloge", "contacts": "Synchronisation des contacts", "channels": "Synchronisation des canaux", - "messages": "Synchronisation des messages", - "received": "{{count}} reçus", "progress": "{{current}} sur {{total}}", "label": "Progression de la synchronisation", "valueText": "{{stage}} — {{percent}} %", diff --git a/store/meshStore.ts b/store/meshStore.ts index a49a0c70..9de3c0fd 100644 --- a/store/meshStore.ts +++ b/store/meshStore.ts @@ -685,14 +685,14 @@ interface MeshState { */ latestInbound: LatestInbound | null; /** - * Whether the background drain is still catching up on an offline queue too - * large for the connect-time pass. + * Whether the background drain is still catching up on an offline queue — + * the one waiting at connect, or one too large for a single pass. * * @remarks Indeterminate by necessity — the companion protocol has no * queue-depth query, so there is no remaining count or fraction to report. * While it is set, arrivals are collapsed into one summary instead of each - * raising its own notification. Session state, cleared by `reset()` and - * never persisted. + * raising its own notification, and are withheld from automation. Session + * state, cleared by `reset()` and never persisted. */ backlogDraining: boolean; /** diff --git a/types/meshcore.ts b/types/meshcore.ts index 8091af83..1d9a0db6 100644 --- a/types/meshcore.ts +++ b/types/meshcore.ts @@ -461,7 +461,7 @@ export type ConnectionStatus = /** Progress of the initial connect sync, for the loading UI. */ export interface SyncProgress { - stage: 'device' | 'clock' | 'contacts' | 'channels' | 'messages'; + stage: 'device' | 'clock' | 'contacts' | 'channels'; percent: number; current?: number; total?: number; From c9947db57f80c29093e8e50a268b1b9e59b3b5b3 Mon Sep 17 00:00:00 2001 From: kNoAPP Date: Fri, 25 Sep 2026 22:10:17 -0700 Subject: [PATCH 2/4] fix(sync): stop a room pull at log-out and quiet its replayed posts --- hooks/useMeshCore.ts | 15 +++++--- hooks/useRepeaterAutoLogin.ts | 23 +++++------ lib/session/remoteLogin.ts | 72 +++++++++++++++++++++++++---------- locales/de.json | 2 + locales/en.json | 2 + locales/es.json | 2 + locales/fr.json | 2 + 7 files changed, 81 insertions(+), 37 deletions(-) diff --git a/hooks/useMeshCore.ts b/hooks/useMeshCore.ts index a0fb88ef..dd08d778 100644 --- a/hooks/useMeshCore.ts +++ b/hooks/useMeshCore.ts @@ -86,7 +86,7 @@ import { MAX_CHANNEL_SLOTS, } from '@/lib/meshcore/constants'; import { - isReplayingRoom, + isReplayedRoomPost, loginNode, signInRememberedRooms, } from '@/lib/session/remoteLogin'; @@ -473,9 +473,14 @@ export function useMeshCore() { }; const state = useMeshStore.getState(); const visible = isConvoVisible(state, id); + // A room's history, replayed by the connect-time pull, is old + // news: it raises no arrival cues (the pull ends with a summary + // instead) and is withheld from automation, which is live-only. + const replayed = + isRoom && isReplayedRoomPost(prefix, enriched.timestamp); addMessage(id, enriched); if (state.backlogDraining) backlogDelivered++; - if (state.status === 'connected') { + if (state.status === 'connected' && !replayed) { const room = contact?.name || prefix.slice(0, 8); const convo: ActiveConvo = { kind: isRoom ? 'room' : 'direct', @@ -511,10 +516,8 @@ export function useMeshCore() { notifyArrival(convo, sender, msg.text); } } - // Withheld during the backlog, as on the channel branch above, and - // so is a room's replay while the connect-time sync pulls it: those - // posts were written while we were away. - if (!state.backlogDraining && !(isRoom && isReplayingRoom(prefix))) + // Withheld during the backlog, as on the channel branch above. + if (!state.backlogDraining && !replayed) emit({ type: 'message', msg: enriched }); } }, diff --git a/hooks/useRepeaterAutoLogin.ts b/hooks/useRepeaterAutoLogin.ts index 310e1650..c08c4a0f 100644 --- a/hooks/useRepeaterAutoLogin.ts +++ b/hooks/useRepeaterAutoLogin.ts @@ -186,17 +186,6 @@ export function useRepeaterAutoLogin(contact: Contact): RepeaterAutoLogin { } for (let i = 1; i <= total; i++) { - // Another sign-in may have claimed the node while this cycle sat - // between attempts — the connect-time room sync starts one on any - // room it finds logged out — so wait it out rather than send over it. - // A login that resolved meanwhile, that one or the node answering - // late, is the outcome already. - const settled = await settledLogin(prefix); - if (!live()) return; - if (isAuthedLogin(settled)) { - setFailure(null); - break; - } // Two timeouts have condemned the stored route, so let the last attempt // flood rather than repeat the same lost path. Mirrors // `applyRoutePolicy`'s two-failures-then-reset: one loss is not enough @@ -208,6 +197,18 @@ export function useRepeaterAutoLogin(contact: Contact): RepeaterAutoLogin { await resetPathRef.current(liveContact(), true); if (!live()) return; } + // Another sign-in may have claimed the node while this cycle sat + // between attempts — the connect-time room sync starts one on any + // room it finds logged out — so wait it out rather than send over it. + // A login that resolved meanwhile, that one or the node answering + // late, is the outcome already. Checked last, after every other + // await, so nothing can claim the node between here and the send. + const settled = await settledLogin(prefix); + if (!live()) return; + if (isAuthedLogin(settled)) { + setFailure(null); + break; + } setAttempt(i); const outcome = await repeaterLoginRef.current( contactRef.current, diff --git a/lib/session/remoteLogin.ts b/lib/session/remoteLogin.ts index aeb14d10..6c16d431 100644 --- a/lib/session/remoteLogin.ts +++ b/lib/session/remoteLogin.ts @@ -4,11 +4,7 @@ import type { MeshCoreClient } from '@/lib/meshcore/client'; import { PushTimeoutError } from '@/lib/meshcore/errors'; import { ADV_TYPE_ROOM, NO_PATH } from '@/lib/meshcore/constants'; -import { - loadRepeaterCred, - saveRepeaterCred, - type RememberedCred, -} from '@/lib/meshcore/adminCreds'; +import { loadRepeaterCred, saveRepeaterCred } from '@/lib/meshcore/adminCreds'; import { canTransmit } from '@/lib/session/guards'; import { useMeshStore, isAuthedLogin, roomConvoId } from '@/store/meshStore'; import i18n from '@/lib/i18n'; @@ -102,7 +98,7 @@ export async function loginNode( const prior = useMeshStore.getState().adminSessions[prefix]; const renewing = isAuthedLogin(prior?.login); // Whether a renewal still has the session it set out to renew: a log-out - // deletes it, and a later login replaces its token. + // deletes it, and any session opened after that carries a new token. const renewedAway = () => renewing && useMeshStore.getState().adminSessions[prefix]?.token !== prior?.token; @@ -183,16 +179,24 @@ export async function loginNode( } } -// Rooms whose history the connect-time sync is pulling right now. -const replaying = new Set(); +// Rooms whose history the connect-time sync is pulling right now, each with +// when its pull began, in epoch seconds on our clock. +const replaying = new Map(); /** - * Whether the connect-time sync is pulling this room's history, so a post - * from it is most likely its replay — written while we were away — rather - * than live traffic. + * Whether a room post is replayed history rather than live traffic: the + * connect-time sync is pulling the room, and the post was written before the + * pull began. + * + * @param timestamp - the post's time already converted to our clock, as + * `roomPostTime` gives it. */ -export function isReplayingRoom(prefix: string): boolean { - return replaying.has(prefix); +export function isReplayedRoomPost( + prefix: string, + timestamp: number | undefined, +): boolean { + const since = replaying.get(prefix); + return since !== undefined && timestamp !== undefined && timestamp < since; } // Resolves once the backlog drain is not running, at once when it already @@ -219,7 +223,6 @@ function backlogSettled(): Promise { async function signInRoom( client: MeshCoreClient, prefix: string, - cred: RememberedCred, alive: () => boolean, ): Promise { for (let i = 1; i <= LOGIN_ATTEMPTS; i++) { @@ -231,12 +234,25 @@ async function signInRoom( await client.resetPath(stale).catch(() => undefined); if (!alive()) return false; } + // Re-read before every attempt: a log-out clears the record, and a pull + // that outlived it would sign the user back in and store the password + // they just told us to forget. The two share one I/O queue, so a log-out + // that lands first is seen here; one that lands during the read is caught + // below by the session it tore down. + const before = useMeshStore.getState().adminSessions[prefix]?.login; + const cred = await loadRepeaterCred(prefix); + if (!cred || !alive()) return false; // Read after every await, and claimed by `loginNode` in the same // synchronous step, so the room view's cycle can't be mid-handshake when // this one is sent. A contact deleted meanwhile ends the round. const state = useMeshStore.getState(); const contact = state.contacts[prefix]; - if (!contact || state.adminSessions[prefix]?.login === 'pending') { + const login = state.adminSessions[prefix]?.login; + if ( + !contact || + login === 'pending' || + (isAuthedLogin(before) && !isAuthedLogin(login)) + ) { return false; } const outcome = await loginNode( @@ -305,16 +321,32 @@ export async function signInRememberedRooms( .map((c) => c.pubkeyPrefix); for (const prefix of prefixes) { if (!alive()) return; - const cred = await loadRepeaterCred(prefix); - if (!cred) continue; - replaying.add(prefix); + if (!(await loadRepeaterCred(prefix))) continue; + replaying.set(prefix, Math.floor(Date.now() / 1000)); + let pulled = 0; try { for (let round = 1; round <= ROOM_PULL_ROUNDS; round++) { - if (!(await signInRoom(client, prefix, cred, alive))) break; - if ((await replayQuiet(prefix, alive)) === 0) break; + if (!(await signInRoom(client, prefix, alive))) break; + const posts = await replayQuiet(prefix, alive); + if (posts === 0) break; + pulled += posts; } } finally { replaying.delete(prefix); } + // The replay's own arrivals raise no notice of their own (see + // `isReplayedRoomPost`), so one summary stands in for them, as the + // backlog drain's does. + const room = useMeshStore.getState().contacts[prefix]; + if (pulled > 0 && alive() && room) { + useMeshStore.getState().notify({ + level: 'success', + text: i18n.t('notify.roomCaughtUp', { + count: pulled, + room: room.name || prefix.slice(0, 8), + }), + key: `roomCaughtUp:${prefix}`, + }); + } } } diff --git a/locales/de.json b/locales/de.json index 6bb6af2a..62779027 100644 --- a/locales/de.json +++ b/locales/de.json @@ -118,6 +118,8 @@ "newPostIn": "Neuer Beitrag in {{room}}", "caughtUp_one": "Aufgeholt · {{count}} Nachricht", "caughtUp_other": "Aufgeholt · {{count}} Nachrichten", + "roomCaughtUp_one": "{{room}} aufgeholt · {{count}} Beitrag", + "roomCaughtUp_other": "{{room}} aufgeholt · {{count}} Beiträge", "roomPostNoAccess": "Sie dürfen in diesem Raum nicht mehr veröffentlichen", "connected": "Verbunden — {{device}}", "connectionFailed": "Verbindung fehlgeschlagen", diff --git a/locales/en.json b/locales/en.json index e4b2e60b..4ae7dbba 100644 --- a/locales/en.json +++ b/locales/en.json @@ -117,6 +117,8 @@ "newPostIn": "New post in {{room}}", "caughtUp_one": "Caught up · {{count}} message", "caughtUp_other": "Caught up · {{count}} messages", + "roomCaughtUp_one": "Caught up on {{room}} · {{count}} post", + "roomCaughtUp_other": "Caught up on {{room}} · {{count}} posts", "roomPostNoAccess": "You no longer have permission to post in this room", "connected": "Connected — {{device}}", "connectionFailed": "Connection failed", diff --git a/locales/es.json b/locales/es.json index 14556a6e..3c97cd94 100644 --- a/locales/es.json +++ b/locales/es.json @@ -118,6 +118,8 @@ "newPostIn": "Nueva publicación en {{room}}", "caughtUp_one": "Al día · {{count}} mensaje", "caughtUp_other": "Al día · {{count}} mensajes", + "roomCaughtUp_one": "{{room}} al día · {{count}} publicación", + "roomCaughtUp_other": "{{room}} al día · {{count}} publicaciones", "roomPostNoAccess": "Ya no tienes permiso para publicar en esta sala", "connected": "Conectado — {{device}}", "connectionFailed": "Conexión fallida", diff --git a/locales/fr.json b/locales/fr.json index 01f4a250..a777625b 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -118,6 +118,8 @@ "newPostIn": "Nouvelle publication dans {{room}}", "caughtUp_one": "Rattrapé · {{count}} message", "caughtUp_other": "Rattrapé · {{count}} messages", + "roomCaughtUp_one": "{{room}} rattrapé · {{count}} publication", + "roomCaughtUp_other": "{{room}} rattrapé · {{count}} publications", "roomPostNoAccess": "Vous n’avez plus l’autorisation de publier dans ce salon", "connected": "Connecté — {{device}}", "connectionFailed": "Échec de la connexion", From 6187feb1b048cf28f00e6ddcb7670d178a4b06de Mon Sep 17 00:00:00 2001 From: kNoAPP Date: Fri, 25 Sep 2026 22:13:39 -0700 Subject: [PATCH 3/4] fix(login): settle before a route reset and count only replayed posts --- hooks/useRepeaterAutoLogin.ts | 26 +++++++++++++++++--------- lib/session/remoteLogin.ts | 21 ++++++++++++++++----- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/hooks/useRepeaterAutoLogin.ts b/hooks/useRepeaterAutoLogin.ts index c08c4a0f..1842c5c1 100644 --- a/hooks/useRepeaterAutoLogin.ts +++ b/hooks/useRepeaterAutoLogin.ts @@ -186,25 +186,33 @@ export function useRepeaterAutoLogin(contact: Contact): RepeaterAutoLogin { } for (let i = 1; i <= total; i++) { + // Another sign-in may have claimed the node while this cycle sat + // between attempts — the connect-time room sync starts one on any + // room it finds logged out — so wait it out rather than send over it. + // A login that resolved meanwhile, that one or the node answering + // late, is the outcome already. + let settled = await settledLogin(prefix); + if (!live()) return; // Two timeouts have condemned the stored route, so let the last attempt // flood rather than repeat the same lost path. Mirrors // `applyRoutePolicy`'s two-failures-then-reset: one loss is not enough // to throw away a path every other message to this node also uses. - if (i === total && total > 1 && liveContact().outPathLen !== NO_PATH) { + if ( + !isAuthedLogin(settled) && + i === total && + total > 1 && + liveContact().outPathLen !== NO_PATH + ) { // Quiet, like the attempts around it: the user asked to open a node, // not to reset its route, so this one stays part of the sign-in the // gate is already narrating. await resetPathRef.current(liveContact(), true); if (!live()) return; + // The reset is an await of its own, so settle again: nothing may + // claim the node between this check and the send. + settled = await settledLogin(prefix); + if (!live()) return; } - // Another sign-in may have claimed the node while this cycle sat - // between attempts — the connect-time room sync starts one on any - // room it finds logged out — so wait it out rather than send over it. - // A login that resolved meanwhile, that one or the node answering - // late, is the outcome already. Checked last, after every other - // await, so nothing can claim the node between here and the send. - const settled = await settledLogin(prefix); - if (!live()) return; if (isAuthedLogin(settled)) { setFailure(null); break; diff --git a/lib/session/remoteLogin.ts b/lib/session/remoteLogin.ts index 6c16d431..f0b72c2f 100644 --- a/lib/session/remoteLogin.ts +++ b/lib/session/remoteLogin.ts @@ -228,6 +228,11 @@ async function signInRoom( for (let i = 1; i <= LOGIN_ATTEMPTS; i++) { if (!alive()) return false; const stale = useMeshStore.getState().contacts[prefix]; + // Checked before the reset as well as after it, so a sign-in the room view + // has in flight doesn't lose the route it is using. + if (useMeshStore.getState().adminSessions[prefix]?.login === 'pending') { + return false; + } if (i === LOGIN_ATTEMPTS && stale && stale.outPathLen !== NO_PATH) { // Best-effort: a failed reset leaves the path in place, and the last // attempt still goes out on it. @@ -270,11 +275,17 @@ async function signInRoom( return false; } -const inboundCount = (list: Message[] | undefined): number => - list?.reduce((n, m) => (m.own ? n : n + 1), 0) ?? 0; +// How many of a feed's posts are replayed history: inbound, and written before +// the pull began. A live post already raised its own notice and says nothing +// about whether the replay has stalled. +const replayedCount = (prefix: string, list: Message[] | undefined): number => + list?.reduce( + (n, m) => (!m.own && isReplayedRoomPost(prefix, m.timestamp) ? n + 1 : n), + 0, + ) ?? 0; -// Waits until the room's feed has gone ROOM_REPLAY_QUIET_MS without an inbound -// post, or the session ends, and resolves with how many posts landed. +// Waits until the room's feed has gone ROOM_REPLAY_QUIET_MS without a replayed +// post, or the session ends, and resolves with how many landed. function replayQuiet(prefix: string, alive: () => boolean): Promise { const id = roomConvoId(prefix); let posts = 0; @@ -283,7 +294,7 @@ function replayQuiet(prefix: string, alive: () => boolean): Promise { const list = state.msgHistory[id]; const before = prev.msgHistory[id]; if (list === before) return; - const added = inboundCount(list) - inboundCount(before); + const added = replayedCount(prefix, list) - replayedCount(prefix, before); if (added <= 0) return; posts += added; lastAt = Date.now(); From d3910cf04f57bad68e3a6cf73a0ec83b30affc0b Mon Sep 17 00:00:00 2001 From: kNoAPP Date: Fri, 25 Sep 2026 22:14:57 -0700 Subject: [PATCH 4/4] fix(sync): let a room view sign-in stand in for the pull's own login --- lib/session/remoteLogin.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/lib/session/remoteLogin.ts b/lib/session/remoteLogin.ts index f0b72c2f..9f6c2185 100644 --- a/lib/session/remoteLogin.ts +++ b/lib/session/remoteLogin.ts @@ -225,14 +225,17 @@ async function signInRoom( prefix: string, alive: () => boolean, ): Promise { + const start = useMeshStore.getState().adminSessions[prefix]?.login; for (let i = 1; i <= LOGIN_ATTEMPTS; i++) { if (!alive()) return false; const stale = useMeshStore.getState().contacts[prefix]; + const now = useMeshStore.getState().adminSessions[prefix]?.login; // Checked before the reset as well as after it, so a sign-in the room view - // has in flight doesn't lose the route it is using. - if (useMeshStore.getState().adminSessions[prefix]?.login === 'pending') { - return false; - } + // has in flight doesn't lose the route it is using. One that already won + // during this round's backoff signed us in: its login set the replay off + // just as ours would have. + if (now === 'pending') return false; + if (!isAuthedLogin(start) && isAuthedLogin(now)) return true; if (i === LOGIN_ATTEMPTS && stale && stale.outPathLen !== NO_PATH) { // Best-effort: a failed reset leaves the path in place, and the last // attempt still goes out on it.