From d90dfb2d7a1ff440f86281d7d34d34b3ff99cf96 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 17:57:28 -0600 Subject: [PATCH 1/3] fix(reticulum): stop inclusive since_ts from looping inbound catch-up Make /lxmf/recent exclusive, advance the watermark on live inbound, and demote catch-up warns when every hash is already ingested. --- docs/reticulum-sidecar-ipc.md | 16 ++-- reticulum-sidecar/src/api/lxmf.rs | 2 +- .../src/stack/lxmf_inbound_log.rs | 49 ++++++++-- .../lib/reticulum/catchUpInboundLxmf.test.ts | 93 +++++++++++++++++++ .../catchUpRecentInboundLxmf.test.ts | 80 ++++++++++++++++ .../lib/reticulum/catchUpRecentInboundLxmf.ts | 23 ++++- .../lib/reticulum/fetchRecentInboundLxmf.ts | 2 +- .../reticulumInboundLxmfDiagnostics.test.ts | 8 ++ .../reticulumInboundLxmfDiagnostics.ts | 5 +- ...time.inbound-lxmf-catchup.contract.test.ts | 6 ++ src/renderer/runtime/useReticulumRuntime.ts | 8 ++ 11 files changed, 271 insertions(+), 21 deletions(-) diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 3cc515e3b..980d596ea 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -87,14 +87,14 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ ### LXMF and contacts -| Method | Path | Body / notes | Response | -| ------ | ------------------------------ | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | -| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | -| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200); `ring_len` is current buffer occupancy | -| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | -| GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | -| DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) | +| Method | Path | Body / notes | Response | +| ------ | ------------------------------ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | +| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | +| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional, **exclusive** lower bound), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200); `since_ts` keeps rows with `timestamp > since_ts` so a watermark equal to the newest ingested ts does not re-return that boundary forever; `ring_len` is current buffer occupancy | +| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | +| GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | +| DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) | ### Peers, topology, and propagation diff --git a/reticulum-sidecar/src/api/lxmf.rs b/reticulum-sidecar/src/api/lxmf.rs index aa126f0db..bdab2c4cf 100644 --- a/reticulum-sidecar/src/api/lxmf.rs +++ b/reticulum-sidecar/src/api/lxmf.rs @@ -128,7 +128,7 @@ pub async fn lxmf_delete_message( #[derive(Debug, Deserialize)] pub struct RecentLxmfQuery { - /// Inclusive lower bound on payload `timestamp` (ms). Omit to return the full ring. + /// Exclusive lower bound on payload `timestamp` (ms). Omit to return the full ring. #[serde(default)] pub since_ts: Option, /// Max rows (default 200, capped at 500). diff --git a/reticulum-sidecar/src/stack/lxmf_inbound_log.rs b/reticulum-sidecar/src/stack/lxmf_inbound_log.rs index 87541f2c3..94fd6891a 100644 --- a/reticulum-sidecar/src/stack/lxmf_inbound_log.rs +++ b/reticulum-sidecar/src/stack/lxmf_inbound_log.rs @@ -48,8 +48,12 @@ impl LxmfInboundBuffer { self.inner.lock().map(|buf| buf.len()).unwrap_or(0) } - /// Snapshot newest-first filtered by optional `since_ts` (inclusive, ms), then reverse to - /// chronological order for ingest catch-up. + /// Snapshot newest-first filtered by optional `since_ts` (exclusive lower bound, ms), + /// then reverse to chronological order for ingest catch-up. + /// + /// Exclusive (`ts > since_ts`) so a watermark equal to the newest ingested timestamp + /// does not re-return that boundary row on every periodic catch-up. + /// Same-ms twins at exactly `since_ts` are skipped (accepted tradeoff vs inclusive loop). pub fn snapshot(&self, since_ts: Option, limit: usize) -> Vec { let limit = limit.max(1); let Ok(buf) = self.inner.lock() else { @@ -62,7 +66,7 @@ impl LxmfInboundBuffer { Some(min_ts) => row .get("timestamp") .and_then(serde_json::Value::as_i64) - .is_some_and(|ts| ts >= min_ts), + .is_some_and(|ts| ts > min_ts), }) .cloned() .collect(); @@ -101,14 +105,45 @@ mod tests { } #[test] - fn since_ts_filters_and_limit_keeps_newest() { + fn since_ts_filters_exclusive_and_limit_keeps_newest() { let buf = LxmfInboundBuffer::new(10); buf.push(msg("h1", 100, "a")); buf.push(msg("h2", 200, "b")); buf.push(msg("h3", 300, "c")); let rows = buf.snapshot(Some(200), 2); - assert_eq!(rows.len(), 2); - assert_eq!(rows[0]["message_hash"], "h2"); - assert_eq!(rows[1]["message_hash"], "h3"); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0]["message_hash"], "h3"); + } + + #[test] + fn since_ts_at_boundary_returns_empty() { + let buf = LxmfInboundBuffer::new(10); + buf.push(msg("h2", 200, "b")); + let rows = buf.snapshot(Some(200), 10); + assert!(rows.is_empty()); + } + + #[test] + fn since_ts_none_returns_full_chronological_buffer() { + let buf = LxmfInboundBuffer::new(10); + buf.push(msg("h1", 100, "a")); + buf.push(msg("h2", 200, "b")); + buf.push(msg("h3", 300, "c")); + let rows = buf.snapshot(None, 10); + assert_eq!(rows.len(), 3); + assert_eq!(rows[0]["message_hash"], "h1"); + assert_eq!(rows[1]["message_hash"], "h2"); + assert_eq!(rows[2]["message_hash"], "h3"); + } + + #[test] + fn same_ms_twins_excluded_at_exact_since_ts() { + let buf = LxmfInboundBuffer::new(10); + buf.push(msg("h_a", 200, "a")); + buf.push(msg("h_b", 200, "b")); + let below = buf.snapshot(Some(199), 10); + assert_eq!(below.len(), 2); + let at = buf.snapshot(Some(200), 10); + assert!(at.is_empty()); } } diff --git a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts index 878087620..afe588dc0 100644 --- a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ingestReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; import { OFFLINE_RETICULUM_IDENTITY_ID } from '@/renderer/lib/offlineProtocolIdentities'; +import { catchUpRecentInboundLxmf } from '@/renderer/lib/reticulum/catchUpRecentInboundLxmf'; import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; import { getReticulumInboundLxmfDiagnostics, @@ -201,4 +202,96 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { expect(bucket['live-hash'].payload).toBe('from WS'); expect(bucket['db-hash'].payload).toBe('from DB'); }); + + it('live inbound lxmf_message advances the catch-up watermark', async () => { + const hash = '11'.repeat(32); + const { result, unmount } = renderHook(() => useReticulumRuntime()); + await act(async () => { + await result.current.connect(); + }); + expect(eventHandler).toBeTruthy(); + const onEvent = eventHandler!; + resetReticulumInboundLxmfDiagnosticsForTests(); + + act(() => { + onEvent({ + type: 'lxmf_message', + payload: sampleInbound(hash, 'live inbound', 5_000), + }); + }); + + await waitFor(() => { + expect(useMessageStore.getState().messages[identityId][hash].payload).toBe('live inbound'); + }); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBe(5_000); + unmount(); + }); + + it('outbound lxmf_message does not advance the catch-up watermark', async () => { + const hash = '22'.repeat(32); + const { result, unmount } = renderHook(() => useReticulumRuntime()); + await act(async () => { + await result.current.connect(); + }); + expect(eventHandler).toBeTruthy(); + const onEvent = eventHandler!; + resetReticulumInboundLxmfDiagnosticsForTests(); + + act(() => { + onEvent({ + type: 'lxmf_message', + payload: { + ...sampleInbound(hash, 'outbound echo', 9_000), + direction: 'outbound', + to_hash: 'bb'.repeat(16), + }, + }); + }); + + await waitFor(() => { + expect(useMessageStore.getState().messages[identityId][hash].payload).toBe('outbound echo'); + }); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBeNull(); + unmount(); + }); + + it('periodic catch-up after watermark does not re-warn the same boundary row', async () => { + const hash = '33'.repeat(32); + const atT = sampleInbound(hash, 'boundary', 4_000); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [atT], + ringLen: 1, + }); + + const { result, unmount } = renderHook(() => useReticulumRuntime()); + await act(async () => { + await result.current.connect(); + }); + + await waitFor(() => { + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBe(4_000); + }); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=1 reason=connect')); + warnSpy.mockClear(); + + // Exclusive since_ts=T → empty ring slice (Runr stuck-loop regression). + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ messages: [], ringLen: 1 }); + const sinceTs = getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs ?? undefined; + await expect( + catchUpRecentInboundLxmf({ + identityId, + ingest: () => {}, + sinceTs, + reason: 'periodic', + }), + ).resolves.toBeNull(); + + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledWith({ + limit: 200, + sinceTs: 4_000, + }); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('count=1 reason=periodic')); + expect(getReticulumInboundLxmfDiagnostics().lastInboundCatchUpCount).toBe(1); + unmount(); + }); }); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts index 068851842..8c5255bcd 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; +import { type MessageRecord, useMessageStore } from '@/renderer/stores/messageStore'; import { catchUpRecentInboundLxmf } from './catchUpRecentInboundLxmf'; @@ -19,11 +20,35 @@ function sample(hash: string, timestamp: number): ReticulumLxmfPayload { }; } +function seedKnown(identityId: string, hash: string): void { + const record: MessageRecord = { + id: hash, + from: 1, + to: 0, + payload: 'hi', + channelIndex: 0, + timestamp: 1_000, + reticulumMessageHash: hash, + }; + useMessageStore.setState({ + messages: { + ...useMessageStore.getState().messages, + [identityId]: { + ...(useMessageStore.getState().messages[identityId] ?? {}), + [hash]: record, + }, + }, + }); +} + describe('catchUpRecentInboundLxmf', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); beforeEach(() => { warnSpy.mockClear(); + debugSpy.mockClear(); + useMessageStore.setState({ messages: {} }); vi.mocked(fetchRecentInboundLxmfDetailed).mockReset(); }); @@ -57,5 +82,60 @@ describe('catchUpRecentInboundLxmf', () => { expect(ingest).toHaveBeenCalledTimes(2); expect(outcome).toEqual({ count: 2, watermarkTs: 2_500 }); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=2 reason=periodic')); + expect(debugSpy).not.toHaveBeenCalled(); + }); + + it('returns null on a second pass when the watermark fetch is empty', async () => { + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ messages: [], ringLen: 1 }); + await expect( + catchUpRecentInboundLxmf({ + identityId: 'id-1', + ingest: vi.fn(), + sinceTs: 2_500, + reason: 'periodic', + }), + ).resolves.toBeNull(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('demotes warn to debug when every hash is already in the message store', async () => { + const known = 'aa'.repeat(32); + seedKnown('id-1', known); + const ingest = vi.fn(); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [sample(known, 1_000)], + ringLen: 1, + }); + + const outcome = await catchUpRecentInboundLxmf({ + identityId: 'id-1', + ingest, + reason: 'periodic', + }); + + expect(outcome).toEqual({ count: 1, watermarkTs: 1_000 }); + expect(ingest).toHaveBeenCalledTimes(1); + expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('count=1 reason=periodic')); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('still warns when a mixed batch includes an unknown hash', async () => { + const known = 'aa'.repeat(32); + const unknown = 'bb'.repeat(32); + seedKnown('id-1', known); + const ingest = vi.fn(); + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ + messages: [sample(known, 1_000), sample(unknown, 2_000)], + ringLen: 2, + }); + + await catchUpRecentInboundLxmf({ + identityId: 'id-1', + ingest, + reason: 'periodic', + }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=2 reason=periodic')); + expect(debugSpy).not.toHaveBeenCalledWith(expect.stringContaining('catch-up count=')); }); }); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts index ab5cc4a27..5075185d2 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts @@ -1,5 +1,6 @@ import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest'; import { fetchRecentInboundLxmfDetailed } from '@/renderer/lib/reticulum/fetchRecentInboundLxmf'; +import { useMessageStore } from '@/renderer/stores/messageStore'; export interface CatchUpRecentInboundLxmfOpts { identityId: string; @@ -14,9 +15,21 @@ export interface CatchUpRecentInboundLxmfOutcome { watermarkTs: number | null; } +function rowAlreadyInMessageStore(identityId: string, p: ReticulumLxmfPayload): boolean { + const hash = typeof p.message_hash === 'string' ? p.message_hash.trim() : ''; + if (!hash) return false; + // Identity buckets are sparse at runtime despite Record typing. + const bucket = useMessageStore.getState().messages[identityId] as + Record | undefined; + return Boolean(bucket && Object.hasOwn(bucket, hash)); +} + /** * Fetch recent inbound LXMF, ingest rows, and compute the catch-up watermark. * Caller applies diagnostics (`noteReticulumInboundCatchUp` / watermark advance). + * + * Sidecar `since_ts` is exclusive; returned `watermarkTs` is the max seen timestamp and is + * safe to pass as the next periodic `sinceTs`. */ export async function catchUpRecentInboundLxmf( opts: CatchUpRecentInboundLxmfOpts, @@ -30,9 +43,13 @@ export async function catchUpRecentInboundLxmf( if (rows.length === 0) return null; const reason = opts.reason ?? 'catch-up'; - console.warn( - `[catchUpRecentInboundLxmf] inbound LXMF catch-up count=${rows.length} reason=${reason}`, - ); + const allKnown = rows.every((p) => rowAlreadyInMessageStore(opts.identityId, p)); + const logLine = `[catchUpRecentInboundLxmf] inbound LXMF catch-up count=${rows.length} reason=${reason}`; + if (allKnown) { + console.debug(logLine); + } else { + console.warn(logLine); + } let maxTs = opts.sinceTs ?? 0; for (const p of rows) { diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts index 7d5978e32..b69dddfa5 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts @@ -3,7 +3,7 @@ import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest import { noteReticulumInboundRingLen } from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; export interface FetchRecentInboundLxmfOpts { - /** Inclusive lower bound on payload timestamp (ms). */ + /** Exclusive lower bound on payload timestamp (ms); sidecar returns `timestamp > since_ts`. */ sinceTs?: number; limit?: number; } diff --git a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts index f57eae302..998bfd244 100644 --- a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts +++ b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts @@ -23,9 +23,17 @@ describe('reticulumInboundLxmfDiagnostics', () => { const snap = getReticulumInboundLxmfDiagnostics(); expect(snap.lastEventsLaggedSkipped).toBe(7); expect(snap.lastInboundCatchUpCount).toBe(3); + // Stored watermark is the exclusive lower bound for the next periodic since_ts. expect(snap.inboundCatchUpWatermarkTs).toBe(1_000); expect(snap.lastInboundRingLen).toBe(12); expect(snap.lastEventsLaggedAt).toEqual(expect.any(Number)); expect(snap.lastInboundCatchUpAt).toEqual(expect.any(Number)); }); + + it('only advances the exclusive watermark forward', () => { + advanceReticulumInboundCatchUpWatermark(2_500); + advanceReticulumInboundCatchUpWatermark(2_500); + advanceReticulumInboundCatchUpWatermark(1_000); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBe(2_500); + }); }); diff --git a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts index 374773b37..c123056a6 100644 --- a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts +++ b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts @@ -8,7 +8,10 @@ export interface ReticulumInboundLxmfDiagnosticsSnapshot { lastEventsLaggedSkipped: number | null; lastInboundCatchUpAt: number | null; lastInboundCatchUpCount: number | null; - /** Inclusive watermark (ms) for periodic `since_ts` catch-up. */ + /** + * Exclusive lower-bound watermark (ms) for periodic `since_ts` catch-up. + * Next fetch uses this as `since_ts` so the boundary row is not re-returned. + */ inboundCatchUpWatermarkTs: number | null; lastInboundRingLen: number | null; } diff --git a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts index 6f84bf4e7..c27499795 100644 --- a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts @@ -40,4 +40,10 @@ describe('useReticulumRuntime inbound LXMF catch-up wiring (source contract)', ( expect(SOURCE).toMatch(/void catchUpRecentInboundLxmf\(\{ sinceTs, reason: 'periodic' \}\)/); expect(SOURCE).toMatch(/RETICULUM_INBOUND_LXMF_CATCHUP_MS/); }); + + it('advances the catch-up watermark on live inbound ingest', () => { + const ingestBody = extractUseCallbackBody(SOURCE, 'ingestLxmfPayload'); + expect(ingestBody).toContain('advanceReticulumInboundCatchUpWatermark(p.timestamp)'); + expect(ingestBody).toMatch(/p\.direction !== 'outbound'/); + }); }); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 5c22887e1..55ef84125 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -613,6 +613,14 @@ export function useReticulumRuntime(): ProtocolRuntime { selfLxmfHash: selfLxmfHash ?? undefined, attachmentPath, }); + // Keep periodic catch-up cursor ahead of live traffic so older ring rows do not loop. + if ( + p.direction !== 'outbound' && + typeof p.timestamp === 'number' && + Number.isFinite(p.timestamp) + ) { + advanceReticulumInboundCatchUpWatermark(p.timestamp); + } if ( p.direction !== 'outbound' && p.sender_hash && From 32d82d990f54a26a58d655c6bbe42b8423340938 Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 17:59:52 -0600 Subject: [PATCH 2/3] fix: stop Bonjour/mDNS hints on private IP HTTP/TCP errors #610 widened isMeshtasticLocalAddress to isLocalConnectHost, so MeshCore LAN IP failures showed meshtastic.local Bonjour advice. Gate those hints on mDNS hosts only and suppress setup AbortError on HTTP/TCP like BLE. --- src/renderer/components/ConnectionPanel.tsx | 11 ++-- .../lib/connectionPanelErrorHumanize.test.ts | 52 ++++++++++++++++--- .../lib/connectionPanelErrorHumanize.ts | 13 +++-- src/shared/connectHost.test.ts | 14 ++++- src/shared/connectHost.ts | 16 ++++-- 5 files changed, 87 insertions(+), 19 deletions(-) diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 16085d605..6ad054333 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1275,7 +1275,8 @@ export default function ConnectionPanel({ } else { errorMsg = err instanceof Error ? err.message : t('connectionPanel.error.connectionFailed'); } - setError(errorMsg); + // Empty humanize = MeshCore setup AbortError (supersede/cancel); do not setError(''). + if (errorMsg) setError(errorMsg); setConnecting(false); setConnectionStage(''); } @@ -1760,7 +1761,9 @@ export default function ConnectionPanel({ setShowSerialPicker(false); setConnectionStage('connectionPanel.stagePleaseWait'); onConnect('http', addr).catch((err: unknown) => { - setError(humanizeHttpError(addr, err, t)); + // Empty humanize = MeshCore setup AbortError (supersede/cancel); do not setError(''). + const httpErr = humanizeHttpError(addr, err, t); + if (httpErr) setError(httpErr); setConnecting(false); setConnectionStage(''); }); @@ -1775,7 +1778,9 @@ export default function ConnectionPanel({ setShowSerialPicker(false); setConnectionStage('connectionPanel.stagePleaseWait'); onConnect('tcp', addr).catch((err: unknown) => { - setError(humanizeHttpError(addr, err, t)); + // Empty humanize = MeshCore setup AbortError (supersede/cancel); do not setError(''). + const tcpErr = humanizeHttpError(addr, err, t); + if (tcpErr) setError(tcpErr); setConnecting(false); setConnectionStage(''); }); diff --git a/src/renderer/lib/connectionPanelErrorHumanize.test.ts b/src/renderer/lib/connectionPanelErrorHumanize.test.ts index c4d76b3a0..876ea28c3 100644 --- a/src/renderer/lib/connectionPanelErrorHumanize.test.ts +++ b/src/renderer/lib/connectionPanelErrorHumanize.test.ts @@ -45,10 +45,12 @@ describe('hostFromAddressInput / isMeshtasticLocalAddress', () => { expect(hostFromAddressInput('http://meshtastic.local')).toBe('meshtastic.local'); expect(isMeshtasticLocalAddress('meshtastic.local')).toBe(true); expect(isMeshtasticLocalAddress('node.meshtastic.local')).toBe(true); - expect(isMeshtasticLocalAddress('192.168.1.10')).toBe(true); - expect(isMeshtasticLocalAddress('fd00::1')).toBe(true); - expect(isMeshtasticLocalAddress('fe80::1')).toBe(true); - expect(isMeshtasticLocalAddress('::1')).toBe(true); + expect(isMeshtasticLocalAddress('radio.local')).toBe(true); + // Regression #610: private/ULA/loopback are local hosts but not mDNS — Bonjour copy must not apply. + expect(isMeshtasticLocalAddress('192.168.1.10')).toBe(false); + expect(isMeshtasticLocalAddress('fd00::1')).toBe(false); + expect(isMeshtasticLocalAddress('fe80::1')).toBe(false); + expect(isMeshtasticLocalAddress('::1')).toBe(false); expect(isMeshtasticLocalAddress('8.8.8.8')).toBe(false); expect(isMeshtasticLocalAddress('2001:db8::1')).toBe(false); }); @@ -93,7 +95,9 @@ describe('humanizeHttpError', () => { 'timeoutMdnsWindows', ], ['mdns non-windows timeout', 'linux', 'meshtastic.local', 'timeout', 'timeoutMdnsNonWindows'], - ['local private ip timeout', 'linux', '192.168.1.10', 'aborted', 'timeoutMdnsNonWindows'], + ['private ip timeout linux', 'linux', '192.168.1.10', 'aborted', 'timeoutGeneric'], + ['private ip timeout darwin', 'darwin', '192.168.4.35', 'timeout', 'timeoutGeneric'], + ['private ip timeout win32', 'win32', '192.168.1.10', 'timed out', 'timeoutGeneric'], ['public ip timeout', 'linux', '8.8.8.8', 'aborted', 'timeoutGeneric'], ['unauthorized', 'linux', '192.168.1.10', '401 unauthorized', 'unauthorizedHint'], ['refused', 'linux', '192.168.1.10', 'ECONNREFUSED', 'econnrefusedHint'], @@ -104,16 +108,48 @@ describe('humanizeHttpError', () => { expect(result).toContain(`connectionPanel.humanize.http.${hintKey}`); }); - it('adds local-network suffix on non-timeout errors for LAN addresses', () => { + // Regression #610: do not re-broaden isMeshtasticLocalAddress to isLocalConnectHost. + it.each(['win32', 'darwin', 'linux'] as const)( + 'does not attach Bonjour/mDNS timeout hints to private IPs on %s', + (platform) => { + mockPlatform(platform); + const result = humanizeHttpError('192.168.4.35', new Error('connection timed out'), t); + expect(result).toContain('timeoutGeneric'); + expect(result).not.toContain('timeoutMdnsWindows'); + expect(result).not.toContain('timeoutMdnsNonWindows'); + expect(result).not.toContain('suffixMdnsWindows'); + expect(result).not.toContain('suffixMdnsNonWindows'); + }, + ); + + it('adds mDNS suffix on non-timeout errors for .local hosts only', () => { mockPlatform('win32'); - const result = humanizeHttpError('192.168.1.10', new Error('weird failure'), t); - expect(result).toContain('suffixMdnsWindows'); + const mdnsResult = humanizeHttpError('meshtastic.local', new Error('weird failure'), t); + expect(mdnsResult).toContain('suffixMdnsWindows'); + + mockPlatform('linux'); + const mdnsNonWin = humanizeHttpError('meshtastic.local', new Error('weird failure'), t); + expect(mdnsNonWin).toContain('suffixMdnsNonWindows'); + }); + + it('returns raw message for private-IP non-timeout errors (no Bonjour suffix)', () => { + mockPlatform('win32'); + expect(humanizeHttpError('192.168.1.10', new Error('weird failure'), t)).toBe('weird failure'); }); it('returns raw message for generic public IP errors', () => { mockPlatform('linux'); expect(humanizeHttpError('8.8.8.8', new Error('weird failure'), t)).toBe('weird failure'); }); + + it.each(['win32', 'darwin'] as const)( + 'suppresses MeshCore setup AbortError on private IP (%s)', + (platform) => { + mockPlatform(platform); + const err = new DOMException(MESHCORE_SETUP_ABORT_MESSAGE, 'AbortError'); + expect(humanizeHttpError('192.168.4.35', err, t)).toBe(''); + }, + ); }); describe('humanizeBleError', () => { diff --git a/src/renderer/lib/connectionPanelErrorHumanize.ts b/src/renderer/lib/connectionPanelErrorHumanize.ts index accb1f843..f6069670e 100644 --- a/src/renderer/lib/connectionPanelErrorHumanize.ts +++ b/src/renderer/lib/connectionPanelErrorHumanize.ts @@ -1,7 +1,7 @@ import type { TFunction } from 'i18next'; import { - isLocalConnectHost, + isMdnsConnectHost, parseConnectHostPort, stripConnectHostBrackets, } from '@/shared/connectHost'; @@ -28,9 +28,9 @@ export function hostFromAddressInput(address: string): string { } } +/** True only for *.local / meshtastic.local — not private IPs (do not use isLocalConnectHost; #610). */ export function isMeshtasticLocalAddress(address: string): boolean { - const host = hostFromAddressInput(address); - return isLocalConnectHost(host); + return isMdnsConnectHost(hostFromAddressInput(address)); } type RuntimePlatform = 'linux' | 'darwin' | 'win32' | 'unknown'; @@ -85,7 +85,14 @@ export function humanizeSerialError(err: unknown, t: TFunction): string { } export function humanizeHttpError(address: string, err: unknown, t: TFunction): string { + // Same contract as humanizeBleError: intentional MeshCore setup supersede/cancel is not a + // user-facing HTTP/TCP failure — do not attach Bonjour/LAN network hints. + if (isMeshcoreSetupAbortError(err)) { + return ''; + } const msg = err instanceof Error ? err.message : String(err); + // Bonjour / "try the IP" copy only for real mDNS hosts. Do not switch to isLocalConnectHost — + // #610 did and showed meshtastic.local Bonjour advice for MeshCore TCP private IPs. const isMdns = isMeshtasticLocalAddress(address); const platform = runtimePlatform(); const isWindows = platform === 'win32'; diff --git a/src/shared/connectHost.test.ts b/src/shared/connectHost.test.ts index 41a65b67f..9145f5eda 100644 --- a/src/shared/connectHost.test.ts +++ b/src/shared/connectHost.test.ts @@ -8,6 +8,7 @@ import { isLinkLocalIpv6, isLocalConnectHost, isLoopbackHost, + isMdnsConnectHost, isPrivateNetworkHost, isUniqueLocalIpv6, isValidConnectHost, @@ -77,7 +78,7 @@ describe('local network classification', () => { expect(isLoopbackHost('192.168.1.1')).toBe(false); }); - it('classifies local connect hosts for error hints', () => { + it('classifies local connect hosts for SSRF / RNode locality', () => { expect(isLocalConnectHost('192.168.1.10')).toBe(true); expect(isLocalConnectHost('fd00::1')).toBe(true); expect(isLocalConnectHost('fe80::1')).toBe(true); @@ -88,6 +89,17 @@ describe('local network classification', () => { expect(isLocalConnectHost('8.8.8.8')).toBe(false); expect(isLocalConnectHost('2001:db8::1')).toBe(false); }); + + it('classifies mDNS connect hosts without treating private IPs as mDNS', () => { + expect(isMdnsConnectHost('meshtastic.local')).toBe(true); + expect(isMdnsConnectHost('node.meshtastic.local')).toBe(true); + expect(isMdnsConnectHost('radio.local')).toBe(true); + expect(isMdnsConnectHost('192.168.1.10')).toBe(false); + expect(isMdnsConnectHost('10.0.0.1')).toBe(false); + expect(isMdnsConnectHost('8.8.8.8')).toBe(false); + expect(isMdnsConnectHost('fd00::1')).toBe(false); + expect(isMdnsConnectHost('')).toBe(false); + }); }); describe('formatHostForUrl / formatHostForSocket', () => { diff --git a/src/shared/connectHost.ts b/src/shared/connectHost.ts index cf939ef36..4fafe57bd 100644 --- a/src/shared/connectHost.ts +++ b/src/shared/connectHost.ts @@ -125,8 +125,15 @@ export function isLoopbackHost(host: string): boolean { return octets !== null && octets[0] === 127; } -function isMdnsLocalHostname(host: string): boolean { - const normalized = host.toLowerCase(); +/** + * mDNS / Bonjour hostnames only (*.local, meshtastic.local). + * Do not fold into isLocalConnectHost for UI error copy — #610 did that and attached + * Bonjour/meshtastic.local hints to bare private IPs (e.g. MeshCore TCP 192.168.x.x). + * Keep private/ULA/loopback locality on isLocalConnectHost for SSRF / RNode primary. + */ +export function isMdnsConnectHost(host: string): boolean { + const normalized = stripConnectHostBrackets(host.trim()).toLowerCase(); + if (!normalized) return false; return ( normalized === 'meshtastic.local' || normalized.endsWith('.meshtastic.local') || @@ -135,12 +142,13 @@ function isMdnsLocalHostname(host: string): boolean { } /** - * Local connect targets for error hints: RFC1918, RFC4193 ULA, link-local, loopback, mDNS. + * Local connect targets: RFC1918, RFC4193 ULA, link-local, loopback, mDNS. + * For Connection-panel Bonjour/mDNS hint copy, use isMdnsConnectHost — not this. */ export function isLocalConnectHost(host: string): boolean { const bare = stripConnectHostBrackets(host.trim()).toLowerCase(); if (!bare) return false; - if (isMdnsLocalHostname(bare)) return true; + if (isMdnsConnectHost(bare)) return true; if (isLoopbackHost(bare)) return true; if (isPrivateNetworkHost(bare)) return true; if (isUniqueLocalIpv6(bare)) return true; From 3f995e8afe20d43126def25869000e689f756d8d Mon Sep 17 00:00:00 2001 From: Joey Stanford Date: Mon, 3 Aug 2026 18:13:18 -0600 Subject: [PATCH 3/3] fix(reticulum): recover same-ms LXMF catch-up via ring_seq cursor Pair exclusive since_ts with opaque ring_seq so same-millisecond inbound twins are not dropped, skip re-ingest of known rows, and annotate HTTP/TCP reconnect catch paths for the logging rule. --- docs/reticulum-sidecar-ipc.md | 16 +- reticulum-sidecar/src/api/lxmf.rs | 10 +- reticulum-sidecar/src/stack/live.rs | 4 +- .../src/stack/lxmf_inbound_log.rs | 139 ++++++++++++++---- reticulum-sidecar/src/stack/mod.rs | 3 +- src/renderer/components/ConnectionPanel.tsx | 2 + .../ReticulumDiagnosticEngine.test.ts | 4 + src/renderer/lib/ingest/reticulumIngest.ts | 2 + .../lib/reticulum/catchUpInboundLxmf.test.ts | 44 ++++-- .../catchUpRecentInboundLxmf.test.ts | 29 ++-- .../lib/reticulum/catchUpRecentInboundLxmf.ts | 50 +++++-- .../reticulum/fetchRecentInboundLxmf.test.ts | 4 +- .../lib/reticulum/fetchRecentInboundLxmf.ts | 16 +- .../reticulum/reticulumDiagnosticSnapshot.ts | 1 + .../reticulumInboundLxmfDiagnostics.test.ts | 21 ++- .../reticulumInboundLxmfDiagnostics.ts | 29 +++- ...time.inbound-lxmf-catchup.contract.test.ts | 10 +- src/renderer/runtime/useReticulumRuntime.ts | 20 ++- 18 files changed, 305 insertions(+), 99 deletions(-) diff --git a/docs/reticulum-sidecar-ipc.md b/docs/reticulum-sidecar-ipc.md index 980d596ea..15213652a 100644 --- a/docs/reticulum-sidecar-ipc.md +++ b/docs/reticulum-sidecar-ipc.md @@ -87,14 +87,14 @@ Routing bias between **RF** (LoRa / RNode) and **network** (TCP/UDP/I2P/gateway/ ### LXMF and contacts -| Method | Path | Body / notes | Response | -| ------ | ------------------------------ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | -| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | -| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional, **exclusive** lower bound), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200); `since_ts` keeps rows with `timestamp > since_ts` so a watermark equal to the newest ingested ts does not re-return that boundary forever; `ring_len` is current buffer occupancy | -| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | -| GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | -| DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) | +| Method | Path | Body / notes | Response | +| ------ | ------------------------------ | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| POST | `/api/v1/lxmf/send` | `{ destination_hash, text, reply_to_hash?, reply_to_id?, reply_preview_text? }` | Live: stamps LXMF `FIELD_REPLY_TO` (0x30) / optional `FIELD_REPLY_QUOTE` (0x31) before sign; `{ ok, delivery_method?, delivery_status?, sent_via?, message? }` or `{ ok: false, error: "no_propagation_node" }`. **`delivery_status` on this response is initial enqueue state only** (`queued` or `sending`) — not delivery confirmation. Stub: `{ ok, sent_via?, message? }` | +| POST | `/api/v1/lxmf/reaction` | `{ destination_hash, target_hash, emoji }` | `{ ok, message? }` | +| GET | `/api/v1/lxmf/recent` | `?since_ts=` (ms, optional), `?since_seq=` (opaque `ring_seq`, optional), `?limit=` (default 200, max 500) | `{ messages: [], ring_len }` — ring buffer of recent **inbound** LXMF payloads for WS lag/reconnect catch-up (not durable across sidecar restart; capped at 200). Rows are chronological (oldest→newest) and each accepted row is stamped with monotonic `ring_seq`. Cursor: `since_ts` alone keeps `timestamp > since_ts`; with `since_seq`, keep rows after the complete `(since_ts, since_seq)` cursor (`timestamp > since_ts` **or** same-ms with `ring_seq > since_seq`) so same-ms twins remain recoverable without reprocessing the boundary; `ring_len` is current buffer occupancy | +| DELETE | `/api/v1/lxmf/messages/{hash}` | | `{ ok }` | +| GET | `/api/v1/contacts` | | `{ contacts: [] }` — overlays announce/peer/Nomad labels onto nameless or hash-prefix contact `display_name` values (does not overwrite a real name) and may persist fills | +| DELETE | `/api/v1/contacts` | | `{ ok, cleared }` — clears LXMF contacts after demoting them into the peer cache (keeps Peers; does not delete chat messages) | ### Peers, topology, and propagation diff --git a/reticulum-sidecar/src/api/lxmf.rs b/reticulum-sidecar/src/api/lxmf.rs index bdab2c4cf..308b5f494 100644 --- a/reticulum-sidecar/src/api/lxmf.rs +++ b/reticulum-sidecar/src/api/lxmf.rs @@ -128,9 +128,15 @@ pub async fn lxmf_delete_message( #[derive(Debug, Deserialize)] pub struct RecentLxmfQuery { - /// Exclusive lower bound on payload `timestamp` (ms). Omit to return the full ring. + /// Exclusive lower-bound cursor on payload `timestamp` (ms). Omit with `since_seq` to return + /// the full ring. Rows are chronological (oldest→newest). With `since_seq`, keep rows after + /// the complete `(since_ts, since_seq)` cursor so same-ms twins remain recoverable. #[serde(default)] pub since_ts: Option, + /// Opaque monotonic `ring_seq` stamped by the inbound ring. Pair with `since_ts`; ignored when + /// `since_ts` is omitted. Without `since_seq`, filtering is timestamp-only exclusive. + #[serde(default)] + pub since_seq: Option, /// Max rows (default 200, capped at 500). #[serde(default)] pub limit: Option, @@ -142,7 +148,7 @@ pub async fn list_recent_lxmf( Query(q): Query, ) -> Json { let limit = q.limit.unwrap_or(200).clamp(1, 500); - let messages = stack.list_recent_inbound_lxmf(q.since_ts, limit); + let messages = stack.list_recent_inbound_lxmf(q.since_ts, q.since_seq, limit); let ring_len = stack.inbound_lxmf_ring_len(); Json(serde_json::json!({ "messages": messages, "ring_len": ring_len })) } diff --git a/reticulum-sidecar/src/stack/live.rs b/reticulum-sidecar/src/stack/live.rs index 8b7f7ce63..0c5536d62 100644 --- a/reticulum-sidecar/src/stack/live.rs +++ b/reticulum-sidecar/src/stack/live.rs @@ -346,7 +346,9 @@ impl LiveBridge { Some(&inbound_sender_name), ); // Buffer before WS emit so lag/reconnect can catch up via GET /api/v1/lxmf/recent. - inbound_lxmf_cb.push(payload.clone()); + // Stamp `ring_seq` on accepted rows so live watermark and catch-up share a cursor. + // Deduped / lock-failed pushes return None — still emit the original payload. + let payload = inbound_lxmf_cb.push(payload.clone()).unwrap_or(payload); let message_hash = payload .get("message_hash") .and_then(|v| v.as_str()) diff --git a/reticulum-sidecar/src/stack/lxmf_inbound_log.rs b/reticulum-sidecar/src/stack/lxmf_inbound_log.rs index 94fd6891a..0340a1453 100644 --- a/reticulum-sidecar/src/stack/lxmf_inbound_log.rs +++ b/reticulum-sidecar/src/stack/lxmf_inbound_log.rs @@ -9,6 +9,8 @@ pub const MAX_LXMF_INBOUND_LOG: usize = 200; #[derive(Debug)] pub struct LxmfInboundBuffer { max: usize, + /// Monotonic opaque sequence stamped onto accepted rows as `ring_seq`. + next_seq: Mutex, inner: Mutex>, } @@ -16,14 +18,17 @@ impl LxmfInboundBuffer { pub fn new(max: usize) -> Self { Self { max: max.max(1), + next_seq: Mutex::new(1), inner: Mutex::new(VecDeque::new()), } } /// Push an inbound `lxmf_message` payload. Dedupes by `message_hash` when present. - pub fn push(&self, payload: serde_json::Value) { + /// Stamps a monotonic opaque `ring_seq` on accepted rows for catch-up cursors. + /// Returns the stamped payload when accepted, or `None` when deduped / lock failed. + pub fn push(&self, mut payload: serde_json::Value) -> Option { let Ok(mut buf) = self.inner.lock() else { - return; + return None; }; if let Some(hash) = payload .get("message_hash") @@ -35,39 +40,52 @@ impl LxmfInboundBuffer { .and_then(|v| v.as_str()) .is_some_and(|h| h.eq_ignore_ascii_case(hash)) }) { - return; + return None; } } + let Ok(mut next) = self.next_seq.lock() else { + return None; + }; + let seq = *next; + *next = next.saturating_add(1); + drop(next); + if let Some(obj) = payload.as_object_mut() { + obj.insert("ring_seq".into(), serde_json::json!(seq)); + } if buf.len() >= self.max { buf.pop_front(); } - buf.push_back(payload); + buf.push_back(payload.clone()); + Some(payload) } pub fn len(&self) -> usize { self.inner.lock().map(|buf| buf.len()).unwrap_or(0) } - /// Snapshot newest-first filtered by optional `since_ts` (exclusive lower bound, ms), - /// then reverse to chronological order for ingest catch-up. + /// Snapshot in chronological push order (oldest→newest via `push_back` / `VecDeque::iter`), + /// filtered by an optional exclusive `(since_ts, since_seq)` cursor, then truncated to + /// the newest `limit` rows. /// - /// Exclusive (`ts > since_ts`) so a watermark equal to the newest ingested timestamp - /// does not re-return that boundary row on every periodic catch-up. - /// Same-ms twins at exactly `since_ts` are skipped (accepted tradeoff vs inclusive loop). - pub fn snapshot(&self, since_ts: Option, limit: usize) -> Vec { + /// Cursor semantics: + /// - `since_ts` alone: keep rows with `timestamp > since_ts` (legacy exclusive ms bound). + /// - `since_ts` + `since_seq`: keep rows after that complete cursor — + /// `timestamp > since_ts` **or** (`timestamp == since_ts` **and** `ring_seq > since_seq`). + /// Same-ms twins after the stamped sequence are therefore recoverable without + /// re-returning already-processed boundary rows. + pub fn snapshot( + &self, + since_ts: Option, + since_seq: Option, + limit: usize, + ) -> Vec { let limit = limit.max(1); let Ok(buf) = self.inner.lock() else { return Vec::new(); }; let mut out: Vec = buf .iter() - .filter(|row| match since_ts { - None => true, - Some(min_ts) => row - .get("timestamp") - .and_then(serde_json::Value::as_i64) - .is_some_and(|ts| ts > min_ts), - }) + .filter(|row| after_catch_up_cursor(row, since_ts, since_seq)) .cloned() .collect(); if out.len() > limit { @@ -77,6 +95,41 @@ impl LxmfInboundBuffer { } } +fn row_timestamp(row: &serde_json::Value) -> Option { + row.get("timestamp").and_then(serde_json::Value::as_i64) +} + +fn row_ring_seq(row: &serde_json::Value) -> u64 { + row.get("ring_seq") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0) +} + +fn after_catch_up_cursor( + row: &serde_json::Value, + since_ts: Option, + since_seq: Option, +) -> bool { + let Some(min_ts) = since_ts else { + return true; + }; + let Some(ts) = row_timestamp(row) else { + return false; + }; + if ts > min_ts { + return true; + } + if ts < min_ts { + return false; + } + // Same millisecond as the cursor: require a sequence past `since_seq`. + match since_seq { + Some(min_seq) => row_ring_seq(row) > min_seq, + // Timestamp-only exclusive bound (legacy): drop the entire ms bucket. + None => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -94,11 +147,11 @@ mod tests { #[test] fn ring_evicts_oldest_and_dedupes_hash() { let buf = LxmfInboundBuffer::new(2); - buf.push(msg("h1", 1, "a")); - buf.push(msg("h2", 2, "b")); - buf.push(msg("h1", 1, "a-dup")); - buf.push(msg("h3", 3, "c")); - let rows = buf.snapshot(None, 10); + assert!(buf.push(msg("h1", 1, "a")).is_some()); + assert!(buf.push(msg("h2", 2, "b")).is_some()); + assert!(buf.push(msg("h1", 1, "a-dup")).is_none()); + assert!(buf.push(msg("h3", 3, "c")).is_some()); + let rows = buf.snapshot(None, None, 10); assert_eq!(rows.len(), 2); assert_eq!(rows[0]["message_hash"], "h2"); assert_eq!(rows[1]["message_hash"], "h3"); @@ -110,7 +163,7 @@ mod tests { buf.push(msg("h1", 100, "a")); buf.push(msg("h2", 200, "b")); buf.push(msg("h3", 300, "c")); - let rows = buf.snapshot(Some(200), 2); + let rows = buf.snapshot(Some(200), None, 2); assert_eq!(rows.len(), 1); assert_eq!(rows[0]["message_hash"], "h3"); } @@ -119,7 +172,7 @@ mod tests { fn since_ts_at_boundary_returns_empty() { let buf = LxmfInboundBuffer::new(10); buf.push(msg("h2", 200, "b")); - let rows = buf.snapshot(Some(200), 10); + let rows = buf.snapshot(Some(200), None, 10); assert!(rows.is_empty()); } @@ -129,7 +182,7 @@ mod tests { buf.push(msg("h1", 100, "a")); buf.push(msg("h2", 200, "b")); buf.push(msg("h3", 300, "c")); - let rows = buf.snapshot(None, 10); + let rows = buf.snapshot(None, None, 10); assert_eq!(rows.len(), 3); assert_eq!(rows[0]["message_hash"], "h1"); assert_eq!(rows[1]["message_hash"], "h2"); @@ -137,13 +190,35 @@ mod tests { } #[test] - fn same_ms_twins_excluded_at_exact_since_ts() { + fn same_ms_twins_recoverable_via_ring_seq_cursor() { + let buf = LxmfInboundBuffer::new(10); + let a = buf.push(msg("h_a", 200, "a")).expect("accepted"); + let b = buf.push(msg("h_b", 200, "b")).expect("accepted"); + let seq_a = a["ring_seq"].as_u64().expect("seq a"); + let seq_b = b["ring_seq"].as_u64().expect("seq b"); + assert!(seq_b > seq_a); + + // After processing only the first twin, the second same-ms row must still be returned. + let after_a = buf.snapshot(Some(200), Some(seq_a), 10); + assert_eq!(after_a.len(), 1); + assert_eq!(after_a[0]["message_hash"], "h_b"); + assert_eq!(after_a[0]["ring_seq"], seq_b); + + // Complete cursor at the second twin — no reprocessing. + let after_b = buf.snapshot(Some(200), Some(seq_b), 10); + assert!(after_b.is_empty()); + + // Timestamp-only exclusive bound still drops the whole ms bucket (legacy clients). + let ts_only = buf.snapshot(Some(200), None, 10); + assert!(ts_only.is_empty()); + } + + #[test] + fn push_stamps_monotonic_ring_seq_on_accepted_rows() { let buf = LxmfInboundBuffer::new(10); - buf.push(msg("h_a", 200, "a")); - buf.push(msg("h_b", 200, "b")); - let below = buf.snapshot(Some(199), 10); - assert_eq!(below.len(), 2); - let at = buf.snapshot(Some(200), 10); - assert!(at.is_empty()); + let a = buf.push(msg("h1", 1, "a")).expect("a"); + let b = buf.push(msg("h2", 2, "b")).expect("b"); + assert_eq!(a["ring_seq"], 1); + assert_eq!(b["ring_seq"], 2); } } diff --git a/reticulum-sidecar/src/stack/mod.rs b/reticulum-sidecar/src/stack/mod.rs index 74fe7bc81..e9907eb4a 100644 --- a/reticulum-sidecar/src/stack/mod.rs +++ b/reticulum-sidecar/src/stack/mod.rs @@ -317,9 +317,10 @@ impl StackHandle { pub fn list_recent_inbound_lxmf( &self, since_ts: Option, + since_seq: Option, limit: usize, ) -> Vec { - self.inbound_lxmf.snapshot(since_ts, limit) + self.inbound_lxmf.snapshot(since_ts, since_seq, limit) } pub fn inbound_lxmf_ring_len(&self) -> usize { diff --git a/src/renderer/components/ConnectionPanel.tsx b/src/renderer/components/ConnectionPanel.tsx index 6ad054333..b453db2af 100644 --- a/src/renderer/components/ConnectionPanel.tsx +++ b/src/renderer/components/ConnectionPanel.tsx @@ -1761,6 +1761,7 @@ export default function ConnectionPanel({ setShowSerialPicker(false); setConnectionStage('connectionPanel.stagePleaseWait'); onConnect('http', addr).catch((err: unknown) => { + // catch-no-log-ok reconnect errors surfaced via setError/humanizeHttpError // Empty humanize = MeshCore setup AbortError (supersede/cancel); do not setError(''). const httpErr = humanizeHttpError(addr, err, t); if (httpErr) setError(httpErr); @@ -1778,6 +1779,7 @@ export default function ConnectionPanel({ setShowSerialPicker(false); setConnectionStage('connectionPanel.stagePleaseWait'); onConnect('tcp', addr).catch((err: unknown) => { + // catch-no-log-ok reconnect errors surfaced via setError/humanizeHttpError // Empty humanize = MeshCore setup AbortError (supersede/cancel); do not setError(''). const tcpErr = humanizeHttpError(addr, err, t); if (tcpErr) setError(tcpErr); diff --git a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.test.ts b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.test.ts index cd25fc4e7..69a270ef2 100644 --- a/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.test.ts +++ b/src/renderer/lib/diagnostics/ReticulumDiagnosticEngine.test.ts @@ -347,6 +347,7 @@ describe('ReticulumDiagnosticEngine', () => { lastInboundCatchUpAt: null, lastInboundCatchUpCount: null, inboundCatchUpWatermarkTs: null, + inboundCatchUpWatermarkSeq: null, lastInboundRingLen: null, }, }, @@ -410,6 +411,7 @@ describe('ReticulumDiagnosticEngine', () => { lastInboundCatchUpAt: null, lastInboundCatchUpCount: null, inboundCatchUpWatermarkTs: null, + inboundCatchUpWatermarkSeq: null, lastInboundRingLen: null, }, now, @@ -428,6 +430,7 @@ describe('ReticulumDiagnosticEngine', () => { lastInboundCatchUpAt: null, lastInboundCatchUpCount: null, inboundCatchUpWatermarkTs: null, + inboundCatchUpWatermarkSeq: null, lastInboundRingLen: null, }, now, @@ -799,6 +802,7 @@ describe('ReticulumDiagnosticEngine', () => { lastInboundCatchUpAt: null, lastInboundCatchUpCount: null, inboundCatchUpWatermarkTs: null, + inboundCatchUpWatermarkSeq: null, lastInboundRingLen: null, }, hotPeerInterface: 'RNS Dublin Mainnet', diff --git a/src/renderer/lib/ingest/reticulumIngest.ts b/src/renderer/lib/ingest/reticulumIngest.ts index dd0fb0fb9..b77a91ad7 100644 --- a/src/renderer/lib/ingest/reticulumIngest.ts +++ b/src/renderer/lib/ingest/reticulumIngest.ts @@ -45,6 +45,8 @@ export interface ReticulumLxmfPayload { sender_name?: string; text?: string; timestamp?: number; + /** Opaque monotonic ring sequence from sidecar inbound buffer (catch-up cursor). */ + ring_seq?: number; to_hash?: string; reply_to_hash?: string; reply_preview_text?: string; diff --git a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts index afe588dc0..69dd8f81a 100644 --- a/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpInboundLxmf.test.ts @@ -37,7 +37,7 @@ vi.mock('@/renderer/lib/reticulum/useReticulumPropagationAutoSync', () => ({ useReticulumPropagationAutoSync: () => {}, })); -function sampleInbound(hash: string, text: string, timestamp = 1_000) { +function sampleInbound(hash: string, text: string, timestamp = 1_000, ringSeq?: number) { return { sender_hash: 'e16af7d675a0ae7f3067185800a46678', sender_name: 'Runr02', @@ -46,6 +46,7 @@ function sampleInbound(hash: string, text: string, timestamp = 1_000) { direction: 'inbound' as const, message_hash: hash, received_via: 'tcp', + ...(ringSeq != null ? { ring_seq: ringSeq } : {}), }; } @@ -216,7 +217,7 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { act(() => { onEvent({ type: 'lxmf_message', - payload: sampleInbound(hash, 'live inbound', 5_000), + payload: sampleInbound(hash, 'live inbound', 5_000, 7), }); }); @@ -224,6 +225,7 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { expect(useMessageStore.getState().messages[identityId][hash].payload).toBe('live inbound'); }); expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBe(5_000); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkSeq).toBe(7); unmount(); }); @@ -255,12 +257,14 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { unmount(); }); - it('periodic catch-up after watermark does not re-warn the same boundary row', async () => { - const hash = '33'.repeat(32); - const atT = sampleInbound(hash, 'boundary', 4_000); + it('same-ms catch-up twins are recovered exactly once via ring_seq cursor', async () => { + const hashA = '33'.repeat(32); + const hashB = '44'.repeat(32); + const twinA = sampleInbound(hashA, 'twin-a', 4_000, 1); + const twinB = sampleInbound(hashB, 'twin-b', 4_000, 2); vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ - messages: [atT], - ringLen: 1, + messages: [twinA, twinB], + ringLen: 2, }); const { result, unmount } = renderHook(() => useReticulumRuntime()); @@ -269,19 +273,26 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { }); await waitFor(() => { - expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBe(4_000); + const bucket = useMessageStore.getState().messages[identityId]; + expect(bucket[hashA].payload).toBe('twin-a'); + expect(bucket[hashB].payload).toBe('twin-b'); }); - expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=1 reason=connect')); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBe(4_000); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkSeq).toBe(2); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=2 reason=connect')); warnSpy.mockClear(); - // Exclusive since_ts=T → empty ring slice (Runr stuck-loop regression). - vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ messages: [], ringLen: 1 }); - const sinceTs = getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs ?? undefined; + // Complete (ts, seq) cursor → empty ring slice; both twins already ingested once. + vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ messages: [], ringLen: 2 }); + const diag = getReticulumInboundLxmfDiagnostics(); await expect( catchUpRecentInboundLxmf({ identityId, - ingest: () => {}, - sinceTs, + ingest: () => { + throw new Error('must not re-ingest after complete cursor'); + }, + sinceTs: diag.inboundCatchUpWatermarkTs ?? undefined, + sinceSeq: diag.inboundCatchUpWatermarkSeq ?? undefined, reason: 'periodic', }), ).resolves.toBeNull(); @@ -289,9 +300,10 @@ describe('useReticulumRuntime inbound LXMF catch-up', () => { expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledWith({ limit: 200, sinceTs: 4_000, + sinceSeq: 2, }); - expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('count=1 reason=periodic')); - expect(getReticulumInboundLxmfDiagnostics().lastInboundCatchUpCount).toBe(1); + expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('count=2 reason=periodic')); + expect(getReticulumInboundLxmfDiagnostics().lastInboundCatchUpCount).toBe(2); unmount(); }); }); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts index 8c5255bcd..5342693d1 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.test.ts @@ -10,13 +10,14 @@ vi.mock('@/renderer/lib/reticulum/fetchRecentInboundLxmf', () => ({ fetchRecentInboundLxmfDetailed: vi.fn(), })); -function sample(hash: string, timestamp: number): ReticulumLxmfPayload { +function sample(hash: string, timestamp: number, ringSeq?: number): ReticulumLxmfPayload { return { sender_hash: 'e16af7d675a0ae7f3067185800a46678', text: 'hi', timestamp, direction: 'inbound', message_hash: hash, + ...(ringSeq != null ? { ring_seq: ringSeq } : {}), }; } @@ -67,7 +68,7 @@ describe('catchUpRecentInboundLxmf', () => { it('ingests rows, warns, and returns count plus watermark', async () => { const ingest = vi.fn(); vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ - messages: [sample('aa'.repeat(32), 1_000), sample('bb'.repeat(32), 2_500)], + messages: [sample('aa'.repeat(32), 1_000, 1), sample('bb'.repeat(32), 2_500, 2)], ringLen: 2, }); @@ -75,12 +76,17 @@ describe('catchUpRecentInboundLxmf', () => { identityId: 'id-1', ingest, sinceTs: 500, + sinceSeq: 0, reason: 'periodic', }); - expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledWith({ limit: 200, sinceTs: 500 }); + expect(fetchRecentInboundLxmfDetailed).toHaveBeenCalledWith({ + limit: 200, + sinceTs: 500, + sinceSeq: 0, + }); expect(ingest).toHaveBeenCalledTimes(2); - expect(outcome).toEqual({ count: 2, watermarkTs: 2_500 }); + expect(outcome).toEqual({ count: 2, watermarkTs: 2_500, watermarkSeq: 2 }); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=2 reason=periodic')); expect(debugSpy).not.toHaveBeenCalled(); }); @@ -92,18 +98,19 @@ describe('catchUpRecentInboundLxmf', () => { identityId: 'id-1', ingest: vi.fn(), sinceTs: 2_500, + sinceSeq: 2, reason: 'periodic', }), ).resolves.toBeNull(); expect(warnSpy).not.toHaveBeenCalled(); }); - it('demotes warn to debug when every hash is already in the message store', async () => { + it('demotes warn to debug and skips ingest when every hash is already known', async () => { const known = 'aa'.repeat(32); seedKnown('id-1', known); const ingest = vi.fn(); vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ - messages: [sample(known, 1_000)], + messages: [sample(known, 1_000, 1)], ringLen: 1, }); @@ -113,19 +120,19 @@ describe('catchUpRecentInboundLxmf', () => { reason: 'periodic', }); - expect(outcome).toEqual({ count: 1, watermarkTs: 1_000 }); - expect(ingest).toHaveBeenCalledTimes(1); + expect(outcome).toEqual({ count: 1, watermarkTs: 1_000, watermarkSeq: 1 }); + expect(ingest).not.toHaveBeenCalled(); expect(debugSpy).toHaveBeenCalledWith(expect.stringContaining('count=1 reason=periodic')); expect(warnSpy).not.toHaveBeenCalled(); }); - it('still warns when a mixed batch includes an unknown hash', async () => { + it('warns on a mixed batch and ingests only the unknown row', async () => { const known = 'aa'.repeat(32); const unknown = 'bb'.repeat(32); seedKnown('id-1', known); const ingest = vi.fn(); vi.mocked(fetchRecentInboundLxmfDetailed).mockResolvedValue({ - messages: [sample(known, 1_000), sample(unknown, 2_000)], + messages: [sample(known, 1_000, 1), sample(unknown, 2_000, 2)], ringLen: 2, }); @@ -135,6 +142,8 @@ describe('catchUpRecentInboundLxmf', () => { reason: 'periodic', }); + expect(ingest).toHaveBeenCalledTimes(1); + expect(ingest).toHaveBeenCalledWith(expect.objectContaining({ message_hash: unknown })); expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('count=2 reason=periodic')); expect(debugSpy).not.toHaveBeenCalledWith(expect.stringContaining('catch-up count=')); }); diff --git a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts index 5075185d2..3eaf10d44 100644 --- a/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/catchUpRecentInboundLxmf.ts @@ -6,13 +6,17 @@ export interface CatchUpRecentInboundLxmfOpts { identityId: string; ingest: (payload: ReticulumLxmfPayload) => void; sinceTs?: number; + /** Opaque sidecar `ring_seq` paired with `sinceTs` for same-ms recovery. */ + sinceSeq?: number; reason?: string; } export interface CatchUpRecentInboundLxmfOutcome { count: number; - /** Max payload timestamp among ingested rows; null when none usable for watermark. */ + /** Max payload timestamp among fetched rows; null when none usable for watermark. */ watermarkTs: number | null; + /** `ring_seq` for {@link watermarkTs} (max seq at that timestamp among fetched rows). */ + watermarkSeq: number | null; } function rowAlreadyInMessageStore(identityId: string, p: ReticulumLxmfPayload): boolean { @@ -24,12 +28,30 @@ function rowAlreadyInMessageStore(identityId: string, p: ReticulumLxmfPayload): return Boolean(bucket && Object.hasOwn(bucket, hash)); } +function rowRingSeq(p: ReticulumLxmfPayload): number | null { + return typeof p.ring_seq === 'number' && Number.isFinite(p.ring_seq) + ? Math.floor(p.ring_seq) + : null; +} + +function isCursorAfter( + ts: number, + seq: number | null, + maxTs: number, + maxSeq: number | null, +): boolean { + if (ts > maxTs) return true; + if (ts < maxTs) return false; + if (seq == null) return false; + return maxSeq == null || seq > maxSeq; +} + /** - * Fetch recent inbound LXMF, ingest rows, and compute the catch-up watermark. + * Fetch recent inbound LXMF, ingest unknown rows, and compute the catch-up watermark. * Caller applies diagnostics (`noteReticulumInboundCatchUp` / watermark advance). * - * Sidecar `since_ts` is exclusive; returned `watermarkTs` is the max seen timestamp and is - * safe to pass as the next periodic `sinceTs`. + * Sidecar cursor is exclusive `(since_ts, since_seq)`; returned watermarks are the max + * `(timestamp, ring_seq)` among fetched rows and are safe for the next periodic fetch. */ export async function catchUpRecentInboundLxmf( opts: CatchUpRecentInboundLxmfOpts, @@ -39,11 +61,13 @@ export async function catchUpRecentInboundLxmf( const { messages: rows } = await fetchRecentInboundLxmfDetailed({ limit: 200, ...(opts.sinceTs != null ? { sinceTs: opts.sinceTs } : {}), + ...(opts.sinceSeq != null ? { sinceSeq: opts.sinceSeq } : {}), }); if (rows.length === 0) return null; + const knownFlags = rows.map((p) => rowAlreadyInMessageStore(opts.identityId, p)); + const allKnown = knownFlags.every(Boolean); const reason = opts.reason ?? 'catch-up'; - const allKnown = rows.every((p) => rowAlreadyInMessageStore(opts.identityId, p)); const logLine = `[catchUpRecentInboundLxmf] inbound LXMF catch-up count=${rows.length} reason=${reason}`; if (allKnown) { console.debug(logLine); @@ -52,15 +76,23 @@ export async function catchUpRecentInboundLxmf( } let maxTs = opts.sinceTs ?? 0; - for (const p of rows) { - opts.ingest(p); - if (typeof p.timestamp === 'number' && Number.isFinite(p.timestamp) && p.timestamp > maxTs) { - maxTs = p.timestamp; + let maxSeq: number | null = opts.sinceSeq ?? null; + for (const [i, p] of rows.entries()) { + if (!knownFlags[i]) { + opts.ingest(p); + } + if (typeof p.timestamp === 'number' && Number.isFinite(p.timestamp)) { + const seq = rowRingSeq(p); + if (isCursorAfter(p.timestamp, seq, maxTs, maxSeq)) { + maxTs = p.timestamp; + maxSeq = seq; + } } } return { count: rows.length, watermarkTs: maxTs > 0 ? maxTs : null, + watermarkSeq: maxTs > 0 ? maxSeq : null, }; } diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts index e0cda35a4..2d3fe0fdb 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.test.ts @@ -45,8 +45,8 @@ describe('fetchRecentInboundLxmf', () => { ring_len: 3, }); - const rows = await fetchRecentInboundLxmf({ sinceTs: 500, limit: 50 }); - expect(proxyGet).toHaveBeenCalledWith('/api/v1/lxmf/recent?since_ts=500&limit=50'); + const rows = await fetchRecentInboundLxmf({ sinceTs: 500, sinceSeq: 3, limit: 50 }); + expect(proxyGet).toHaveBeenCalledWith('/api/v1/lxmf/recent?since_ts=500&since_seq=3&limit=50'); expect(rows).toHaveLength(1); expect(rows[0]?.text).toBe('hello'); expect(getReticulumInboundLxmfDiagnostics().lastInboundRingLen).toBe(3); diff --git a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts index b69dddfa5..96b8cd005 100644 --- a/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts +++ b/src/renderer/lib/reticulum/fetchRecentInboundLxmf.ts @@ -3,8 +3,14 @@ import type { ReticulumLxmfPayload } from '@/renderer/lib/ingest/reticulumIngest import { noteReticulumInboundRingLen } from '@/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics'; export interface FetchRecentInboundLxmfOpts { - /** Exclusive lower bound on payload timestamp (ms); sidecar returns `timestamp > since_ts`. */ + /** + * Exclusive lower-bound cursor on payload timestamp (ms). + * Alone: sidecar returns `timestamp > since_ts`. + * With {@link sinceSeq}: rows after the complete `(since_ts, since_seq)` cursor. + */ sinceTs?: number; + /** Opaque sidecar `ring_seq` paired with {@link sinceTs} for same-ms recovery. */ + sinceSeq?: number; limit?: number; } @@ -32,6 +38,14 @@ export async function fetchRecentInboundLxmfDetailed( if (opts.sinceTs != null && Number.isFinite(opts.sinceTs)) { params.set('since_ts', String(Math.floor(opts.sinceTs))); } + if ( + opts.sinceTs != null && + Number.isFinite(opts.sinceTs) && + opts.sinceSeq != null && + Number.isFinite(opts.sinceSeq) + ) { + params.set('since_seq', String(Math.floor(opts.sinceSeq))); + } if (opts.limit != null && Number.isFinite(opts.limit)) { params.set('limit', String(Math.max(1, Math.min(500, Math.floor(opts.limit))))); } diff --git a/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts b/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts index f96bb9f7c..477f224fa 100644 --- a/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts +++ b/src/renderer/lib/reticulum/reticulumDiagnosticSnapshot.ts @@ -57,6 +57,7 @@ export interface ReticulumDiagnosticSidecarSnapshot { lastInboundCatchUpAt: number | null; lastInboundCatchUpCount: number | null; inboundCatchUpWatermarkTs: number | null; + inboundCatchUpWatermarkSeq: number | null; lastInboundRingLen: number | null; }; } diff --git a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts index 998bfd244..3d4772b3d 100644 --- a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts +++ b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.test.ts @@ -17,23 +17,32 @@ describe('reticulumInboundLxmfDiagnostics', () => { it('records lag, catch-up, watermark, and ring len', () => { noteReticulumEventsLagged(7); noteReticulumInboundCatchUp(3); - advanceReticulumInboundCatchUpWatermark(1_000); - advanceReticulumInboundCatchUpWatermark(500); + advanceReticulumInboundCatchUpWatermark(1_000, 4); + advanceReticulumInboundCatchUpWatermark(500, 9); noteReticulumInboundRingLen(12); const snap = getReticulumInboundLxmfDiagnostics(); expect(snap.lastEventsLaggedSkipped).toBe(7); expect(snap.lastInboundCatchUpCount).toBe(3); - // Stored watermark is the exclusive lower bound for the next periodic since_ts. + // Stored watermark is the exclusive lower bound for the next periodic since_ts/since_seq. expect(snap.inboundCatchUpWatermarkTs).toBe(1_000); + expect(snap.inboundCatchUpWatermarkSeq).toBe(4); expect(snap.lastInboundRingLen).toBe(12); expect(snap.lastEventsLaggedAt).toEqual(expect.any(Number)); expect(snap.lastInboundCatchUpAt).toEqual(expect.any(Number)); }); it('only advances the exclusive watermark forward', () => { - advanceReticulumInboundCatchUpWatermark(2_500); - advanceReticulumInboundCatchUpWatermark(2_500); - advanceReticulumInboundCatchUpWatermark(1_000); + advanceReticulumInboundCatchUpWatermark(2_500, 1); + advanceReticulumInboundCatchUpWatermark(2_500, 1); + advanceReticulumInboundCatchUpWatermark(1_000, 99); expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBe(2_500); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkSeq).toBe(1); + }); + + it('advances same-ms ring_seq without moving timestamp backward', () => { + advanceReticulumInboundCatchUpWatermark(2_500, 1); + advanceReticulumInboundCatchUpWatermark(2_500, 3); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs).toBe(2_500); + expect(getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkSeq).toBe(3); }); }); diff --git a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts index c123056a6..50db091c7 100644 --- a/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts +++ b/src/renderer/lib/reticulum/reticulumInboundLxmfDiagnostics.ts @@ -10,9 +10,14 @@ export interface ReticulumInboundLxmfDiagnosticsSnapshot { lastInboundCatchUpCount: number | null; /** * Exclusive lower-bound watermark (ms) for periodic `since_ts` catch-up. - * Next fetch uses this as `since_ts` so the boundary row is not re-returned. + * Pair with {@link inboundCatchUpWatermarkSeq} for the complete cursor. */ inboundCatchUpWatermarkTs: number | null; + /** + * Opaque sidecar `ring_seq` paired with {@link inboundCatchUpWatermarkTs}. + * Next fetch uses both so same-ms twins after the stamped sequence are still returned. + */ + inboundCatchUpWatermarkSeq: number | null; lastInboundRingLen: number | null; } @@ -22,6 +27,7 @@ const state: ReticulumInboundLxmfDiagnosticsSnapshot = { lastInboundCatchUpAt: null, lastInboundCatchUpCount: null, inboundCatchUpWatermarkTs: null, + inboundCatchUpWatermarkSeq: null, lastInboundRingLen: null, }; @@ -40,11 +46,27 @@ export function noteReticulumInboundCatchUp(count: number): void { state.lastInboundCatchUpCount = count; } -export function advanceReticulumInboundCatchUpWatermark(timestampMs: number): void { +/** + * Advance the exclusive `(timestamp, ring_seq)` catch-up cursor. + * Only moves forward; a higher timestamp resets the sequence half of the cursor. + */ +export function advanceReticulumInboundCatchUpWatermark( + timestampMs: number, + ringSeq?: number | null, +): void { if (!Number.isFinite(timestampMs)) return; const ts = Math.floor(timestampMs); - if (state.inboundCatchUpWatermarkTs == null || ts > state.inboundCatchUpWatermarkTs) { + const seq = typeof ringSeq === 'number' && Number.isFinite(ringSeq) ? Math.floor(ringSeq) : null; + + const curTs = state.inboundCatchUpWatermarkTs; + const curSeq = state.inboundCatchUpWatermarkSeq; + if (curTs == null || ts > curTs) { state.inboundCatchUpWatermarkTs = ts; + state.inboundCatchUpWatermarkSeq = seq; + return; + } + if (ts === curTs && seq != null && (curSeq == null || seq > curSeq)) { + state.inboundCatchUpWatermarkSeq = seq; } } @@ -61,5 +83,6 @@ export function resetReticulumInboundLxmfDiagnosticsForTests(): void { state.lastInboundCatchUpAt = null; state.lastInboundCatchUpCount = null; state.inboundCatchUpWatermarkTs = null; + state.inboundCatchUpWatermarkSeq = null; state.lastInboundRingLen = null; } diff --git a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts index c27499795..d408b3a06 100644 --- a/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts +++ b/src/renderer/runtime/useReticulumRuntime.inbound-lxmf-catchup.contract.test.ts @@ -14,7 +14,7 @@ describe('useReticulumRuntime inbound LXMF catch-up wiring (source contract)', ( /import \{ catchUpRecentInboundLxmf as runInboundLxmfCatchUp \} from '@\/renderer\/lib\/reticulum\/catchUpRecentInboundLxmf'/, ); expect(SOURCE).toMatch( - /const catchUpRecentInboundLxmf = useCallback\(\s*async \(opts\?: \{ sinceTs\?: number; reason\?: string \}\) => \{/, + /const catchUpRecentInboundLxmf = useCallback\(\s*async \(opts\?: \{ sinceTs\?: number; sinceSeq\?: number; reason\?: string \}\) => \{/, ); expect(SOURCE).toContain('await runInboundLxmfCatchUp({'); }); @@ -37,13 +37,17 @@ describe('useReticulumRuntime inbound LXMF catch-up wiring (source contract)', ( }); it('schedules periodic catch-up while the stack is active', () => { - expect(SOURCE).toMatch(/void catchUpRecentInboundLxmf\(\{ sinceTs, reason: 'periodic' \}\)/); + expect(SOURCE).toMatch(/void catchUpRecentInboundLxmf\(\{/); + expect(SOURCE).toContain("reason: 'periodic'"); + expect(SOURCE).toContain('inboundCatchUpWatermarkSeq'); expect(SOURCE).toMatch(/RETICULUM_INBOUND_LXMF_CATCHUP_MS/); }); it('advances the catch-up watermark on live inbound ingest', () => { const ingestBody = extractUseCallbackBody(SOURCE, 'ingestLxmfPayload'); - expect(ingestBody).toContain('advanceReticulumInboundCatchUpWatermark(p.timestamp)'); + expect(ingestBody).toContain('advanceReticulumInboundCatchUpWatermark('); + expect(ingestBody).toContain('p.timestamp'); + expect(ingestBody).toContain('p.ring_seq'); expect(ingestBody).toMatch(/p\.direction !== 'outbound'/); }); }); diff --git a/src/renderer/runtime/useReticulumRuntime.ts b/src/renderer/runtime/useReticulumRuntime.ts index 55ef84125..8b94e0b0c 100644 --- a/src/renderer/runtime/useReticulumRuntime.ts +++ b/src/renderer/runtime/useReticulumRuntime.ts @@ -619,7 +619,10 @@ export function useReticulumRuntime(): ProtocolRuntime { typeof p.timestamp === 'number' && Number.isFinite(p.timestamp) ) { - advanceReticulumInboundCatchUpWatermark(p.timestamp); + advanceReticulumInboundCatchUpWatermark( + p.timestamp, + typeof p.ring_seq === 'number' ? p.ring_seq : null, + ); } if ( p.direction !== 'outbound' && @@ -681,18 +684,19 @@ export function useReticulumRuntime(): ProtocolRuntime { ); const catchUpRecentInboundLxmf = useCallback( - async (opts?: { sinceTs?: number; reason?: string }) => { + async (opts?: { sinceTs?: number; sinceSeq?: number; reason?: string }) => { if (!identityId) return; const outcome = await runInboundLxmfCatchUp({ identityId, ingest: ingestLxmfPayload, ...(opts?.sinceTs != null ? { sinceTs: opts.sinceTs } : {}), + ...(opts?.sinceSeq != null ? { sinceSeq: opts.sinceSeq } : {}), ...(opts?.reason != null ? { reason: opts.reason } : {}), }); if (!outcome) return; noteReticulumInboundCatchUp(outcome.count); if (outcome.watermarkTs != null) { - advanceReticulumInboundCatchUpWatermark(outcome.watermarkTs); + advanceReticulumInboundCatchUpWatermark(outcome.watermarkTs, outcome.watermarkSeq); } }, [identityId, ingestLxmfPayload], @@ -1647,8 +1651,14 @@ export function useReticulumRuntime(): ProtocolRuntime { let timeoutId: ReturnType | null = null; const scheduleNext = () => { timeoutId = setTimeout(() => { - const sinceTs = getReticulumInboundLxmfDiagnostics().inboundCatchUpWatermarkTs ?? undefined; - void catchUpRecentInboundLxmf({ sinceTs, reason: 'periodic' }).catch((e: unknown) => { + const diag = getReticulumInboundLxmfDiagnostics(); + const sinceTs = diag.inboundCatchUpWatermarkTs ?? undefined; + const sinceSeq = diag.inboundCatchUpWatermarkSeq ?? undefined; + void catchUpRecentInboundLxmf({ + ...(sinceTs != null ? { sinceTs } : {}), + ...(sinceSeq != null ? { sinceSeq } : {}), + reason: 'periodic', + }).catch((e: unknown) => { console.warn( '[useReticulumRuntime] periodic inbound LXMF catch-up failed ' + errLikeToLogString(e), );