fix(observer): eager archive hydration on panel open + 200-frame pages (#2574)

Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
This commit is contained in:
Will Pfleger
2026-07-23 15:40:46 -04:00
committed by GitHub
co-authored by npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent df0a086177
commit 8cb05028be
4 changed files with 1293 additions and 22 deletions
@@ -354,7 +354,7 @@ describe("load-older cursor advance logic", () => {
it("test_short_page_signals_archive_exhausted", () => {
// A page with fewer events than the limit signals end-of-archive.
const PAGE_SIZE = 50;
const PAGE_SIZE = 200;
const page = Array.from({ length: 30 }, (_, i) => ({
created_at: 1000 - i,
}));
@@ -367,8 +367,8 @@ describe("load-older cursor advance logic", () => {
});
it("test_full_page_signals_more_archive_available", () => {
const PAGE_SIZE = 50;
const page = Array.from({ length: 50 }, (_, i) => ({
const PAGE_SIZE = 200;
const page = Array.from({ length: 200 }, (_, i) => ({
created_at: 1000 - i,
}));
const exhausted = page.length < PAGE_SIZE;
@@ -394,6 +394,7 @@ describe("load-older cursor advance logic", () => {
import {
createArchivePagingState,
applyChannelReset,
runHydrationLoop,
} from "@/features/agents/ui/archivePagingState.ts";
describe("archive paging state reset on channel change", () => {
@@ -410,6 +411,12 @@ describe("archive paging state reset on channel change", () => {
"backfillPromise is eagerly initialized",
);
assert.equal(ps.cursor, null, "cursor starts null");
assert.equal(
ps.initialHydrationDone,
false,
"initialHydrationDone starts false",
);
assert.equal(ps.activeChannelId, null, "activeChannelId starts null");
});
it("test_channel_switch_resets_cursor_exhaustion_and_fetch_lock", () => {
@@ -419,11 +426,13 @@ describe("archive paging state reset on channel change", () => {
ps.cursor = { createdAt: 1000, id: "event-a5" };
ps.hasOlderArchived = false; // channel A exhausted
ps.isFetching = true; // mid-flight request (edge case)
ps.initialHydrationDone = true; // hydration ran for channel A
ps.activeChannelId = "chan-a";
ps.backfillStatus = "done"; // backfill ran once already
const originalPromise = ps.backfillPromise; // must survive reset
// Channel switch — this is what the useEffect([channelId]) calls.
applyChannelReset(ps);
applyChannelReset(ps, "chan-b");
assert.equal(ps.cursor, null, "cursor resets to null on channel switch");
assert.equal(
@@ -436,6 +445,16 @@ describe("archive paging state reset on channel change", () => {
false,
"isFetching resets to false on channel switch",
);
assert.equal(
ps.initialHydrationDone,
false,
"initialHydrationDone resets to false on channel switch so the new channel hydrates",
);
assert.equal(
ps.activeChannelId,
"chan-b",
"activeChannelId updates to new channel on switch",
);
// Backfill state must NOT be touched — it is identity-level and should
// survive channel switches so the backfill only runs once per identity mount.
@@ -459,10 +478,11 @@ describe("archive paging state reset on channel change", () => {
it("test_multiple_channel_switches_each_start_fresh", () => {
const ps = createArchivePagingState();
// Switch to channel A: exhaust it.
// Switch to channel A: exhaust it and complete hydration.
ps.cursor = { createdAt: 500, id: "a-oldest" };
ps.hasOlderArchived = false;
applyChannelReset(ps);
ps.initialHydrationDone = true;
applyChannelReset(ps, "chan-b");
assert.equal(ps.cursor, null, "switch A→B: cursor reset");
assert.equal(
@@ -470,11 +490,22 @@ describe("archive paging state reset on channel change", () => {
true,
"switch A→B: hasOlderArchived reset",
);
assert.equal(
ps.initialHydrationDone,
false,
"switch A→B: initialHydrationDone reset",
);
assert.equal(
ps.activeChannelId,
"chan-b",
"switch A→B: activeChannelId updated",
);
// Simulate channel B also being paged.
// Simulate channel B also being paged and hydrated.
ps.cursor = { createdAt: 200, id: "b-oldest" };
ps.hasOlderArchived = false;
applyChannelReset(ps);
ps.initialHydrationDone = true;
applyChannelReset(ps, "chan-c");
assert.equal(ps.cursor, null, "switch B→C: cursor reset again");
assert.equal(
@@ -482,6 +513,164 @@ describe("archive paging state reset on channel change", () => {
true,
"switch B→C: hasOlderArchived reset again",
);
assert.equal(
ps.initialHydrationDone,
false,
"switch B→C: initialHydrationDone reset again",
);
assert.equal(
ps.activeChannelId,
"chan-c",
"switch B→C: activeChannelId updated",
);
});
});
// ── Eager initial hydration loop logic ───────────────────────────────────────
//
// The initial hydration loop in useLoadArchivedObserverEvents calls
// fetchOlderArchived up to INITIAL_HYDRATION_BUDGET_PAGES times. The loop must:
// - Stop at budget (10 pages) even if more archive exists.
// - Stop early when the archive is exhausted (ps.hasOlderArchived → false).
// - Respect channel-switch cancellation (signal.cancelled).
//
// These tests call the PRODUCTION runHydrationLoop from archivePagingState.ts
// with mock fetchOnePage functions — so they fail if the production loop logic
// is deleted or misrouted, not just if a reimplemented copy breaks.
describe("eager initial hydration loop control flow (production runHydrationLoop)", () => {
const BUDGET = 10; // mirrors INITIAL_HYDRATION_BUDGET_PAGES
it("test_hydration_stops_at_budget_when_archive_never_exhausted", async () => {
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-1");
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
// archive remains non-empty — ps.hasOlderArchived stays true
};
const signal = { cancelled: false };
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
BUDGET,
`production runHydrationLoop must stop after exactly ${BUDGET} pages (budget limit)`,
);
});
it("test_hydration_stops_early_when_archive_exhausted", async () => {
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-1");
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
if (fetchCount >= 3) {
ps.hasOlderArchived = false; // mock: archive exhausted on page 3
}
};
const signal = { cancelled: false };
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
3,
"production runHydrationLoop must stop as soon as ps.hasOlderArchived is false (before budget)",
);
});
it("test_hydration_respects_cancellation_on_channel_switch", async () => {
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-1");
const signal = { cancelled: false };
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
if (fetchCount >= 2) {
signal.cancelled = true; // mock: channel switched mid-loop
}
};
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
2,
"production runHydrationLoop must stop when signal.cancelled is true (channel switch)",
);
});
it("test_hydration_zero_iterations_when_already_exhausted", async () => {
// If ps.hasOlderArchived is already false before the loop starts (e.g.
// channel A was exhausted and reset did not run yet), the loop must not
// call fetchOnePage at all.
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-1");
ps.hasOlderArchived = false; // already exhausted
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
};
const signal = { cancelled: false };
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
0,
"must not fetch when archive is already exhausted",
);
});
// Regression: stale React closure — the original fetchOlderArchived captured
// hasOlderArchived from React state, which was false (exhausted) for channel A
// while ps.hasOlderArchived had already been reset to true by applyChannelReset.
// The production loop uses ps.hasOlderArchived (the ref) to guard iterations,
// so the switch-then-hydrate path must call fetchOnePage for the new channel.
it("test_exhausted_channel_A_then_switch_to_B_hydrates_B", async () => {
const ps = createArchivePagingState();
// Simulate exhausting channel A.
applyChannelReset(ps, "chan-a");
ps.hasOlderArchived = false; // channel A exhausted
// Switch to channel B — resets hasOlderArchived and activeChannelId.
applyChannelReset(ps, "chan-b");
// ps.hasOlderArchived is now true; the loop should call fetchOnePage.
let fetchCount = 0;
const fetchOnePage = async () => {
fetchCount++;
ps.hasOlderArchived = false; // B also exhausted after 1 page
};
const signal = { cancelled: false };
await runHydrationLoop(ps, fetchOnePage, BUDGET, signal);
assert.equal(
fetchCount,
1,
"after switch from exhausted channel A to B, runHydrationLoop must call fetchOnePage for B (not skip due to stale exhaustion)",
);
});
// Regression: stale cursor write — an in-flight read from channel A should
// not write A's cursor into ps.cursor after the switch to B. The activeChannelId
// token (set by applyChannelReset) is what lets fetchOlderArchived detect and
// discard the stale result. This test verifies that applyChannelReset correctly
// advances the token so a pre-switch requestChannelId !== ps.activeChannelId.
it("test_activeChannelId_token_detects_stale_read_from_prior_channel", () => {
const ps = createArchivePagingState();
applyChannelReset(ps, "chan-a");
const requestChannelId = ps.activeChannelId; // captured at request start = "chan-a"
// Simulate channel switch before the Tauri read resolves.
applyChannelReset(ps, "chan-b");
// The in-flight A read checks requestChannelId !== ps.activeChannelId.
assert.notEqual(
requestChannelId,
ps.activeChannelId,
"requestChannelId from channel A must not match activeChannelId after switching to B — stale read must be discarded",
);
assert.equal(
ps.activeChannelId,
"chan-b",
"activeChannelId must reflect the current channel after switch",
);
});
});
@@ -28,6 +28,20 @@ export interface ArchivePagingState {
* Mirrors SQL ORDER BY created_at DESC, id DESC so same-second siblings are
* never skipped at a page boundary. */
cursor: { createdAt: number; id: string } | null;
/** True once the initial eager-hydration pass for the current channel has
* completed (budget reached or archive exhausted). Resets on channel change
* so switching channels triggers a fresh hydration pass. */
initialHydrationDone: boolean;
/** The channelId that this paging state is currently scoped to.
* Kept for diagnostics only; NOT used as a generation token (see
* resetGeneration). Channel equality is not a unique request identifier:
* ABA makes old-A channel checks pass again. */
activeChannelId: string | null;
/** Monotonically increasing counter incremented by applyChannelReset.
* Each fetch snapshots this value at request start and checks it again
* after every async boundary a mismatch means a channel switch occurred
* mid-flight (even ABA), and results are discarded. */
resetGeneration: number;
}
/**
@@ -43,6 +57,9 @@ export function createArchivePagingState(): ArchivePagingState {
backfillPromise: null,
backfillResolve: null,
cursor: null,
initialHydrationDone: false,
activeChannelId: null,
resetGeneration: 0,
};
state.backfillPromise = new Promise<void>((resolve) => {
state.backfillResolve = resolve;
@@ -53,15 +70,57 @@ export function createArchivePagingState(): ArchivePagingState {
/**
* Reset per-channel paging state when the viewed channel changes.
*
* Only cursor, exhaustion flag, and fetch lock are channel-scoped. Backfill
* state is identity-level (the index covers ALL channels and needs to run only
* once per identity mount), so it is intentionally NOT touched here.
* Only cursor, exhaustion flag, fetch lock, channel label, and generation token
* are channel-scoped. Backfill state is identity-level (the index covers ALL
* channels and needs to run only once per identity mount), so it is
* intentionally NOT touched here.
*
* `resetGeneration` is incremented on every call. In-flight fetches snapshot
* the generation at start and recheck it after every async boundary a
* mismatch (including ABA) means the request is stale, so results are
* discarded. Channel ID is retained for diagnostics only; it does NOT serve
* as the generation token.
*
* Called by the useEffect([channelId]) in useLoadArchivedObserverEvents.
* Exported so tests can verify the reset semantics without a React runtime.
*/
export function applyChannelReset(state: ArchivePagingState): void {
export function applyChannelReset(
state: ArchivePagingState,
newChannelId: string | null,
): void {
state.cursor = null;
state.isFetching = false;
state.hasOlderArchived = true;
state.initialHydrationDone = false;
state.activeChannelId = newChannelId;
state.resetGeneration += 1;
}
/**
* Run the eager initial-hydration paging loop.
*
* Calls `fetchOnePage()` up to `budget` times. Stops early when:
* - `ps.hasOlderArchived` is false (archive exhausted for this channel), OR
* - `signal.cancelled` is true (channel switched away mid-loop).
*
* `fetchOnePage` is the per-page read unit: it must respect `ps.isFetching`
* (lock), await backfill, perform the Tauri read, ingest results, advance
* the cursor, and set `ps.hasOlderArchived = false` when the page is short.
* The hook wires the real implementation; tests supply a mock.
*
* Exported so tests can call the production loop logic directly passing a
* mock `fetchOnePage` without reimplementing the control flow.
*/
export async function runHydrationLoop(
ps: ArchivePagingState,
fetchOnePage: () => Promise<void>,
budget: number,
signal: { cancelled: boolean },
): Promise<void> {
for (let page = 0; page < budget; page++) {
if (signal.cancelled || !ps.hasOlderArchived) {
break;
}
await fetchOnePage();
}
}
@@ -0,0 +1,936 @@
/**
* Mounted-hook lifecycle and race regression tests for
* useLoadArchivedObserverEvents.
*
* These tests mount the REAL production hook (including its useEffect wiring,
* fetchOlderArchived closure, runHydrationLoop call, and resetGeneration token
* checks) against a mocked Tauri IPC bridge and a real QueryClientProvider.
* They fail if any of the following is removed from the production hook:
* - the hydration effect
* - the resetGeneration checks (post-backfill, post-Tauri-read, post-ingest)
* - the generation-aware isFetching clear in finally
* - the post-backfill isFetching recheck before lock acquisition
*
* Four regressions:
* (a) exhausted-A switch-to-B: B must read from a null cursor and ingest
* its rows. GREEN at dfb2d0385 (stale-closure was fixed in round 1).
* Fails at the pre-round-1 head where ps.hasOlderArchived was read from
* React state, not the ref.
* (b) deferred-I/O race (AB): A is in flight (decrypt deferred), switch to B,
* resolve A's 1-row short ingest — A must NOT mark B exhausted or steal B's
* fetch lock, and B's eager loop must continue past page 1. Fails at
* dfb2d0385 (post-ingest token missing). Lock theft is asserted by calling
* fetchOlderArchived concurrently while B holds the lock: with correct
* protection the concurrent call is rejected (read count doesn't jump);
* without it, A's stale finally clears the lock and the concurrent call
* would start a duplicate read.
* (c) concurrent fetches during pending backfill: two callers both suspend on
* the backfill promise, both resume after it resolves only one must
* acquire the lock and issue the Tauri read. Fails at 4c92a018d (no
* post-backfill isFetching recheck).
* (d) ABA: old-A's in-flight decrypt completes after the user returns to A.
* Old-A's generation no longer matches (each reset increments the counter),
* so it must not mark fresh-A exhausted or steal fresh-A's lock. Fails
* when resetGeneration is replaced with a channel-string equality check.
*
* DOM shim
* react-dom/client requires a minimal DOM; node has none. We install the same
* minimal shim used by MessageComposerDraftImagePersist.test.mjs.
*
* Tauri IPC mock
* @tauri-apps/api/core calls window.__TAURI_INTERNALS__.invoke(cmd, args).
* We install a per-test mock at globalThis.__TAURI_INTERNALS__.invoke so every
* listSaveSubscriptions / readArchivedObserverEventsForChannel / readUnindexed /
* indexObserverChannelId call is intercepted by command name without patching
* module internals.
*/
import assert from "node:assert/strict";
import { describe, it, beforeEach } from "node:test";
// ── Minimal DOM shim (matches MessageComposerDraftImagePersist.test.mjs) ──────
function installDOMShim() {
class MinimalEventTarget {
constructor() {
this._listeners = {};
}
addEventListener(type, fn) {
if (!this._listeners[type]) this._listeners[type] = [];
this._listeners[type].push(fn);
}
removeEventListener(type, fn) {
if (this._listeners[type]) {
this._listeners[type] = this._listeners[type].filter((f) => f !== fn);
}
}
dispatchEvent(e) {
for (const fn of this._listeners[e.type] ?? []) fn(e);
return true;
}
}
class MinimalNode extends MinimalEventTarget {
constructor(tagName) {
super();
this.tagName = tagName;
this.children = [];
this.childNodes = [];
this.style = {};
this.nodeType = 1;
this.parentNode = null;
}
get ownerDocument() {
return globalThis.document;
}
get firstChild() {
return this.children[0] ?? null;
}
get lastChild() {
return this.children[this.children.length - 1] ?? null;
}
get nextSibling() {
return null;
}
get nodeValue() {
return null;
}
appendChild(child) {
this.children.push(child);
this.childNodes.push(child);
child.parentNode = this;
return child;
}
removeChild(child) {
this.children = this.children.filter((c) => c !== child);
this.childNodes = this.childNodes.filter((c) => c !== child);
return child;
}
insertBefore(newNode, refNode) {
if (!refNode) return this.appendChild(newNode);
const i = this.children.indexOf(refNode);
if (i < 0) return this.appendChild(newNode);
this.children.splice(i, 0, newNode);
this.childNodes.splice(i, 0, newNode);
newNode.parentNode = this;
return newNode;
}
contains(node) {
if (!node) return false;
return this === node || this.children.some((c) => c?.contains?.(node));
}
}
class MinimalDocument extends MinimalEventTarget {
constructor() {
super();
this.nodeType = 9;
}
createElement(tagName) {
return new MinimalNode(tagName);
}
createTextNode(value) {
const n = new MinimalNode("#text");
n.nodeValue = value;
n.nodeType = 3;
return n;
}
createComment(value) {
const n = new MinimalNode("#comment");
n.nodeValue = value;
n.nodeType = 8;
return n;
}
get body() {
if (!this._body) this._body = this.createElement("body");
return this._body;
}
get activeElement() {
return null;
}
contains(node) {
return node != null;
}
}
globalThis.document = new MinimalDocument();
globalThis.HTMLIFrameElement = MinimalNode;
globalThis.HTMLElement = MinimalNode;
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
if (typeof globalThis.window === "undefined") {
Object.defineProperty(globalThis, "window", {
value: globalThis,
configurable: true,
});
}
if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) {
Object.defineProperty(globalThis, "navigator", {
value: { userAgent: "node" },
configurable: true,
});
}
globalThis.MutationObserver = class {
observe() {}
disconnect() {}
takeRecords() {
return [];
}
};
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
}
installDOMShim();
// ── Tauri IPC interceptor ─────────────────────────────────────────────────────
//
// @tauri-apps/api/core calls window.__TAURI_INTERNALS__.invoke(cmd, args).
// Install a stub now (before any module that imports tauriArchive is loaded)
// so listSaveSubscriptions, readArchivedObserverEventsForChannel, etc. can be
// controlled per-test by replacing ipcHandlers.
/** @type {Map<string, (args: unknown) => Promise<unknown>>} */
const ipcHandlers = new Map();
globalThis.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
const handler = ipcHandlers.get(cmd);
if (handler) return handler(args);
return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
},
transformCallback: (_cb) => {
const id = Math.random();
return id;
},
};
function setIpcHandler(cmd, fn) {
ipcHandlers.set(cmd, fn);
}
function clearIpcHandlers() {
ipcHandlers.clear();
}
// ── Production imports (after shim, after IPC stub) ───────────────────────────
import React from "react";
import { createRoot } from "react-dom/client";
import { act } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useLoadArchivedObserverEvents } from "@/features/agents/ui/useObserverEvents.ts";
import {
resetAgentObserverStore,
_testRegisterKnownAgents,
_testGetArchivedChannelEvents,
} from "@/features/agents/observerRelayStore.ts";
// ── Constants ─────────────────────────────────────────────────────────────────
const AGENT_PUBKEY = "a".repeat(64);
const IDENTITY_PUBKEY = "c".repeat(64);
const SUB_ID = "test-hook-sub";
// ── Tauri wire-shape helpers ──────────────────────────────────────────────────
/** Returns a list_save_subscriptions response with one owner_p subscription. */
function makeOwnerPSubResponse() {
return [
{
identity_pubkey: IDENTITY_PUBKEY,
relay_url: "wss://test",
scope_type: "owner_p",
scope_value: IDENTITY_PUBKEY,
kinds: "[24200]",
created_at: 1000,
},
];
}
/** Returns a raw archived observer event row for readArchivedObserverEventsForChannel. */
function makeArchivedRow(seq, channelId = "chan-1") {
return {
id: `ev${String(seq).padStart(63, "0")}`,
pubkey: AGENT_PUBKEY,
created_at: 1000 + seq,
kind: 24200,
tags: [
["p", IDENTITY_PUBKEY],
["agent", AGENT_PUBKEY],
["frame", "telemetry"],
],
content: JSON.stringify({
seq,
timestamp: new Date(1_000_000 + seq * 1000).toISOString(),
channelId,
kind: "telemetry",
sessionId: "sess-1",
turnId: "turn-1",
payload: { method: "session/update", params: {} },
}),
sig: "s".repeat(128),
};
}
// ── React mounting helpers ────────────────────────────────────────────────────
/**
* Mount useLoadArchivedObserverEvents in a real React tree with a QueryClient
* pre-seeded with the identity. Returns { unmount, render(channelId),
* getFetchOlderArchived() }.
*
* getFetchOlderArchived() returns the latest fetchOlderArchived function from
* the hook's return value, captured on each render. Tests can call it directly
* to probe lock behaviour without going through the hydration loop.
*/
function mountHook(_initialChannelId, queryClient) {
// Capture the latest hook return values so tests can call fetchOlderArchived.
const hookReturnRef = { current: null };
function HarnessComponent({ channelId }) {
const result = useLoadArchivedObserverEvents(true, channelId);
hookReturnRef.current = result;
return null;
}
const container = document.createElement("div");
const root = createRoot(container);
const render = async (channelId) => {
await act(async () => {
root.render(
React.createElement(
QueryClientProvider,
{ client: queryClient },
React.createElement(HarnessComponent, { channelId }),
),
);
});
};
return {
render,
getFetchOlderArchived: () => hookReturnRef.current?.fetchOlderArchived,
unmount: async () => {
await act(async () => {
root.unmount();
});
},
};
}
/** Make a QueryClient pre-seeded with identity so useIdentityQuery resolves. */
function makeQueryClient() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(["identity"], { pubkey: IDENTITY_PUBKEY });
return qc;
}
// ── Settle helper ─────────────────────────────────────────────────────────────
//
// Flushes microtasks + a few macrotask ticks so async effects can settle.
// Uses act() so React commits state updates from effects.
async function settle(iterations = 3) {
for (let i = 0; i < iterations; i++) {
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
}
}
// ── Tests ─────────────────────────────────────────────────────────────────────
describe("useLoadArchivedObserverEvents — mounted hook lifecycle regressions", () => {
beforeEach(() => {
resetAgentObserverStore();
clearIpcHandlers();
_testRegisterKnownAgents(SUB_ID, [AGENT_PUBKEY]);
});
/**
* Regression (a): exhausted channel A switch to channel B.
*
* The original stale-closure bug (pre-round-1): fetchOlderArchived captured
* hasOlderArchived from React state (false for exhausted A). After the switch,
* React state was still false while ps.hasOlderArchived had been reset to true.
* The hydration loop called fetchOlderArchived up to 10 times, each returning
* immediately at the !ps.hasOlderArchived guard B never got a read.
*
* After the fix (reading ps.hasOlderArchived from the ref), B gets at least
* one read from a null cursor and its rows are ingested.
*
* PROVENANCE: this test is GREEN at dfb2d0385 (the stale-closure was already
* fixed in round 1). It would be red at the pre-round-1 head (884ed9ba2)
* where hasOlderArchived was captured from React state in the closure.
*/
it("test_exhausted_channel_A_switch_to_B_hook_reads_B_from_null_cursor", async () => {
// Channel A: 1 page of 1 row (short page → exhausted immediately).
const aRows = [makeArchivedRow(1, "chan-a")];
// Channel B: 1 page of 1 row.
const bRows = [makeArchivedRow(2, "chan-b")];
const aCalls = [];
const bCalls = [];
setIpcHandler("list_save_subscriptions", async () =>
makeOwnerPSubResponse(),
);
setIpcHandler("read_unindexed_observer_rows", async () => []);
setIpcHandler("index_observer_channel_id", async () => null);
setIpcHandler("read_archived_observer_events_for_channel", async (args) => {
if (args.channelId === "chan-a") {
aCalls.push({ cursor: args.beforeCreatedAt ?? null });
return aRows.map((r) => JSON.stringify(r));
}
if (args.channelId === "chan-b") {
bCalls.push({ cursor: args.beforeCreatedAt ?? null });
return bRows.map((r) => JSON.stringify(r));
}
return [];
});
// decrypt_observer_event is called inside ingestArchivedObserverEvents.
// invokeTauri passes { eventJson: JSON.stringify(rawRelayEvent) }.
// The row.content is the JSON-encoded ObserverEvent — return it parsed.
setIpcHandler("decrypt_observer_event", async (args) => {
try {
const event = JSON.parse(args.eventJson);
return JSON.parse(event.content);
} catch {
return { kind: "telemetry", channelId: null };
}
});
const qc = makeQueryClient();
const { render, unmount } = mountHook("chan-a", qc);
// Mount on chan-a and let hydration settle.
await render("chan-a");
await settle(10);
// A must have been read (at least one call, from null cursor).
assert.ok(aCalls.length >= 1, `expected A reads, got ${aCalls.length}`);
assert.equal(aCalls[0].cursor, null, "A first read must use null cursor");
// Switch to chan-b.
await render("chan-b");
await settle(10);
// B must have been read from a null cursor (fresh channel, no inherited cursor).
assert.ok(bCalls.length >= 1, `expected B reads, got ${bCalls.length}`);
assert.equal(
bCalls[0].cursor,
null,
"B first read must use null cursor (not A's cursor)",
);
// B's rows must have been ingested into the archive store.
const bArchived = _testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-b");
assert.ok(
bArchived.length >= 1,
`B's rows must be ingested — found ${bArchived.length} (exhausted-A stale closure bug would leave this 0)`,
);
await unmount();
});
/**
* Regression (b): deferred-I/O race AB: A's stale writes after ingest AND
* lock theft via stale finally.
*
* Two protections are under test independently:
*
* 1. Post-ingest exhaustion write (post-ingest token check):
* The bug at dfb2d0385: fetchOlderArchived for A checked the token BEFORE
* ingestArchivedObserverEvents but NOT after. A's deferred decrypt resumed
* after the channel switch, wrote ps.hasOlderArchived=false (short-page
* exhaustion) for B's paging state, and B's eager loop stopped after 1 page.
* Removing the post-ingest token check causes bCallCount==1.
*
* 2. Lock theft via generation-gated finally:
* If the generation guard in finally is removed, stale A's finally runs
* ps.isFetching=false while B holds the lock. We probe this directly:
* after resolving stale A (while B's first read is in flight), we call
* fetchOlderArchived() on B ourselves. With the correct guard, B holds
* the lock and the concurrent call returns immediately (bCallCount stays
* at 1 for now). Without it (stale A stole the lock), the concurrent call
* acquires the lock and starts an extra Tauri read (bCallCount jumps to 2
* prematurely, with concurrent in-flight reads the assertion catches this
* because it fires BEFORE B's deferred first read resolves).
*
* Precise race sequence:
* 1. Mount on chan-a. A's Tauri read returns 1 row. Cursor set. Ingest starts.
* 2. A's decrypt is DEFERRED (aDecryptDeferred).
* 3. Switch to channel B.
* 4. B's hydration loop starts. B's first Tauri read is ALSO DEFERRED.
* 5. Resolve A's decrypt A finishes ingest, hits post-ingest check.
* - At dfb2d0385 (no post-ingest check): writes ps.hasOlderArchived=false.
* Also, if finally is unguarded, ps.isFetching=false (lock stolen).
* - After fix: both writes discarded (generation mismatch).
* 6. LOCK PROBE (while B's first read is still deferred):
* call fetchOlderArchived() directly. Must return without starting a new
* Tauri read (B holds the lock; bCallCount must still be 1).
* 7. Resolve B's first Tauri read B ingests 200 rows.
* 8. B loop continues: bCallCount >= 2.
*
* VERIFIED: removing the post-ingest token check causes bCallCount<2 (step 8).
* Removing just the finally generation guard causes bCallCount>=2 but the lock
* probe at step 6 catches the theft: bCallCount jumps to 2 before B's deferred
* first read resolves (duplicate concurrent read started while B is in flight).
*
* RED at dfb2d0385 (bCallCount==1). GREEN at current head.
*/
it("test_deferred_A_ingest_cannot_exhaust_B_or_steal_B_lock", async () => {
let resolveADecrypt;
const aDecryptDeferred = new Promise((resolve) => {
resolveADecrypt = resolve;
});
let resolveBFirstRead;
const bFirstReadDeferred = new Promise((resolve) => {
resolveBFirstRead = resolve;
});
const PAGE_SIZE = 200;
const makeBPage = (offset) =>
Array.from({ length: PAGE_SIZE }, (_, i) =>
makeArchivedRow(offset + i, "chan-b"),
);
let bCallCount = 0;
let aDecryptStarted = false;
let bFirstReadHeld = false;
setIpcHandler("list_save_subscriptions", async () =>
makeOwnerPSubResponse(),
);
setIpcHandler("read_unindexed_observer_rows", async () => []);
setIpcHandler("index_observer_channel_id", async () => null);
setIpcHandler("read_archived_observer_events_for_channel", async (args) => {
if (args.channelId === "chan-a") {
return [JSON.stringify(makeArchivedRow(1, "chan-a"))]; // 1 row = short page
}
if (args.channelId === "chan-b") {
bCallCount++;
if (bCallCount === 1 && !bFirstReadHeld) {
// Defer B's first Tauri read until we explicitly release it.
bFirstReadHeld = true;
await bFirstReadDeferred;
return makeBPage(100).map((r) => JSON.stringify(r)); // full page
}
if (bCallCount <= 4)
return makeBPage(bCallCount * 100).map((r) => JSON.stringify(r));
return [JSON.stringify(makeArchivedRow(9999, "chan-b"))]; // short = exhaust
}
return [];
});
setIpcHandler("decrypt_observer_event", async (args) => {
try {
const event = JSON.parse(args.eventJson);
const parsed = JSON.parse(event.content);
if (parsed.channelId === "chan-a" && !aDecryptStarted) {
aDecryptStarted = true;
await aDecryptDeferred; // block A's decrypt
}
return parsed;
} catch {
return { kind: "telemetry", channelId: null };
}
});
const qc = makeQueryClient();
const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc);
// Step 1-2: Mount on chan-a. A's Tauri read completes (1 row), cursor set,
// ingest starts and blocks at A's decrypt.
await render("chan-a");
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// Step 3: Switch to chan-b while A's decrypt/ingest is blocked.
await render("chan-b");
// Step 4: B calls its first Tauri read and blocks.
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// B's first read must have been attempted (bCallCount >= 1).
assert.ok(
bCallCount >= 1,
`B must have started its first read before the lock probe — got bCallCount=${bCallCount}`,
);
const bCallCountBeforeAResolve = bCallCount;
// Step 5: Resolve A's decrypt. At dfb2d0385 this writes
// ps.hasOlderArchived=false (if no post-ingest check) and/or clears
// ps.isFetching (if finally is unguarded).
resolveADecrypt();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// Step 6: LOCK PROBE — B still holds the lock (B's first read is deferred).
// Call fetchOlderArchived directly. With correct protection, B's lock is
// intact and this call returns immediately without a Tauri read — bCallCount
// must NOT increase. If A stole the lock, this call acquires it and starts
// a new Tauri read before B's deferred first read resolves (bCallCount jumps).
const fetchFn = getFetchOlderArchived();
if (fetchFn) {
await act(async () => {
await fetchFn();
});
}
assert.equal(
bCallCount,
bCallCountBeforeAResolve,
`Lock probe must not start a new B read while B holds the lock (bCallCount=${bCallCount}, expected ${bCallCountBeforeAResolve}). If A's stale finally cleared the lock, this concurrent call would start a duplicate read.`,
);
// Step 7: Now resolve B's first Tauri read.
resolveBFirstRead();
// Let B's loop run.
await settle(10);
// Step 8: B must have made at least 2 Tauri reads.
// At dfb2d0385: A corrupted ps.hasOlderArchived=false before B's first page
// resolved, so after B's first page, the loop checks and exits. bCallCount==1.
// After fix: A's write was discarded, ps.hasOlderArchived is still true,
// B continues to page 2+.
assert.ok(
bCallCount >= 2,
`B must read at least 2 pages — got ${bCallCount}. Post-ingest token missing at dfb2d0385 let A corrupt B's exhaustion state (bCallCount==1).`,
);
// A's row must NOT appear in B's channel archive.
const bArchived = _testGetArchivedChannelEvents(AGENT_PUBKEY, "chan-b");
for (const evt of bArchived) {
assert.equal(
evt.channelId,
"chan-b",
"B's archive must only contain B-channel events",
);
}
await unmount();
});
/**
* Regression (c): concurrent fetches during pending backfill only one
* same-generation call may acquire the lock after backfill resolves.
*
* The gap at 4c92a018d: `ps.isFetching` was checked only at the top of
* fetchOlderArchived, BEFORE `await ps.backfillPromise`. Two callers
* (eager hydration loop + a concurrent scroll trigger) could both observe
* isFetching=false, both suspend on the same pending backfill promise, then
* both resume and proceed past the post-backfill guard (which only checked
* generation + exhaustion). Both would set isFetching=true and issue
* readArchivedObserverEventsForChannel from the SAME cursor duplicating
* the first page.
*
* Fix: after `await ps.backfillPromise`, recheck `ps.isFetching` immediately
* before acquiring the lock. The second caller finds the lock already taken
* and returns without reading.
*
* The test defers the ARCHIVE response (not just backfill) so we can count
* reads while the winner's first response is still in flight. If both
* callers acquired the lock, archiveCallCount will be 2 before the first
* deferred response is released. With the fix, archiveCallCount is 1.
*
* Precise race sequence:
* 1. Mount on chan-a. Backfill is DEFERRED (backfillDeferred).
* Archive responses are also deferred until resolveArchive() is called.
* 2. Hydration loop call #1 suspends on backfill.
* 3. Inject manual call #2 it also sees isFetching=false and suspends on
* backfill.
* 4. Resolve backfill. Both calls resume and race to acquire the lock.
* - Without fix: both pass the post-backfill guard, both set
* isFetching=true, both issue the Tauri read archiveCallCount == 2.
* - With fix: one passes, sets isFetching=true; the other sees the lock
* taken and returns archiveCallCount == 1.
* 5. Assert archiveCallCount == 1 before releasing archive response.
* (Archive is still deferred, so loop hasn't advanced past page 1 yet
* any count > 1 is purely from the concurrent race, not loop progress.)
*
* VERIFIED: removing the post-backfill `ps.isFetching` recheck causes
* archiveCallCount == 2 at step 5. GREEN at current head.
*/
it("test_two_concurrent_fetches_during_backfill_only_one_proceeds", async () => {
let resolveBackfill;
const backfillDeferred = new Promise((resolve) => {
resolveBackfill = resolve;
});
// Defer ALL archive responses until we release them. This way, if two
// callers both acquire the lock, archiveCallCount jumps to 2 before we
// release the response — and we can catch it unambiguously.
let resolveArchive;
const archiveDeferred = new Promise((resolve) => {
resolveArchive = resolve;
});
let archiveCallCount = 0;
setIpcHandler("list_save_subscriptions", async () =>
makeOwnerPSubResponse(),
);
// Defer readUnindexedObserverRows to simulate a pending backfill.
setIpcHandler("read_unindexed_observer_rows", async () => {
await backfillDeferred;
return [];
});
setIpcHandler("index_observer_channel_id", async () => null);
setIpcHandler("read_archived_observer_events_for_channel", async (args) => {
if (args.channelId === "chan-a") {
archiveCallCount++;
// Hold this response until we explicitly release it so we can
// count concurrent reads before any result is returned.
await archiveDeferred;
return Array.from({ length: 200 }, (_, i) =>
JSON.stringify(makeArchivedRow(i, "chan-a")),
);
}
return [];
});
setIpcHandler("decrypt_observer_event", async (args) => {
try {
const event = JSON.parse(args.eventJson);
return JSON.parse(event.content);
} catch {
return { kind: "telemetry", channelId: null };
}
});
const qc = makeQueryClient();
const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc);
// Step 1-2: Mount on chan-a. Backfill is in flight (deferred).
// Hydration loop call #1 enters fetchOlderArchived and suspends on backfill.
await render("chan-a");
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// Step 3: Inject call #2 while backfill is still pending and call #1 is
// suspended. Both observe isFetching=false here.
const fetchFn = getFetchOlderArchived();
let call2Promise;
if (fetchFn) {
// Do NOT await yet — let it run concurrently with call #1.
call2Promise = fetchFn();
}
// Let call #2 reach its backfill await before we resolve backfill.
await act(async () => {
await new Promise((r) => setTimeout(r, 5));
});
// Step 4: Resolve backfill. Both calls resume and race to acquire lock.
resolveBackfill();
// Yield to let both calls advance past the post-backfill guard and issue
// their Tauri reads (or be blocked by the lock recheck).
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// Step 5: Archive responses are still deferred — loop cannot have advanced
// past page 1. Any archiveCallCount > 1 here is purely from concurrent
// reads racing through the backfill await without a lock recheck.
assert.equal(
archiveCallCount,
1,
`Exactly one archive read must start after backfill resolves — got ${archiveCallCount}. Without the post-backfill isFetching recheck, both concurrent callers acquire the lock and both issue a Tauri read (archiveCallCount == 2).`,
);
// Release archive responses so the lock holder can finish and the test
// can unmount cleanly.
resolveArchive();
// Wait for call #2 to settle as well.
if (call2Promise) {
await act(async () => {
await call2Promise;
});
}
await settle(5);
await unmount();
});
/**
* Regression (d): ABA old-A's stale in-flight request must not corrupt
* fresh-A's paging state when the user returns to channel A.
*
* The gap in the channel-string equality check (af45fbf05): using
* `requestChannelId === ps.activeChannelId` as the ownership test fails when
* the user navigates ABA. Old-A's request sees `ps.activeChannelId === "A"`
* after the second return to A so every check passes. Old-A can mark fresh-A
* exhausted and its finally releases fresh-A's lock.
*
* With resetGeneration each applyChannelReset() call increments the counter,
* so A(gen=1) B(gen=2) A(gen=3): old-A snapshotted gen=1, which never
* equals gen=3, so all writes are discarded regardless of channel name.
*
* Race sequence:
* 1. Mount on chan-a (gen=1). A's Tauri read = 1 row (short page). Ingest
* starts. A's decrypt is DEFERRED.
* 2. Switch to chan-b (gen=2). B's paging starts.
* 3. Switch BACK to chan-a (gen=3). Fresh-A's hydration starts. Fresh-A's
* first Tauri read is DEFERRED (freshAReadDeferred).
* 4. Resolve old-A's decrypt. Old-A hits short-page branch.
* - Without generation: old-A sees ps.activeChannelId==="chan-a", writes
* ps.hasOlderArchived=false and clears ps.isFetching.
* - With generation: gen=1 !== gen=3, writes discarded.
* 5. LOCK PROBE: call fetchOlderArchived directly. Fresh-A holds the lock
* (its deferred read is in flight). With correct protection the probe
* returns immediately (freshACallCount unchanged). Without it (old-A's
* finally stole the lock), the probe starts a duplicate read.
* 6. Resolve fresh-A's first read (full page). Fresh-A loop continues.
* 7. Assert freshACallCount >= 2 (fresh-A ran past page 1).
*
* VERIFIED: this test is RED when activeChannelId string equality replaces
* resetGeneration (old-A passes every check, marks fresh-A exhausted at step 4).
* GREEN at current head.
*/
it("test_A_B_A_old_request_cannot_corrupt_fresh_A_state", async () => {
let resolveOldADecrypt;
const oldADecryptDeferred = new Promise((resolve) => {
resolveOldADecrypt = resolve;
});
let resolveFreshAFirstRead;
const freshAFirstReadDeferred = new Promise((resolve) => {
resolveFreshAFirstRead = resolve;
});
const PAGE_SIZE = 200;
const makePage = (channelId, offset) =>
Array.from({ length: PAGE_SIZE }, (_, i) =>
makeArchivedRow(offset + i, channelId),
);
let oldADecryptStarted = false;
// Track calls per channel / phase. We only care about chan-a reads on fresh-A.
let freshACallCount = 0;
// After we switch back to A (gen=3), track reads for that phase.
let onFreshA = false;
setIpcHandler("list_save_subscriptions", async () =>
makeOwnerPSubResponse(),
);
setIpcHandler("read_unindexed_observer_rows", async () => []);
setIpcHandler("index_observer_channel_id", async () => null);
setIpcHandler("read_archived_observer_events_for_channel", async (args) => {
if (args.channelId === "chan-a") {
if (!onFreshA) {
// Old-A's read: 1 row = short page.
return [JSON.stringify(makeArchivedRow(1, "chan-a"))];
}
// Fresh-A's reads.
freshACallCount++;
if (freshACallCount === 1) {
// Defer fresh-A's first read.
await freshAFirstReadDeferred;
return makePage("chan-a", 200).map((r) => JSON.stringify(r)); // full
}
if (freshACallCount <= 4)
return makePage("chan-a", freshACallCount * 200).map((r) =>
JSON.stringify(r),
);
return [JSON.stringify(makeArchivedRow(9999, "chan-a"))]; // exhaust
}
if (args.channelId === "chan-b") {
// B gets one short page (we don't care about B's progress here).
return [JSON.stringify(makeArchivedRow(50, "chan-b"))];
}
return [];
});
setIpcHandler("decrypt_observer_event", async (args) => {
try {
const event = JSON.parse(args.eventJson);
const parsed = JSON.parse(event.content);
if (parsed.channelId === "chan-a" && !oldADecryptStarted && !onFreshA) {
oldADecryptStarted = true;
await oldADecryptDeferred; // block OLD A's decrypt
}
return parsed;
} catch {
return { kind: "telemetry", channelId: null };
}
});
const qc = makeQueryClient();
const { render, getFetchOlderArchived, unmount } = mountHook("chan-a", qc);
// Step 1: Mount on chan-a (gen=1). Old-A's Tauri read returns 1 row.
// Ingest starts, decrypt blocks.
await render("chan-a");
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// Step 2: Switch to chan-b (gen=2).
await render("chan-b");
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// Step 3: Switch back to chan-a (gen=3). Fresh-A hydration starts.
onFreshA = true;
await render("chan-a");
await act(async () => {
await new Promise((r) => setTimeout(r, 20));
});
// Fresh-A must have started its first (deferred) read.
assert.ok(
freshACallCount >= 1,
`fresh-A must have started its first read — got freshACallCount=${freshACallCount}`,
);
const freshACountBeforeOldResolve = freshACallCount;
// Step 4: Resolve old-A's decrypt. Without generation check, old-A's
// post-ingest branch writes ps.hasOlderArchived=false (marks fresh-A
// exhausted) and its finally clears ps.isFetching (steals fresh-A's lock).
resolveOldADecrypt();
await act(async () => {
await new Promise((r) => setTimeout(r, 10));
});
// Step 5: LOCK PROBE — fresh-A holds the lock (first read deferred).
// A concurrent fetchOlderArchived call must be rejected (lock held).
// If old-A stole the lock, this probe starts a duplicate read (freshACallCount
// jumps before the deferred first read resolves).
const fetchFn = getFetchOlderArchived();
if (fetchFn) {
await act(async () => {
await fetchFn();
});
}
assert.equal(
freshACallCount,
freshACountBeforeOldResolve,
`Lock probe must not start a new fresh-A read while fresh-A holds the lock (freshACallCount=${freshACallCount}, expected ${freshACountBeforeOldResolve}). Old-A's stale finally stole the lock (A→B→A channel-string equality bug).`,
);
// Step 6: Resolve fresh-A's first read (full page). Loop continues.
resolveFreshAFirstRead();
await settle(10);
// Step 7: Fresh-A must have made at least 2 reads (loop continued past page 1).
// Without generation check, old-A wrote ps.hasOlderArchived=false before
// fresh-A's first page resolved, causing the loop to exit — freshACallCount==1.
assert.ok(
freshACallCount >= 2,
`fresh-A must read at least 2 pages — got ${freshACallCount}. Old-A's stale writes (A→B→A) would leave freshACallCount==1.`,
);
await unmount();
});
});
@@ -21,6 +21,7 @@ import type { RelayEvent } from "@/shared/api/types";
import {
createArchivePagingState,
applyChannelReset,
runHydrationLoop,
} from "./archivePagingState";
export type { ArchivePagingState } from "./archivePagingState";
@@ -88,7 +89,12 @@ export function useArchivedChannelEvents(
return React.useSyncExternalStore(subscribeToStore, getSnapshot);
}
const ARCHIVED_EVENTS_PAGE_SIZE = 50;
const ARCHIVED_EVENTS_PAGE_SIZE = 200;
// Number of pages to load eagerly on panel open (before any scroll). Each page
// is ARCHIVED_EVENTS_PAGE_SIZE frames; 10 pages = 2000 frames, which covers
// agent turns that emit hundreds of frames (e.g. a full code-review turn ~900).
const INITIAL_HYDRATION_BUDGET_PAGES = 10;
/**
* Load-older-on-scroll for archived observer frames, scoped to a single channel.
@@ -138,10 +144,13 @@ export function useLoadArchivedObserverEvents(
// Reset per-channel paging state when channelId changes. Backfill state is
// identity-level (not per-channel) and must NOT be reset here — the backfill
// index covers all channels and only needs to run once per identity mount.
// Only the cursor, exhaustion flag, and fetching lock are channel-scoped.
// Only the cursor, exhaustion flag, fetching lock, channel label, and
// resetGeneration are channel-scoped. resetGeneration is incremented by
// applyChannelReset so in-flight reads from any prior reset (including
// A→B→A) detect staleness and discard their results.
// biome-ignore lint/correctness/useExhaustiveDependencies: channelId is the intentional reset key; ps is a stable ref excluded from deps by convention; setHasOlderArchived is a stable React state setter
React.useEffect(() => {
applyChannelReset(ps);
applyChannelReset(ps, channelId);
setHasOlderArchived(true);
}, [channelId]);
@@ -259,7 +268,7 @@ export function useLoadArchivedObserverEvents(
ps.backfillPromise = promise;
}, [enabled, hasSubscription]);
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; ps.isFetching/ps.cursor/ps.backfillPromise/ps.hasOlderArchived are read via the stable ref object, not reactive values
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; all per-page state (isFetching, cursor, hasOlderArchived, resetGeneration) is read from the ref rather than React state, so this callback is intentionally stable across exhaustion/channel changes
const fetchOlderArchived = React.useCallback(async () => {
if (
!enabled ||
@@ -267,11 +276,18 @@ export function useLoadArchivedObserverEvents(
!hasSubscription ||
!channelId ||
ps.isFetching ||
!hasOlderArchived
!ps.hasOlderArchived
) {
return;
}
// Snapshot the reset generation at the start of this request. Every
// shared-state write below rechecks requestGeneration === ps.resetGeneration
// first. A mismatch means at least one channel switch occurred while we
// were awaiting async I/O — even A→B→A is detected because each switch
// increments resetGeneration. Channel ID is kept only as the query input.
const requestGeneration = ps.resetGeneration;
// Await backfill completion before reading the channel index. This
// guarantees the index is populated before the first paginated read, so
// a scroll-trigger that fires before backfill writes can't return 0 rows
@@ -280,12 +296,21 @@ export function useLoadArchivedObserverEvents(
await ps.backfillPromise;
}
// Re-check after awaiting: hasOlderArchived might have been set false
// while we were waiting (e.g. subscription check failed).
if (!hasOlderArchived) {
// Re-check after awaiting: generation may have advanced (channel switched),
// archive exhausted, or another concurrent caller may have acquired the
// fetch lock while we were suspended on backfill. All three must be
// re-evaluated because any of them could have changed mid-await.
if (
!ps.hasOlderArchived ||
requestGeneration !== ps.resetGeneration ||
ps.isFetching
) {
return;
}
// Acquire the fetch lock under this request's generation. The finally
// block only releases the lock if the generation still matches — so a stale
// in-flight request cannot clear the lock that belongs to a later reset.
ps.isFetching = true;
try {
const before = ps.cursor ?? undefined;
@@ -294,6 +319,13 @@ export function useLoadArchivedObserverEvents(
limit: ARCHIVED_EVENTS_PAGE_SIZE,
});
// Discard result if the generation advanced while the Tauri read was in
// flight (channel switch, including A→B→A). The new channel will start
// its own read with a null cursor.
if (requestGeneration !== ps.resetGeneration) {
return;
}
if (events.length > 0) {
// Cursor = the last row in newest-first order = the oldest event on
// this page. Capture both created_at and id to mirror the compound
@@ -306,6 +338,14 @@ export function useLoadArchivedObserverEvents(
await ingestArchivedObserverEvents(events);
}
// Re-check generation after ingestArchivedObserverEvents: ingestion
// decrypts each frame asynchronously and may take time. If a channel
// switch occurred during that await (including A→B→A), discard all
// remaining shared-state writes — exhaustion and React mirror.
if (requestGeneration !== ps.resetGeneration) {
return;
}
// A short page means the archive is exhausted for this channel.
if (events.length < ARCHIVED_EVENTS_PAGE_SIZE) {
setHasOlderArchived(false);
@@ -314,9 +354,56 @@ export function useLoadArchivedObserverEvents(
} catch (error) {
console.error("[useLoadArchivedObserverEvents] fetch failed:", error);
} finally {
ps.isFetching = false;
// Only release the fetch lock if this request still owns it. If the
// generation advanced (any channel switch including A→B→A), the new
// channel acquired its own lock — releasing here would steal it.
if (requestGeneration === ps.resetGeneration) {
ps.isFetching = false;
}
}
}, [enabled, identityPubkey, hasSubscription, channelId, hasOlderArchived]);
}, [enabled, identityPubkey, hasSubscription, channelId]);
// Eager initial hydration: on panel open (or channel switch), load archive
// pages automatically until the budget is reached or the channel is exhausted.
// This makes archived history visible immediately without any scrolling.
//
// Runs when: enabled + subscription confirmed + channelId resolved +
// hydration not yet done for this channel. Respects `applyChannelReset`
// (which resets initialHydrationDone) so channel switches trigger a fresh
// pass. Uses fetchOlderArchived's existing lock/cursor/backfill-await
// machinery — no parallel state machine.
//
// fetchOlderArchived is now stable (it no longer captures hasOlderArchived
// from React state — it reads ps.hasOlderArchived from the ref), so it is
// safe to call from this effect without coupling the hydration lifecycle to
// React state identity changes.
// biome-ignore lint/correctness/useExhaustiveDependencies: ps is a stable ref; initialHydrationDone is read from ps (not as a reactive dep) to avoid triggering re-runs; fetchOlderArchived is stable and intentionally omitted
React.useEffect(() => {
if (
!enabled ||
!identityPubkey ||
!hasSubscription ||
!channelId ||
ps.initialHydrationDone
) {
return;
}
// Mark done immediately to prevent concurrent hydration loops. The loop
// runs asynchronously; the signal object handles mid-loop cancellation on
// channel switch (the cleanup fn sets signal.cancelled = true).
ps.initialHydrationDone = true;
const signal = { cancelled: false };
void runHydrationLoop(
ps,
fetchOlderArchived,
INITIAL_HYDRATION_BUDGET_PAGES,
signal,
);
return () => {
signal.cancelled = true;
};
}, [enabled, identityPubkey, hasSubscription, channelId]);
return { fetchOlderArchived, hasOlderArchived };
}