mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-15 10:59:32 +01:00
fix(story-mode): the reader cannot stall the server, and reads only real transcripts
From review round 7 (security): - Eviction past the per-transcript caps dropped entries one shift() at a time after the whole tail was parsed: quadratic and never yielding (a 2 s event-loop stall on a 70 MB long turn, 51 s from 300k entries). It is one splice, done at every 1 MB yield, so the peak stays near the caps; and a window that saw a turn start which was then trimmed away no longer widens to a bigger tail that would be trimmed the same. - A FIFO or a device in place of a transcript or a helper's .meta.json (stat said size 0, then open() waited for a writer) pinned a libuv worker per request; four of them hang every fs, dns and crypto call on the server. Files are now opened non-blocking, checked with the handle's own stat (a regular file only) and read through that handle. - The cache held 6 transcripts for everyone, so a seventh open view made every poll a full re-read, and a 304 was decided only after reading. An unchanged file is now a 304 from a stat alone, and the cache holds up to 24 transcripts within 30 million characters. - Story Mode is the first route to send a transcript's content to the browser, so the hook-reported path is used only when it really lies (symlinks resolved) in a Claude projects folder: the session's relocated CLAUDE_CONFIG_DIR (now also used for the lookup by id) or the server's. Anything else falls back to the lookup by conversation id. Tests move their transcripts into the (temp) projects folder and cover a path outside it, a symlink out of it, a FIFO transcript, a FIFO meta file and a 100,000-entry tail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
7aa7106cb4
commit
250a77d945
6 files changed
+258
-44
No files matched your search
@@ -2504,6 +2504,11 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
|
||||
/** The session's Claude config dir when it has been relocated (#255), else undefined. */
|
||||
/** The Claude config dir this session runs with (`CLAUDE_CONFIG_DIR` in its env overrides), if relocated. */
|
||||
get claudeConfigDir(): string | undefined {
|
||||
return this._claudeConfigDir();
|
||||
}
|
||||
|
||||
private _claudeConfigDir(): string | undefined {
|
||||
// Trimmed like `claudeProjectsDir()` trims the process-wide override: the
|
||||
// envOverrides schema validates keys only, and a whitespace-only value would
|
||||
|
||||
+114
-22
@@ -16,7 +16,8 @@
|
||||
* which file is read (story-routes.ts).
|
||||
*/
|
||||
|
||||
import { open, stat } from 'node:fs/promises';
|
||||
import { constants } from 'node:fs';
|
||||
import { open, stat, type FileHandle } from 'node:fs/promises';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
import { createStoryReducer, type StoryEntry, type StoryReducer } from './story-mode.js';
|
||||
|
||||
@@ -32,8 +33,9 @@ const TAIL_STEPS = [STORY_TAIL_BYTES, 24 * 1024 * 1024, 64 * 1024 * 1024];
|
||||
const MAX_ENTRIES = 20_000;
|
||||
/** Approximate characters kept per transcript across all entries. */
|
||||
const MAX_CHARS = 6_000_000;
|
||||
/** Transcripts cached at once. */
|
||||
const MAX_TRANSCRIPTS = 6;
|
||||
/** Transcripts cached at once, and their characters together (the oldest go first past either). */
|
||||
const MAX_TRANSCRIPTS = 24;
|
||||
const MAX_CACHE_CHARS = 30_000_000;
|
||||
|
||||
interface CacheEntry {
|
||||
sidechain: boolean;
|
||||
@@ -46,6 +48,8 @@ interface CacheEntry {
|
||||
reducer: StoryReducer;
|
||||
/** The read began mid-file, so the oldest turn may be cut. */
|
||||
partial: boolean;
|
||||
/** A turn started inside what was read (it may have been trimmed away since). */
|
||||
sawTurnStart: boolean;
|
||||
}
|
||||
|
||||
export interface StoryEntriesResult {
|
||||
@@ -62,26 +66,81 @@ const hasTurnStart = (entries: StoryEntry[]) => entries.some((e) => e.k === 'pro
|
||||
const cache = new LRUMap<string, CacheEntry>({ maxSize: MAX_TRANSCRIPTS });
|
||||
const inFlight = new Map<string, Promise<StoryEntriesResult>>();
|
||||
|
||||
/**
|
||||
* The version a read of `path` would report (size and mtime), from a stat only: lets the route answer
|
||||
* 304 for an unchanged page without reading or parsing anything, cached or not. Null for anything
|
||||
* but a regular file.
|
||||
*/
|
||||
export async function storyFileVersion(path: string): Promise<string | null> {
|
||||
try {
|
||||
const info = await stat(path);
|
||||
return info.isFile() ? versionOf(info.size, info.mtimeMs) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Evicts the least recently used transcripts while the cache holds more characters than its budget. */
|
||||
function keepCacheInBudget(keep: string): void {
|
||||
let total = 0;
|
||||
for (const e of cache.values()) total += e.chars;
|
||||
for (const key of cache.keysInOrder()) {
|
||||
if (total <= MAX_CACHE_CHARS) break;
|
||||
if (key === keep) continue;
|
||||
total -= cache.get(key)?.chars ?? 0;
|
||||
cache.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** For tests: forget every cached transcript. */
|
||||
export function clearStoryReaderCache(): void {
|
||||
cache.clear();
|
||||
inFlight.clear();
|
||||
}
|
||||
|
||||
async function readRange(path: string, start: number, end: number): Promise<Buffer> {
|
||||
/**
|
||||
* Opens `path` for reading only if it is a regular file, and keeps the handle: every later check and
|
||||
* read goes through it, never through the path again. Opened non-blocking, so a FIFO (or a symlink
|
||||
* to one) swapped in never pins a libuv worker waiting for a writer; a device or a FIFO is refused by
|
||||
* the handle's own stat.
|
||||
*/
|
||||
export async function openRegularFile(path: string): Promise<{ handle: FileHandle; size: number; mtimeMs: number }> {
|
||||
const handle = await open(path, constants.O_RDONLY | constants.O_NONBLOCK);
|
||||
try {
|
||||
const info = await handle.stat();
|
||||
if (!info.isFile()) throw new Error('Transcript is not a regular file');
|
||||
return { handle, size: info.size, mtimeMs: info.mtimeMs };
|
||||
} catch (err) {
|
||||
await handle.close();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function readRange(handle: FileHandle, start: number, end: number): Promise<Buffer> {
|
||||
const length = Math.max(0, end - start);
|
||||
const buf = Buffer.alloc(length);
|
||||
const handle = await open(path, 'r');
|
||||
let read = 0;
|
||||
while (read < length) {
|
||||
const { bytesRead } = await handle.read(buf, read, length - read, start + read);
|
||||
if (bytesRead === 0) break;
|
||||
read += bytesRead;
|
||||
}
|
||||
return read === length ? buf : buf.subarray(0, read);
|
||||
}
|
||||
|
||||
/** A small regular file read whole (a helper's `.meta.json`), or null when it is anything else or too big. */
|
||||
export async function readSmallRegularFile(path: string, maxBytes: number): Promise<string | null> {
|
||||
let opened: Awaited<ReturnType<typeof openRegularFile>>;
|
||||
try {
|
||||
let read = 0;
|
||||
while (read < length) {
|
||||
const { bytesRead } = await handle.read(buf, read, length - read, start + read);
|
||||
if (bytesRead === 0) break;
|
||||
read += bytesRead;
|
||||
}
|
||||
return read === length ? buf : buf.subarray(0, read);
|
||||
opened = await openRegularFile(path);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
if (opened.size > maxBytes) return null;
|
||||
return (await readRange(opened.handle, 0, opened.size)).toString('utf8');
|
||||
} finally {
|
||||
await handle.close();
|
||||
await opened.handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,25 +184,54 @@ async function ingest(entry: CacheEntry, buf: Buffer, startsMidLine: boolean): P
|
||||
for (const reduced of entry.reducer.reduce(parsed)) {
|
||||
entry.entries.push(reduced);
|
||||
entry.chars += entryChars(reduced);
|
||||
if (reduced.k === 'prompt' && !reduced.queued) entry.sawTurnStart = true;
|
||||
}
|
||||
}
|
||||
sinceYield += nl - pos + 1;
|
||||
pos = nl + 1;
|
||||
if (sinceYield >= YIELD_EVERY_BYTES) {
|
||||
sinceYield = 0;
|
||||
// Trimmed as it grows, so the peak stays near the caps, not the size of the window.
|
||||
trim(entry);
|
||||
await yieldToLoop();
|
||||
}
|
||||
}
|
||||
while (entry.entries.length > MAX_ENTRIES || (entry.chars > MAX_CHARS && entry.entries.length > 1)) {
|
||||
const dropped = entry.entries.shift();
|
||||
if (dropped) entry.chars -= entryChars(dropped);
|
||||
entry.partial = true;
|
||||
trim(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the oldest entries over the caps in ONE splice: dropping them one `shift()` at a time is
|
||||
* quadratic and never yields (seconds on a long turn's tail).
|
||||
*/
|
||||
function trim(entry: CacheEntry): void {
|
||||
const all = entry.entries;
|
||||
let drop = 0;
|
||||
let chars = entry.chars;
|
||||
while (all.length - drop > MAX_ENTRIES || (chars > MAX_CHARS && all.length - drop > 1)) {
|
||||
chars -= entryChars(all[drop]);
|
||||
drop++;
|
||||
}
|
||||
if (!drop) return;
|
||||
all.splice(0, drop);
|
||||
entry.chars = chars;
|
||||
entry.partial = true;
|
||||
}
|
||||
|
||||
async function load(path: string, sidechain: boolean): Promise<StoryEntriesResult> {
|
||||
const info = await stat(path);
|
||||
if (!info.isFile()) throw new Error('Transcript is not a regular file');
|
||||
const { handle, ...info } = await openRegularFile(path);
|
||||
try {
|
||||
return await loadFrom(handle, info, path, sidechain);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFrom(
|
||||
handle: FileHandle,
|
||||
info: { size: number; mtimeMs: number },
|
||||
path: string,
|
||||
sidechain: boolean
|
||||
): Promise<StoryEntriesResult> {
|
||||
const key = sidechain ? `${path}#sidechain` : path;
|
||||
const cached = cache.get(key);
|
||||
const version = versionOf(info.size, info.mtimeMs);
|
||||
@@ -157,11 +245,12 @@ async function load(path: string, sidechain: boolean): Promise<StoryEntriesResul
|
||||
info.size >= cached.size
|
||||
) {
|
||||
// The file only grew: read what was appended since the last complete line.
|
||||
const buf = await readRange(path, cached.offset, info.size);
|
||||
const buf = await readRange(handle, cached.offset, info.size);
|
||||
await ingest(cached, buf, false);
|
||||
cached.size = info.size;
|
||||
cached.mtimeMs = info.mtimeMs;
|
||||
cache.set(key, cached);
|
||||
keepCacheInBudget(key);
|
||||
return { entries: cached.entries, partial: cached.partial, version };
|
||||
}
|
||||
// First read, a rewrite, or more appended than a tail holds: start over from the tail, and take a
|
||||
@@ -179,12 +268,15 @@ async function load(path: string, sidechain: boolean): Promise<StoryEntriesResul
|
||||
chars: 0,
|
||||
reducer: createStoryReducer({ sidechain }),
|
||||
partial: start > 0,
|
||||
sawTurnStart: false,
|
||||
};
|
||||
await ingest(fresh, await readRange(path, start, info.size), start > 0);
|
||||
if (start === 0 || hasTurnStart(fresh.entries)) break;
|
||||
await ingest(fresh, await readRange(handle, start, info.size), start > 0);
|
||||
// A turn start seen and then trimmed away would be trimmed from a bigger tail too: stop there.
|
||||
if (start === 0 || fresh.sawTurnStart || hasTurnStart(fresh.entries)) break;
|
||||
}
|
||||
const done = fresh as CacheEntry;
|
||||
cache.set(key, done);
|
||||
keepCacheInBudget(key);
|
||||
return { entries: done.entries, partial: done.partial, version };
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { readFile, stat } from 'node:fs/promises';
|
||||
import { basename, dirname, join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { basename, dirname, join, sep } from 'node:path';
|
||||
import {
|
||||
ApiErrorCode,
|
||||
createErrorResponse,
|
||||
@@ -31,8 +31,8 @@ import {
|
||||
} from '../../types.js';
|
||||
import { getCli } from '../../config/cli-registry/registry.js';
|
||||
import { buildHelperPage, buildStoryPage, buildStoryTurns, summarizeTurns } from '../../story-mode.js';
|
||||
import { readStoryEntries } from '../../story-reader.js';
|
||||
import { findClaudeTranscriptPath } from '../../utils/claude-transcript.js';
|
||||
import { readSmallRegularFile, readStoryEntries, storyFileVersion } from '../../story-reader.js';
|
||||
import { claudeProjectsDir, findClaudeTranscriptPath } from '../../utils/claude-transcript.js';
|
||||
import { findSessionOrFail } from '../route-helpers.js';
|
||||
import type { ConfigPort, SessionPort } from '../ports/index.js';
|
||||
|
||||
@@ -44,10 +44,45 @@ const SERVER_EPOCH = Date.now().toString(36);
|
||||
/** A helper's `.meta.json` is a few hundred bytes; anything bigger is not one. */
|
||||
const MAX_META_BYTES = 64 * 1024;
|
||||
|
||||
/** The hook-reported path when it looks like a transcript, else a lookup by conversation id. */
|
||||
async function resolveTranscriptPath(hookPath: string | null, conversationId: string): Promise<string | null> {
|
||||
if (hookPath && hookPath.startsWith('/') && hookPath.endsWith('.jsonl')) return hookPath;
|
||||
return findClaudeTranscriptPath(conversationId);
|
||||
/**
|
||||
* Whether `path` really lies in a Claude projects folder (the session's relocated config dir, or the
|
||||
* server's): symlinks resolved on both sides. Story Mode sends a transcript's CONTENT to the
|
||||
* browser, so the hook-reported path is trusted only where Claude Code writes transcripts.
|
||||
*/
|
||||
async function insideClaudeProjects(path: string, configDir?: string): Promise<boolean> {
|
||||
let real: string;
|
||||
try {
|
||||
real = await realpath(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
for (const root of new Set([claudeProjectsDir(configDir), claudeProjectsDir()])) {
|
||||
let base = root;
|
||||
try {
|
||||
base = await realpath(root);
|
||||
} catch {
|
||||
// a projects folder that does not exist holds nothing
|
||||
continue;
|
||||
}
|
||||
if (real.startsWith(base.endsWith(sep) ? base : base + sep)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** The hook-reported path when it is a transcript in a projects folder, else a lookup by conversation id. */
|
||||
async function resolveTranscriptPath(
|
||||
hookPath: string | null,
|
||||
conversationId: string,
|
||||
configDir?: string
|
||||
): Promise<string | null> {
|
||||
if (
|
||||
hookPath &&
|
||||
hookPath.startsWith('/') &&
|
||||
hookPath.endsWith('.jsonl') &&
|
||||
(await insideClaudeProjects(hookPath, configDir))
|
||||
)
|
||||
return hookPath;
|
||||
return findClaudeTranscriptPath(conversationId, configDir);
|
||||
}
|
||||
|
||||
type Source = { unsupported: string } | { path: string | null };
|
||||
@@ -64,15 +99,23 @@ async function storySource(ctx: SessionPort & ConfigPort, id: string, req: Fasti
|
||||
'Story Mode reads transcripts on this machine. This session keeps its transcript on another host or inside a container.',
|
||||
};
|
||||
}
|
||||
return { path: await resolveTranscriptPath(ctx.getTranscriptPath(id), session.claudeSessionId || session.id) };
|
||||
return {
|
||||
path: await resolveTranscriptPath(
|
||||
ctx.getTranscriptPath(id),
|
||||
session.claudeSessionId || session.id,
|
||||
session.claudeConfigDir
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the ETag and answers true (after sending a 304) when the browser already holds this version.
|
||||
* A live view refetches every few seconds; while the file has not changed, nothing gets built.
|
||||
*/
|
||||
const etagOf = (tag: string) => `W/"story-${SERVER_EPOCH}-${tag}"`;
|
||||
|
||||
function notModified(req: FastifyRequest, reply: FastifyReply, tag: string): boolean {
|
||||
const etag = `W/"story-${SERVER_EPOCH}-${tag}"`;
|
||||
const etag = etagOf(tag);
|
||||
reply.header('ETag', etag).header('Cache-Control', 'no-cache');
|
||||
if (req.headers['if-none-match'] !== etag) return false;
|
||||
reply.code(304).send();
|
||||
@@ -81,8 +124,10 @@ function notModified(req: FastifyRequest, reply: FastifyReply, tag: string): boo
|
||||
|
||||
async function readHelperMeta(path: string): Promise<{ type?: string; description?: string; finished?: boolean }> {
|
||||
try {
|
||||
if ((await stat(path)).size > MAX_META_BYTES) return {};
|
||||
const meta = JSON.parse(await readFile(path, 'utf8')) as Record<string, unknown>;
|
||||
// A regular file only, read through one handle (a FIFO or a device here must not pin a worker).
|
||||
const text = await readSmallRegularFile(path, MAX_META_BYTES);
|
||||
if (text === null) return {};
|
||||
const meta = JSON.parse(text) as Record<string, unknown>;
|
||||
return {
|
||||
type: typeof meta.agentType === 'string' ? meta.agentType.slice(0, 80) : undefined,
|
||||
description: typeof meta.description === 'string' ? meta.description.slice(0, 300) : undefined,
|
||||
@@ -100,6 +145,12 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con
|
||||
const source = await storySource(ctx, id, req);
|
||||
if ('unsupported' in source) return { success: true, data: { supported: false, reason: source.unsupported } };
|
||||
if (!source.path) return { success: true, data: { supported: true, turns: [], page: null } };
|
||||
const turnKey = typeof turn === 'string' && turn ? turn : '';
|
||||
const tagOf = (version: string) => `${version}-${encodeURIComponent(turnKey || 'latest')}`;
|
||||
// An unchanged file is a 304 from a stat alone: no read, no parse, whatever the cache holds.
|
||||
const before = await storyFileVersion(source.path);
|
||||
if (before && req.headers['if-none-match'] === etagOf(tagOf(before)) && notModified(req, reply, tagOf(before)))
|
||||
return;
|
||||
|
||||
let read;
|
||||
try {
|
||||
@@ -108,8 +159,7 @@ export function registerStoryRoutes(app: FastifyInstance, ctx: SessionPort & Con
|
||||
reply.code(500);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Could not read the session transcript');
|
||||
}
|
||||
const turnKey = typeof turn === 'string' && turn ? turn : '';
|
||||
if (notModified(req, reply, `${read.version}-${encodeURIComponent(turnKey || 'latest')}`)) return;
|
||||
if (notModified(req, reply, tagOf(read.version))) return;
|
||||
|
||||
const turns = buildStoryTurns(read.entries);
|
||||
const wanted = turnKey ? turns.find((t) => t.id === turnKey) : turns[turns.length - 1];
|
||||
|
||||
Reference in new issue
Block a user