Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ upstream release: 1.0.8). Format follows [Keep a Changelog](https://keepachangel

## [Unreleased]

### Changed
- **Manifest scans stat files concurrently (#99)** — every refresh statted the
whole history one file at a time; through the Windows filter stack that is
the difference between a stalled and an invisible refresh when the system is
busy. Results stay in discovery order.

## [2.5.0] — 2026-10-15

Version 2.5.0 combines local achievements and Codex compressed-log compatibility.
Expand Down
27 changes: 22 additions & 5 deletions src/claudeUsageFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,21 +70,29 @@ export async function scanUsageManifest(
await walkJsonl(path.join(root, 'projects'), paths, io);
}
const entries = new Map<string, UsageFileFingerprint>();
for (const [discoveryIndex, filePath] of paths.entries()) {
// One stat per file, but fanned out: on Windows each stat is a round trip
// through the file system filter stack (antivirus included), so a sequential
// loop over a large history costs seconds on every single refresh.
// mapWithConcurrency keeps the result in discovery order.
const fingerprints = await mapWithConcurrency(paths, 8, async (filePath, discoveryIndex) => {
try {
const fileStat = await io.stat(filePath);
const hasStableIdentity = fileStat.dev > 0 && fileStat.ino > 0;
entries.set(filePath, {
return {
path: filePath,
size: fileStat.size,
mtimeMs: fileStat.mtimeMs,
discoveryIndex,
dev: hasStableIdentity ? fileStat.dev : undefined,
ino: hasStableIdentity ? fileStat.ino : undefined,
});
};
} catch (error) {
if (!isMissingPathError(error)) throw error;
return null;
}
});
for (const fingerprint of fingerprints) {
if (fingerprint) entries.set(fingerprint.path, fingerprint);
}
return {
entries,
Expand Down Expand Up @@ -130,10 +138,19 @@ export async function mapWithConcurrency<T, R>(
}
const output = new Array<R>(values.length);
let next = 0;
let failed = false;
const run = async (): Promise<void> => {
while (next < values.length) {
while (next < values.length && !failed) {
const index = next++;
output[index] = await worker(values[index], index);
try {
output[index] = await worker(values[index], index);
} catch (error) {
// A fatal error rejects the scan. Workers already inside a call may
// settle, but nothing further is dequeued: the rest of a failed
// history must not keep running after the caller has moved on.
failed = true;
throw error;
}
}
};
await Promise.all(Array.from({ length: Math.min(limit, values.length) }, run));
Expand Down
78 changes: 78 additions & 0 deletions src/test/claudeUsageFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,84 @@ test('manifest scanning fails closed on permission errors but tolerates stat ENO
assert.equal(missing.entries.size, 0);
});

test('manifest scanning stats files concurrently and keeps discovery order', async () => {
const { root, file } = await fixture();
const project = path.dirname(file);
const names = Array.from({ length: 20 }, (_, index) => `session-${String(index).padStart(2, '0')}.jsonl`);
for (const name of names) {
await writeFile(path.join(project, name), '{"timestamp":"2026-01-01T00:00:00.000Z"}\n');
}
let inFlight = 0;
let maxInFlight = 0;
const pending: Array<() => void> = [];
const manifestPromise = scanUsageManifest([root], {
readdir: (dir: string) => readdir(dir, { withFileTypes: true }),
stat: (filePath: string) => new Promise((resolve) => {
inFlight += 1;
maxInFlight = Math.max(maxInFlight, inFlight);
pending.push(() => {
inFlight -= 1;
resolve(stat(filePath));
});
}),
});
const waitFor = async (ready: () => boolean): Promise<void> => {
for (let attempt = 0; attempt < 50 && !ready(); attempt += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
assert.ok(ready(), 'the scan did not reach the expected in-flight state');
};
await waitFor(() => pending.length === 8);
assert.equal(maxInFlight, 8, 'the eight-worker ceiling is reached, not exceeded');
// Releasing one worker must admit exactly one more, and never past the ceiling.
pending.pop()!();
await waitFor(() => pending.length === 8);
assert.equal(maxInFlight, 8);
let settled = false;
const manifest = manifestPromise.finally(() => { settled = true; });
while (!settled) {
if (pending.length === 0) {
await new Promise((resolve) => setImmediate(resolve));
continue;
}
// Reverse of start order, so a completion-order leak cannot pass as discovery order.
pending.pop()!();
}
assert.deepEqual(
[...(await manifest).entries.keys()],
names.map((name) => path.join(project, name)),
);
});

test('a fatal stat stops scheduling files that have not started', async () => {
const { root, file } = await fixture();
const project = path.dirname(file);
const names = Array.from({ length: 40 }, (_, index) => `session-${String(index).padStart(2, '0')}.jsonl`);
for (const name of names) {
await writeFile(path.join(project, name), '{"timestamp":"2026-01-01T00:00:00.000Z"}\n');
}
let started = 0;
const blockers: Array<() => void> = [];
const scan = scanUsageManifest([root], {
readdir: (dir: string) => readdir(dir, { withFileTypes: true }),
stat: async () => {
const call = started;
started += 1;
if (call === 0) {
throw Object.assign(new Error('denied'), { code: 'EACCES' });
}
await new Promise<void>((resolve) => blockers.push(resolve));
return stat(file);
},
});
await assert.rejects(scan, (error: NodeJS.ErrnoException) => error.code === 'EACCES');
const startedAtRejection = started;
await new Promise((resolve) => setImmediate(resolve));
assert.ok(startedAtRejection <= 8, `scheduled ${startedAtRejection} stats after a fatal error`);
assert.equal(started, startedAtRejection, 'no stat starts after the scan has rejected');
for (const release of blockers) release();
});

test('manifest diff reports changed, reused, and removed without exposing them in counters', async () => {
const { root, file } = await fixture();
await writeFile(file, '{"timestamp":"2026-01-01T00:00:00.000Z"}\n');
Expand Down
Loading