fix(edit-history): review round 3 — restored sessions read again, /clear reads the old conversation to its end, bounded memory

- remove() drops the session's slot once the file is gone: a session that comes
  back with the same id (reboot restore) starts afresh instead of answering
  `removed` until the next restart. A run in flight stays on its own slot's store,
  and the routes start a sync in the same tick as their session check, so a
  deletion right after it still stops that sync. A store that is not loaded is
  deleted without being parsed (deleteStore).
- A new conversation (/clear, resume) first reads the previous transcript, next
  to the new one, to its end with its subagents, so edits it got after the last
  read are not lost with its cursors. "Load older" reads the stored conversation
  while the switch is pending.
- At most MAX_LOADED_STORES (3) stores stay in memory; the least recently used one
  without a run is written if it changed (settle) and loads again on demand.
- Tmp files are named per process start, so a reused pid (a container's pid 1)
  no longer keeps a stale one through the startup sweep.
- load() keeps only a plain transcript id (the previous transcript's path is built
  from it) and no cursors without one; a pending Bash call with an id, cwd or
  agent id over 4,096 characters is not kept at parse time either.
- nextRange() is gone: production reads forward through the reader. The store
  tests mirror the reader's range choice, and a new end-to-end test runs the
  real reader, sync and store (/clear cases, 40 seeded random transcripts).
- The wiring guard scans all of src for holders of the sync.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
RandalixandClaude Opus 5.5 committed 2026-10-23 15:35:12 +02:00
1 parent 321268754a
commit 1c87cd2ac7
13 files changed
+581 -90

No files matched your search

+12
View File
@@ -11,6 +11,7 @@ import {
MAX_IMAGE_CANDIDATES,
MAX_PENDING_BASH,
MAX_PENDING_COMMAND_CHARS,
MAX_PENDING_FIELD_CHARS,
parseClaudeEditLines,
} from '../src/edit-history-parse.js';
@@ -255,6 +256,17 @@ describe('parseClaudeEditLines: Bash image candidates', () => {
expect(pending[0].id).toBe('toolu_5');
});
it(`keeps no call with an id, cwd or agent id over ${MAX_PENDING_FIELD_CHARS} characters (the store would refuse it on load)`, () => {
const long = 'x'.repeat(MAX_PENDING_FIELD_CHARS + 1);
const lines = [
bashUseLine(long, start, 'render > a.png'),
bashUseLine('toolu_cwd', start, 'render > a.png', { cwd: '/' + long }),
bashUseLine('toolu_agent', start, 'render > a.png', { agentId: long }),
bashUseLine('toolu_ok', start, 'render > a.png', { cwd: '/' + 'x'.repeat(MAX_PENDING_FIELD_CHARS - 1) }),
];
expect(parseClaudeEditLines(lines.join('\n')).pending.map((p) => p.id)).toEqual(['toolu_ok']);
});
it('keeps only the head of a long command (the pending list is persisted)', () => {
const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2);
const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command));
+214
View File
@@ -0,0 +1,214 @@
/**
* Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on
* temp transcripts — what the fakes in edit-history-sync.test.ts cannot show (file order,
* a new conversation next to the old one, every line read exactly once).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readClaudeEditSources } from '../src/edit-history-local-source.js';
import { EditHistoryStore } from '../src/edit-history-store.js';
import { EditHistorySync, MAX_FORWARD_PASSES, type EditHistorySession } from '../src/web/edit-history-sync.js';
let root: string;
const opened: EditHistoryStore[] = [];
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'edit-pipeline-'));
});
afterEach(async () => {
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(root, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
/** One edit result line of `pad` bytes or more, newline included. */
function editLine(id: string, i: number, pad = 0, agentId?: string): string {
const rec: Record<string, unknown> = {
type: 'user',
timestamp: new Date(T0 + i * 1000).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: `/w/${id}.ts`,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
},
pad: '',
};
if (agentId) rec.agentId = agentId;
rec.pad = 'p'.repeat(Math.max(0, pad - JSON.stringify(rec).length - 1));
return JSON.stringify(rec) + '\n';
}
function setup(budget?: number, base = root) {
const project = join(base, 'projects', 'p');
mkdirSync(project, { recursive: true });
const at = (transcriptId: string) => join(project, `${transcriptId}.jsonl`);
/** The transcript watcher's path (the hooks report it; `/clear` moves it). */
let current = at('convA');
const sync = new EditHistorySync({
transcriptPath: async () => (existsSync(current) ? current : null),
readSources: readClaudeEditSources,
probe: async (paths) => paths.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir: join(base, 'store') }),
sweepStores: async () => 0,
budget,
});
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'convA' };
const ids = async () =>
(await sync.getStore('s1'))
.list()
.map((e) => e.id)
.sort();
return {
project,
at,
sync,
session,
ids,
switchTo: (transcriptId: string) => (current = at(transcriptId)),
};
}
describe('a new conversation (/clear, resume)', () => {
it('reads the previous transcript to its end before the new one', async () => {
const { at, sync, session, ids, switchTo } = setup();
appendFileSync(at('convA'), editLine('a1', 1));
await sync.sync(session);
// The turn's last edits land, then /clear before the panel's debounced re-read.
appendFileSync(at('convA'), editLine('a2', 2) + editLine('a3', 3));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 4));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'a2', 'a3', 'b1']);
const store = await sync.getStore('s1');
expect(store.getTranscriptId()).toBe('convB');
expect(store.knownSources()).toEqual(['main']);
});
it('a previous backlog larger than one sync reads on with the next ones; the new transcript follows', async () => {
const { at, sync, session, ids, switchTo } = setup(600);
appendFileSync(at('convA'), editLine('a0', 0));
await sync.sync(session);
const backlog = Array.from({ length: MAX_FORWARD_PASSES * 3 }, (_, i) => `a${i + 1}`);
appendFileSync(at('convA'), backlog.map((id, i) => editLine(id, i + 1, 500)).join(''));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 99));
await sync.sync(session);
expect(await ids()).not.toContain('b1');
for (let i = 0; i < 5; i++) await sync.sync(session);
expect(await ids()).toEqual(['a0', ...backlog, 'b1'].sort());
});
it('subagents of the previous conversation are read to their end too', async () => {
const { project, at, sync, session, ids, switchTo } = setup();
const sub = join(project, 'convA', 'subagents');
mkdirSync(sub, { recursive: true });
appendFileSync(at('convA'), editLine('a1', 1));
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x1', 2, 0, 'x'));
await sync.sync(session);
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x2', 3, 0, 'x'));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 4));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'b1', 'x1', 'x2']);
});
it('a previous transcript that is gone does not hold up the new one', async () => {
const { at, sync, session, ids, switchTo } = setup();
appendFileSync(at('convA'), editLine('a1', 1));
await sync.sync(session);
rmSync(at('convA'));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 2));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'b1']);
});
it('"Load older" before the forward read moved on reads the stored conversation, not the new file', async () => {
const { at, sync, session, ids, switchTo } = setup(600);
const older = Array.from({ length: 4 }, (_, i) => `a${i}`);
appendFileSync(at('convA'), older.map((id, i) => editLine(id, i, 500)).join(''));
await sync.sync(session);
expect(await ids()).toEqual(['a3']);
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 99));
await sync.sync(session, { older: true });
expect(await ids()).toEqual(['a2', 'a3']);
});
});
describe('every line exactly once (seeded random transcripts)', () => {
/** Deterministic: a seed fixes budget, line lengths, subagents, partial writes and sync points. */
async function run(seed: number): Promise<string | null> {
let state = seed;
const rnd = () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
const budget = ri(400, 1500);
const { project, at, sync, session } = setup(budget, mkdtempSync(join(root, 'run-')));
const main = at('convA');
const subDir = join(project, 'convA', 'subagents');
const expected = new Set<string>();
const tooLong = new Set<string>();
const agents: string[] = [];
const tails = new Map<string, string>();
let n = 0;
const write = () => {
if (rnd() < 0.15 && agents.length < 6) {
mkdirSync(subDir, { recursive: true });
agents.push(`a${agents.length}`);
}
const k = ri(0, agents.length);
const agent = k === 0 ? undefined : agents[k - 1];
const file = agent ? join(subDir, `agent-${agent}.jsonl`) : main;
const id = `e${n++}`;
// Some lines are longer than the budget: skipped, never shown, never blocking.
const line = editLine(id, n, rnd() < 0.12 ? ri(budget + 2, budget * 3) : ri(250, budget - 2), agent);
if (Buffer.byteLength(line) < budget) expected.add(id);
else if (Buffer.byteLength(line) > budget) tooLong.add(id);
// Sometimes half a line: claude is still writing it.
const cut = rnd() < 0.15 ? ri(1, line.length - 1) : line.length;
appendFileSync(file, (tails.get(file) ?? '') + line.slice(0, cut));
if (cut < line.length) tails.set(file, line.slice(cut));
else tails.delete(file);
};
for (let i = ri(0, 20); i > 0; i--) write(); // backlog before the panel opens
if (!existsSync(main)) appendFileSync(main, '');
for (let r = ri(5, 25); r > 0; r--) {
for (let i = ri(0, 6); i > 0; i--) write();
await sync.sync(session);
}
for (const [file, rest] of tails) appendFileSync(file, rest);
for (let i = 0; i < 40; i++) await sync.sync(session);
const store = await sync.getStore('s1');
for (let clicks = 0; store.backfillTruncated && clicks < 400; clicks++) await sync.sync(session, { older: true });
const got = new Set(store.list().map((e) => e.id));
const missing = [...expected].filter((id) => !got.has(id));
const extra = [...got].filter((id) => tooLong.has(id));
if (missing.length || extra.length || store.backfillTruncated) {
return `seed ${seed} (budget ${budget}): missing ${missing.join(',')} extra ${extra.join(',')}`;
}
return null;
}
it('reads every line shorter than the budget once, forward and back, and none longer', async () => {
const failures: string[] = [];
for (let seed = 1; seed <= 40; seed++) {
await Promise.all(opened.splice(0).map((s) => s.remove()));
const r = await run(seed);
if (r) failures.push(r);
}
expect(failures).toEqual([]);
});
});
+61 -9
View File
@@ -16,6 +16,8 @@ import {
MAX_FILE_BYTES,
MAX_STORE_BYTES,
parseAgentMeta,
READ_BUDGET_BYTES,
TMP_TAG,
} from '../src/edit-history-store.js';
import { MAX_HUNK_BYTES } from '../src/edit-history-parse.js';
@@ -77,12 +79,24 @@ function bashResult(id: string, sec: number): string {
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
/** Read like the sync will: ask the store for a range, slice it out of `file`, apply. */
function syncForward(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
const range = store.nextRange(source, file.length, tid, budget);
if (!range) return null;
const chunk = file.subarray(range.offset, range.offset + range.len);
return store.apply({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
/**
* Read like the reader (edit-history-local-source.ts): on from the cursor of the current
* conversation (clamped to the size), else the file's last `budget` bytes; then apply.
* Null when nothing is new.
*/
function syncForward(
store: EditHistoryStore,
file: Buffer,
budget = READ_BUDGET_BYTES,
source = MAIN_SOURCE,
tid = 'conv-1'
) {
const cursor = tid === store.getTranscriptId() ? store.getCursor(source) : undefined;
const offset = cursor ? Math.min(cursor.offset, file.length) : Math.max(0, file.length - budget);
const len = Math.min(budget, file.length - offset);
if (cursor && offset === cursor.offset && len === 0) return null;
const chunk = file.subarray(offset, offset + len);
return store.apply({ transcriptId: tid, source, startOffset: offset, chunk, size: file.length });
}
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
@@ -192,10 +206,13 @@ describe('EditHistoryStore: forward reads', () => {
expect(store.get('e2')?.path).toBe(path);
});
it('restarts a file that shrank (replaced) from its end', () => {
it('restarts a file that shrank (replaced) from its end, with the next read', () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
const replaced = jsonl([editLine('n1', 3)]);
// The reader clamps the cursor to the size: an empty chunk at the end, and the cursor goes.
expect(syncForward(store, replaced)?.added).toBe(0);
expect(store.getCursor(MAIN_SOURCE)).toBeUndefined();
expect(syncForward(store, replaced)?.added).toBe(1);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
});
@@ -214,7 +231,6 @@ describe('EditHistoryStore: conversations and sources', () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1)]));
const second = jsonl([editLine('f1', 5)]);
expect(store.nextRange(MAIN_SOURCE, second.length, 'conv-2')).toEqual({ offset: 0, len: second.length });
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
expect(store.getTranscriptId()).toBe('conv-2');
expect(ids(store)).toEqual(['f1', 'e1']);
@@ -590,16 +606,52 @@ describe('EditHistoryStore: persistence', () => {
'live.json',
'gone.json',
`gone.json.${process.pid + 1}.tmp`,
// An earlier run that had this process's pid (a container's pid 1): stale as well.
`live.json.${process.pid}.tmp`,
`live.json.${process.pid}-0a1b2c3d.tmp`,
`live.json.${TMP_TAG}.tmp`,
'notes.txt',
]) {
writeFileSync(join(dir, name), '{}');
}
expect(await EditHistoryStore.sweep((id) => id === 'live', { dir })).toBe(1);
expect(readdirSync(dir).sort()).toEqual([`live.json.${process.pid}.tmp`, 'live.json', 'notes.txt'].sort());
expect(readdirSync(dir).sort()).toEqual([`live.json.${TMP_TAG}.tmp`, 'live.json', 'notes.txt'].sort());
expect(await EditHistoryStore.sweep(() => false, { dir: join(dir, 'missing') })).toBe(0);
});
it('takes only a plain transcript id from the file (the sync builds a path from it)', async () => {
const cursors = { main: { offset: 10, floor: 0 } };
for (const transcriptId of ['../../etc/passwd', 'a/b', '', 7]) {
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId, cursors, entries: [] }));
const store = await EditHistoryStore.load('s1', { dir });
expect(store.getTranscriptId()).toBeNull();
expect(store.knownSources()).toEqual([]); // offsets into no known conversation
}
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId: 'conv-1.a_b', cursors }));
const store = await EditHistoryStore.load('s1', { dir });
expect(store.getTranscriptId()).toBe('conv-1.a_b');
expect(store.knownSources()).toEqual(['main']);
});
it('settle() writes pending changes and nothing for a store without any', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.settle();
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
const loaded = await EditHistoryStore.load('s1', { dir });
rmSync(join(dir, 's1.json'));
await loaded.settle();
await new EditHistoryStore('empty', { dir }).settle();
expect(readdirSync(dir)).toEqual([]);
});
it('delete() removes a stored history without parsing it', async () => {
writeFileSync(join(dir, 's1.json'), 'not json');
await EditHistoryStore.delete('s1', { dir });
await EditHistoryStore.delete('missing', { dir });
expect(readdirSync(dir)).toEqual([]);
});
it('ignores a file of an unknown version', async () => {
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
+102 -11
View File
@@ -3,7 +3,7 @@
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EditHistoryStore } from '../src/edit-history-store.js';
@@ -16,6 +16,7 @@ import type {
import {
confirmImages,
EditHistorySync,
MAX_LOADED_STORES,
MAX_OLDER_SOURCES,
NO_TRANSCRIPT,
REMOVED,
@@ -126,6 +127,8 @@ function setup(over: Partial<EditHistorySyncDeps> = {}) {
const host = new FakeHost();
const probes = new Map<string, PathProbe>();
const lookups: string[] = [];
/** Session ids in the order their stores were loaded. */
const loads: string[] = [];
const deps: EditHistorySyncDeps = {
transcriptPath: async (session) => {
lookups.push(session.id);
@@ -134,16 +137,18 @@ function setup(over: Partial<EditHistorySyncDeps> = {}) {
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...over,
};
const load = deps.loadStore;
deps.loadStore = async (id) => {
loads.push(id);
const store = await load(id);
opened.push(store);
return store;
};
return { host, probes, lookups, deps, sync: new EditHistorySync(deps) };
return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) };
}
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
@@ -317,28 +322,107 @@ describe('EditHistorySync', () => {
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
});
it('a deleted session stays deleted: neither a run in flight nor a later request writes it again', async () => {
it('neither a run in flight nor a request during the removal writes the deleted history again', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
let entered!: () => void;
const reading = new Promise<void>((r) => (entered = r));
const orig = host.read;
host.read = async (...a) => {
entered();
await gate;
return orig(...a);
};
const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued
await reading;
const removing = sync.remove('s1');
// The removal waits for the read under way; a request meanwhile reads nothing.
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
release();
await Promise.all([...runs, removing]);
await new Promise((r) => setTimeout(r, 20)); // past the store's debounce
expect(readdirSync(dir)).toEqual([]);
const reads = host.calls.length;
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
await new Promise((r) => setTimeout(r, 20));
expect(host.calls).toHaveLength(reads);
expect(host.calls).toHaveLength(1); // the read under way; the queued run never read
});
it('the same conversation under an unnormalized path reads on from its cursors, nothing else', async () => {
const path = '/p/./conv1.jsonl';
const { host, sync } = setup({ transcriptPath: async () => path });
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
host.append('main', editLine('t2', T0 + 1, '/w/a.ts'));
await sync.sync(local);
expect(host.paths).toEqual([path, path]);
expect(host.calls[1]).toEqual([
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1, '/w/a.ts').length },
]);
});
it('a session that comes back with the same id (reboot restore) reads its history afresh', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
// Boot: the unpinned record is pruned as stale; then "restore" brings the session back.
await sync.remove('s1');
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
});
it('remove deletes a history it has not loaded without loading it', async () => {
const { sync, loads } = setup();
const other = new EditHistoryStore('s1', { dir });
other.addImageEntries([{ toolUseId: 'b1', path: '/w/a.png', ts: T0 }]);
await other.flush();
await sync.remove('s1');
expect(loads).toEqual([]);
expect(existsSync(join(dir, 's1.json'))).toBe(false);
});
it(`keeps at most ${MAX_LOADED_STORES} stores loaded: a dropped one is written and loads again whole`, async () => {
const { host, sync, loads } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
host.append('main', editLine('t1', T0, '/w/a.ts'));
const ids = Array.from({ length: MAX_LOADED_STORES + 2 }, (_, i) => `s${i}`);
for (const id of ids) await sync.sync({ ...local, id });
expect(loads).toEqual(ids);
// The two least recently used went: written (no debounced save ran), not kept in memory.
await sync.flushAll();
expect(existsSync(join(dir, 's0.json'))).toBe(true);
expect((await sync.getStore('s0')).list().map((e) => e.id)).toEqual(['t1']);
expect(loads.slice(ids.length)).toEqual(['s0']);
// The most recently used ones stay loaded.
await sync.getStore(ids[ids.length - 1]);
expect(loads.slice(ids.length)).toEqual(['s0']);
});
it('a dropped store that never changed writes nothing (no file for a session without edits)', async () => {
const { sync } = setup(); // no transcript: nothing to read
for (let i = 0; i <= MAX_LOADED_STORES; i++) await sync.sync({ ...local, id: `s${i}` });
await sync.flushAll();
expect(readdirSync(dir)).toEqual([]);
expect((await sync.getStore('s1')).list()).toEqual([]);
});
it('a store whose run is in flight is not dropped, and loads once', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { host, sync, loads } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
const orig = host.read;
let first = true;
host.read = async (...a) => {
if (first) {
first = false;
await gate;
}
return orig(...a);
};
const slow = sync.sync(local);
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
release();
expect(await slow).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
expect(loads.filter((id) => id === 's1')).toEqual(['s1']);
});
it('concurrent first callers share one store', async () => {
@@ -563,9 +647,16 @@ describe('reads only on request (wiring guard)', () => {
});
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
for (const file of ['src/web/session-listener-wiring.ts', 'src/web/routes/hook-event-routes.ts']) {
expect(read(file)).not.toMatch(/editHistory/i);
}
const root = join(__dirname, '..');
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
.filter((f) => /\.(ts|js)$/.test(f))
.map((f) => join('src', f).split('\\').join('/'))
.filter((f) => /EditHistorySync|editHistorySync/.test(read(f)));
expect(holders.sort()).toEqual([
'src/web/edit-history-sync.ts',
'src/web/routes/edit-history-routes.ts',
'src/web/server.ts',
]);
for (const file of [
'src/web/edit-history-sync.ts',
'src/edit-history-store.ts',
+13
View File
@@ -68,6 +68,7 @@ async function setup(
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...deps,
});
@@ -114,6 +115,18 @@ describe('GET /api/sessions/:id/edits', () => {
expect(data.entries[0]).not.toHaveProperty('hunks');
});
it('starts the sync before it loads the store (a deletion right after the session check stops the sync)', async () => {
const { app, sync } = await setup();
const calls: string[] = [];
const syncFn = sync.sync.bind(sync);
const getStore = sync.getStore.bind(sync);
vi.spyOn(sync, 'sync').mockImplementation((...a) => (calls.push('sync'), syncFn(...a)));
vi.spyOn(sync, 'getStore').mockImplementation((...a) => (calls.push('getStore'), getStore(...a)));
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
await app.inject({ method: 'POST', url: `${URL}/older` });
expect(calls).toEqual(['sync', 'getStore', 'sync', 'getStore']);
});
it('without sync=1 answers from the store and does not read', async () => {
const { app, read } = await setup();
const res = await app.inject({ method: 'GET', url: URL });