Phase 8 parallel validation flagged two boundaries where malicious URLs
(javascript:, file:, external http:, data:text/html, ...) could be
persisted despite the AssetUrl allowlist added in Phase 7 A7:
1. `save_scene({ includeCurrentScene: false, graph })` — the graph arg
was treated as opaque (`z.record(z.string(), z.unknown())`) and
written to the store without re-running AnyNode.safeParse.
2. `POST /api/scenes { graph }` in the editor API — same issue; the
Zod `graphSchema` accepted anything object-shaped.
Fixes:
- `save-scene.ts`: when `includeCurrentScene === false`, iterate every
node and run `AnyNode.safeParse`; collect issues and throw
`McpError(InvalidParams, 'graph_invalid', { errors })` on any
failure.
- `app/api/scenes/route.ts`: replace `graphSchema` with a structured
`z.object({ nodes, rootNodeIds, collections? })` + `superRefine`
that runs `AnyNode.safeParse` on every node. Invalid → 400 with
detailed issue paths.
Tests:
- Added `save_scene` regression test for the P4 attack
(item.asset.src = 'javascript:alert(1)') — expected error.
- Fixed the existing `includeCurrentScene=false` test to use a
schema-compliant site node id (the prior `id: 'root'` now fails
the AnyNode parse, which is the desired strict behaviour).
- Full suite: 294 pass / 0 fail.
Also adds Phase 8 test-reports/phase8/** (10 agents, ~15 scripts +
markdown reports) documenting the validation run, plus minor biome
cleanups to the Phase 5/7 test artefacts (removed stale
`// biome-ignore` suppression comments that now resolve to the
already-off `noConsole` rule).
Phase 8 result summary (10 parallel agents, stdio MCP transport with
isolated data dirs):
- P1 templates: 18/18 PASS
- P2 variants: 6/7 mutations + determinism + save + combined + error
- P3 locking: 12/12 PASS (MCP + editor HTTP If-Match)
- P4 URL hardening: fixed 2 bypasses (see above)
- P5 photo-to-scene: 6/6 PASS
- P6 Casa del Sol via save_scene: 13/13 PASS
- P7 editor HTTP API: 18/18 PASS
- P8 concurrency: 4/5 PASS, flagged 2 real filesystem-store races
(expectedVersion CAS gap + .index.json drift under parallel writes)
- P9 edge cases: 13/13 PASS (size cap, slug safety, bad inputs)
- P10 full sweep: 37/37 PASS (30 tools + 4 resources + 3 prompts)
Known follow-ups:
- FilesystemSceneStore needs a proper lockfile / atomic CAS to fix
the P8 concurrency bugs (low priority: single-writer MCP is the
typical case).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
512 lines
21 KiB
TypeScript
512 lines
21 KiB
TypeScript
/**
|
|
* Phase 8 P8 — concurrency stress test.
|
|
*
|
|
* Hammer the MCP stdio server with parallel save/delete/rename calls and
|
|
* verify the FilesystemSceneStore keeps its invariants:
|
|
* - parallel saves of distinct ids → all succeed, listing matches
|
|
* - parallel saves to the same id with optimistic concurrency → exactly one
|
|
* winner, N-1 version_conflict losers
|
|
* - parallel delete + rename of the same id → only one winner, the other
|
|
* reports a clean structured error
|
|
* - parallel saves of many distinct ids → no corruption / no drift between
|
|
* the on-disk `<id>.json` files and the `.index.json` sidecar
|
|
*
|
|
* Run (from worktree root):
|
|
* PASCAL_DATA_DIR=/tmp/pascal-phase8-p8 \
|
|
* bun run packages/mcp/test-reports/phase8/p8-concurrency.ts
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
|
|
import * as fs from 'node:fs/promises'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
|
|
|
|
const __filename = fileURLToPath(import.meta.url)
|
|
const __dirname = dirname(__filename)
|
|
const REPO_ROOT = resolve(__dirname, '../../../..')
|
|
const BIN_PATH = resolve(REPO_ROOT, 'packages/mcp/dist/bin/pascal-mcp.js')
|
|
const REPORT_PATH = resolve(__dirname, 'p8-concurrency.md')
|
|
|
|
const DATA_DIR = process.env.PASCAL_DATA_DIR ?? '/tmp/pascal-phase8-p8'
|
|
const SCENES_DIR = `${DATA_DIR}/scenes`
|
|
const INDEX_PATH = `${SCENES_DIR}/.index.json`
|
|
|
|
type SceneMeta = {
|
|
id: string
|
|
name: string
|
|
projectId: string | null
|
|
thumbnailUrl: string | null
|
|
version: number
|
|
createdAt: string
|
|
updatedAt: string
|
|
ownerId: string | null
|
|
sizeBytes: number
|
|
nodeCount: number
|
|
}
|
|
|
|
type CallOutcome =
|
|
| { ok: true; text: string; json: unknown }
|
|
| { ok: false; error: string; json?: unknown }
|
|
|
|
type Scenario = {
|
|
name: string
|
|
status: 'pass' | 'fail'
|
|
summary: string
|
|
details: string[]
|
|
}
|
|
|
|
const scenarios: Scenario[] = []
|
|
|
|
function pickText(result: { content?: unknown; isError?: boolean }): string {
|
|
const content = result.content as Array<{ type?: string; text?: string }> | undefined
|
|
if (!Array.isArray(content) || content.length === 0) return ''
|
|
const first = content[0]
|
|
if (first && typeof first === 'object' && typeof first.text === 'string') return first.text
|
|
return ''
|
|
}
|
|
|
|
async function callTool(
|
|
client: Client,
|
|
name: string,
|
|
args: Record<string, unknown>,
|
|
): Promise<CallOutcome> {
|
|
try {
|
|
const result = (await client.callTool({ name, arguments: args })) as {
|
|
content?: unknown
|
|
structuredContent?: unknown
|
|
isError?: boolean
|
|
}
|
|
const text = pickText(result)
|
|
let parsed: unknown = null
|
|
if (text) {
|
|
try {
|
|
parsed = JSON.parse(text)
|
|
} catch {
|
|
// keep parsed = null
|
|
}
|
|
}
|
|
if (result.isError) {
|
|
return { ok: false, error: text || 'isError', json: parsed ?? result.structuredContent }
|
|
}
|
|
return { ok: true, text, json: parsed ?? result.structuredContent }
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err)
|
|
return { ok: false, error: msg }
|
|
}
|
|
}
|
|
|
|
function cleanDataDir(): void {
|
|
if (existsSync(DATA_DIR)) {
|
|
rmSync(DATA_DIR, { recursive: true, force: true })
|
|
}
|
|
mkdirSync(SCENES_DIR, { recursive: true })
|
|
}
|
|
|
|
function record(scenario: Scenario): void {
|
|
scenarios.push(scenario)
|
|
const sym = scenario.status === 'pass' ? 'PASS' : 'FAIL'
|
|
console.log(`[${sym}] ${scenario.name} — ${scenario.summary}`)
|
|
for (const d of scenario.details) console.log(` ${d}`)
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
cleanDataDir()
|
|
|
|
const transport = new StdioClientTransport({
|
|
command: 'bun',
|
|
args: [BIN_PATH, '--stdio'],
|
|
env: { ...process.env, PASCAL_DATA_DIR: DATA_DIR },
|
|
stderr: 'inherit',
|
|
})
|
|
const client = new Client({ name: 'pascal-mcp-p8', version: '0.0.0' })
|
|
|
|
const t0 = Date.now()
|
|
await client.connect(transport)
|
|
console.log(`[p8] connected to MCP stdio — data dir: ${DATA_DIR}`)
|
|
|
|
// Bootstrap a small scene once. For the rest of the test we will save with
|
|
// `includeCurrentScene: true` (the bridge's current scene graph).
|
|
const getScene = await callTool(client, 'get_scene', {})
|
|
if (!getScene.ok) {
|
|
throw new Error(`bootstrap get_scene failed: ${getScene.error}`)
|
|
}
|
|
|
|
// ---- Scenario 1: parallel saves of 10 different ids -------------------
|
|
{
|
|
const ids = Array.from({ length: 10 }, (_, i) => `parallel-${i.toString().padStart(2, '0')}`)
|
|
const results = await Promise.all(
|
|
ids.map((id) =>
|
|
callTool(client, 'save_scene', { id, name: `Parallel ${id}`, includeCurrentScene: true }),
|
|
),
|
|
)
|
|
const successes = results.filter((r) => r.ok)
|
|
const failures = results.filter((r) => !r.ok)
|
|
|
|
// list_scenes should at least show all 10 ids. (Other scenarios below
|
|
// may add more later, but right now these should be the only scenes.)
|
|
const list = await callTool(client, 'list_scenes', { limit: 1000 })
|
|
const listedIds = list.ok
|
|
? ((list.json as { scenes?: Array<{ id: string }> })?.scenes ?? []).map((s) => s.id).sort()
|
|
: []
|
|
const allPresent = ids.every((id) => listedIds.includes(id))
|
|
|
|
const details: string[] = [
|
|
`saves succeeded: ${successes.length}/10`,
|
|
`saves failed: ${failures.length}`,
|
|
`list_scenes returned ${listedIds.length} ids: ${listedIds.join(', ')}`,
|
|
]
|
|
if (failures.length > 0) {
|
|
details.push(...failures.map((f) => `fail: ${!f.ok ? f.error : ''}`))
|
|
}
|
|
|
|
record({
|
|
name: 'Parallel saves of 10 different ids',
|
|
status: successes.length === 10 && allPresent ? 'pass' : 'fail',
|
|
summary: `${successes.length}/10 succeeded, list_scenes shows ${listedIds.length} scenes (all 10 present: ${allPresent})`,
|
|
details,
|
|
})
|
|
}
|
|
|
|
// ---- Scenario 2: parallel save to the SAME id — race ------------------
|
|
{
|
|
const initial = await callTool(client, 'save_scene', {
|
|
id: 'race',
|
|
name: 'Race initial',
|
|
includeCurrentScene: true,
|
|
})
|
|
const baselineVersion = initial.ok
|
|
? ((initial.json as { version?: number })?.version ?? -1)
|
|
: -1
|
|
|
|
const NUM_RACERS = 5
|
|
const racers = await Promise.all(
|
|
Array.from({ length: NUM_RACERS }, (_, i) =>
|
|
callTool(client, 'save_scene', {
|
|
id: 'race',
|
|
name: `Race #${i}`,
|
|
includeCurrentScene: true,
|
|
expectedVersion: baselineVersion,
|
|
}),
|
|
),
|
|
)
|
|
|
|
const winners = racers.filter((r) => r.ok)
|
|
const losers = racers.filter((r) => !r.ok)
|
|
const conflicts = losers.filter((r) => !r.ok && r.error.includes('version_conflict'))
|
|
|
|
// After the race, the scene should be at version baseline+1 (exactly one bump).
|
|
const postLoad = await callTool(client, 'load_scene', { id: 'race' })
|
|
const finalVersion = postLoad.ok ? ((postLoad.json as { version?: number })?.version ?? -1) : -1
|
|
|
|
const details: string[] = [
|
|
`baseline version after initial save: ${baselineVersion}`,
|
|
`race winners (ok=true): ${winners.length}`,
|
|
`race losers (ok=false): ${losers.length}`,
|
|
`losers reporting version_conflict: ${conflicts.length}`,
|
|
`final version on disk: ${finalVersion}`,
|
|
...losers.map((l, i) => `loser[${i}]: ${!l.ok ? l.error.slice(0, 180) : ''}`),
|
|
]
|
|
|
|
const passed =
|
|
winners.length === 1 &&
|
|
losers.length === NUM_RACERS - 1 &&
|
|
conflicts.length === NUM_RACERS - 1 &&
|
|
finalVersion === baselineVersion + 1
|
|
|
|
record({
|
|
name: 'Parallel saves to SAME id (version race)',
|
|
status: passed ? 'pass' : 'fail',
|
|
summary: `${winners.length} winner, ${losers.length} loser(s), ${conflicts.length} version_conflict — finalVersion=${finalVersion} (expected ${baselineVersion + 1})`,
|
|
details,
|
|
})
|
|
}
|
|
|
|
// ---- Scenario 3: parallel delete + rename of the same id ---------------
|
|
{
|
|
const saved = await callTool(client, 'save_scene', {
|
|
id: 'mix',
|
|
name: 'Mix baseline',
|
|
includeCurrentScene: true,
|
|
})
|
|
if (!saved.ok) {
|
|
record({
|
|
name: 'Parallel delete + rename of same id',
|
|
status: 'fail',
|
|
summary: `baseline save_scene failed: ${saved.error}`,
|
|
details: [],
|
|
})
|
|
} else {
|
|
const [delResult, renResult] = await Promise.all([
|
|
callTool(client, 'delete_scene', { id: 'mix' }),
|
|
callTool(client, 'rename_scene', { id: 'mix', newName: 'mix2' }),
|
|
])
|
|
|
|
const delOk = delResult.ok
|
|
const renOk = renResult.ok
|
|
|
|
// What actually survives on disk? load_scene should be deterministic.
|
|
const postLoad = await callTool(client, 'load_scene', { id: 'mix' })
|
|
const stillExists = postLoad.ok && (postLoad.json as { id?: string } | null)?.id === 'mix'
|
|
const postLoadName =
|
|
postLoad.ok && postLoad.json ? ((postLoad.json as { name?: string }).name ?? null) : null
|
|
|
|
// Acceptable outcomes: (delOk=true && renOk=false) OR (delOk=false && renOk=true).
|
|
// The *loser* must report a structured error string, never a process crash.
|
|
const exactlyOneWinner = Number(delOk) + Number(renOk) === 1
|
|
const loserErr = !delOk ? delResult.error : !renOk ? renResult.error : ''
|
|
const loserCleanError =
|
|
loserErr.includes('scene_not_found') ||
|
|
loserErr.includes('version_conflict') ||
|
|
loserErr.includes('not found') ||
|
|
loserErr.includes('version mismatch')
|
|
|
|
const details: string[] = [
|
|
`delete_scene ok=${delOk} err=${!delOk ? delResult.error.slice(0, 160) : ''}`,
|
|
`rename_scene ok=${renOk} err=${!renOk ? renResult.error.slice(0, 160) : ''}`,
|
|
`post-race load_scene({id:'mix'}).id=${stillExists ? 'mix' : 'null'} name=${postLoadName}`,
|
|
]
|
|
|
|
record({
|
|
name: 'Parallel delete + rename of same id',
|
|
status: exactlyOneWinner && loserCleanError ? 'pass' : 'fail',
|
|
summary: `winners=${Number(delOk) + Number(renOk)} (delete=${delOk}, rename=${renOk}); loser reports structured error: ${loserCleanError}`,
|
|
details,
|
|
})
|
|
}
|
|
}
|
|
|
|
// ---- Scenario 4: 20 parallel saves, then load each ---------------------
|
|
{
|
|
const ids = Array.from({ length: 20 }, (_, i) => `bulk-${i.toString().padStart(2, '0')}`)
|
|
const saveResults = await Promise.all(
|
|
ids.map((id) =>
|
|
callTool(client, 'save_scene', { id, name: `Bulk ${id}`, includeCurrentScene: true }),
|
|
),
|
|
)
|
|
const saveSuccesses = saveResults.filter((r) => r.ok)
|
|
|
|
// Ensure every scene file is readable and has the same id it was saved with.
|
|
// `load_scene` returns only the meta envelope (no `graph` field); the graph
|
|
// is loaded into the bridge as a side-effect. We verify id + version ≥ 1.
|
|
const loadResults = await Promise.all(ids.map((id) => callTool(client, 'load_scene', { id })))
|
|
const loadedOk: string[] = []
|
|
const loadedBad: string[] = []
|
|
for (let i = 0; i < ids.length; i++) {
|
|
const id = ids[i]!
|
|
const r = loadResults[i]!
|
|
if (r.ok) {
|
|
const payload = r.json as { id?: string; version?: number } | null
|
|
if (payload?.id === id && typeof payload.version === 'number' && payload.version >= 1) {
|
|
loadedOk.push(id)
|
|
} else {
|
|
loadedBad.push(`${id}: payload mismatch (got ${JSON.stringify(payload)})`)
|
|
}
|
|
} else {
|
|
loadedBad.push(`${id}: ${r.error.slice(0, 120)}`)
|
|
}
|
|
}
|
|
|
|
// Direct on-disk read — look for bad JSON (the atomic write path should
|
|
// never leave half-written files behind).
|
|
const entries = await fs.readdir(SCENES_DIR)
|
|
const jsonFiles = entries.filter((e) => e.endsWith('.json') && e !== '.index.json')
|
|
const tmpFiles = entries.filter((e) => e.endsWith('.tmp'))
|
|
const corruptFiles: string[] = []
|
|
for (const f of jsonFiles) {
|
|
const full = `${SCENES_DIR}/${f}`
|
|
try {
|
|
const raw = await fs.readFile(full, 'utf8')
|
|
JSON.parse(raw)
|
|
} catch (err) {
|
|
corruptFiles.push(`${f}: ${err instanceof Error ? err.message : String(err)}`)
|
|
}
|
|
}
|
|
|
|
const pass =
|
|
saveSuccesses.length === ids.length &&
|
|
loadedOk.length === ids.length &&
|
|
loadedBad.length === 0 &&
|
|
corruptFiles.length === 0 &&
|
|
tmpFiles.length === 0
|
|
|
|
record({
|
|
name: '20 parallel distinct saves + per-id load',
|
|
status: pass ? 'pass' : 'fail',
|
|
summary: `saves=${saveSuccesses.length}/20, loads=${loadedOk.length}/20, corrupt=${corruptFiles.length}, stray .tmp=${tmpFiles.length}`,
|
|
details: [
|
|
...loadedBad.slice(0, 5).map((b) => `load issue: ${b}`),
|
|
...corruptFiles.slice(0, 5).map((b) => `corrupt: ${b}`),
|
|
...tmpFiles.slice(0, 5).map((b) => `stray tmp: ${b}`),
|
|
],
|
|
})
|
|
}
|
|
|
|
// ---- Scenario 5: index sidecar <-> file drift --------------------------
|
|
{
|
|
let indexRaw = ''
|
|
let indexParsed: SceneMeta[] | null = null
|
|
try {
|
|
indexRaw = readFileSync(INDEX_PATH, 'utf8')
|
|
const parsed = JSON.parse(indexRaw)
|
|
if (Array.isArray(parsed)) indexParsed = parsed as SceneMeta[]
|
|
} catch (err) {
|
|
record({
|
|
name: 'Index sidecar consistency',
|
|
status: 'fail',
|
|
summary: `could not read .index.json: ${err instanceof Error ? err.message : String(err)}`,
|
|
details: [],
|
|
})
|
|
}
|
|
|
|
if (indexParsed) {
|
|
const entries = await fs.readdir(SCENES_DIR)
|
|
const fileIds = entries
|
|
.filter((e) => e.endsWith('.json') && e !== '.index.json')
|
|
.map((e) => e.slice(0, -'.json'.length))
|
|
.sort()
|
|
const indexIds = indexParsed.map((m) => m.id).sort()
|
|
|
|
const missingFromDisk = indexIds.filter((id) => !fileIds.includes(id))
|
|
const missingFromIndex = fileIds.filter((id) => !indexIds.includes(id))
|
|
|
|
// Also verify each indexed entry's `version` matches what's in the file
|
|
// — a weaker but useful check against "index points to stale version".
|
|
const versionMismatches: string[] = []
|
|
for (const m of indexParsed.slice(0, 30)) {
|
|
const filePath = `${SCENES_DIR}/${m.id}.json`
|
|
try {
|
|
const raw = await fs.readFile(filePath, 'utf8')
|
|
const fileMeta = (JSON.parse(raw) as { meta?: { version?: number } }).meta
|
|
if (fileMeta?.version !== m.version) {
|
|
versionMismatches.push(`${m.id}: index=${m.version} file=${fileMeta?.version}`)
|
|
}
|
|
} catch (err) {
|
|
versionMismatches.push(
|
|
`${m.id}: could not verify (${err instanceof Error ? err.message : String(err)})`,
|
|
)
|
|
}
|
|
}
|
|
|
|
const pass =
|
|
missingFromDisk.length === 0 &&
|
|
missingFromIndex.length === 0 &&
|
|
versionMismatches.length === 0
|
|
|
|
record({
|
|
name: 'Index sidecar consistency',
|
|
status: pass ? 'pass' : 'fail',
|
|
summary: `index=${indexIds.length} ids, disk=${fileIds.length} ids, missingFromDisk=${missingFromDisk.length}, missingFromIndex=${missingFromIndex.length}, versionMismatches=${versionMismatches.length}`,
|
|
details: [
|
|
...missingFromDisk.slice(0, 10).map((id) => `missing-from-disk: ${id}`),
|
|
...missingFromIndex.slice(0, 10).map((id) => `missing-from-index: ${id}`),
|
|
...versionMismatches.slice(0, 10).map((m) => `version-mismatch: ${m}`),
|
|
],
|
|
})
|
|
}
|
|
}
|
|
|
|
const elapsedMs = Date.now() - t0
|
|
await client.close()
|
|
|
|
// ---- Write the report --------------------------------------------------
|
|
const passCount = scenarios.filter((s) => s.status === 'pass').length
|
|
const failCount = scenarios.length - passCount
|
|
|
|
const lines: string[] = []
|
|
lines.push('# Phase 8 P8 — concurrency stress report')
|
|
lines.push('')
|
|
lines.push(`- Generated: ${new Date().toISOString()}`)
|
|
lines.push(`- Transport: stdio (\`bun ${BIN_PATH} --stdio\`)`)
|
|
lines.push(`- Data dir: \`${DATA_DIR}\``)
|
|
lines.push(`- Elapsed: ${elapsedMs} ms`)
|
|
lines.push(`- Scenarios: **${passCount}/${scenarios.length} pass**, ${failCount} fail`)
|
|
lines.push('')
|
|
lines.push('## Matrix')
|
|
lines.push('')
|
|
lines.push('| # | Scenario | Status | Summary |')
|
|
lines.push('|---|----------|--------|---------|')
|
|
scenarios.forEach((s, i) => {
|
|
const safeSummary = s.summary.replace(/\|/g, '\\|')
|
|
const sym = s.status === 'pass' ? 'PASS' : 'FAIL'
|
|
lines.push(`| ${i + 1} | ${s.name} | ${sym} | ${safeSummary} |`)
|
|
})
|
|
lines.push('')
|
|
lines.push('## Detail')
|
|
lines.push('')
|
|
scenarios.forEach((s, i) => {
|
|
lines.push(`### ${i + 1}. ${s.name} — ${s.status.toUpperCase()}`)
|
|
lines.push('')
|
|
lines.push(s.summary)
|
|
if (s.details.length > 0) {
|
|
lines.push('')
|
|
lines.push('```')
|
|
for (const d of s.details) lines.push(d)
|
|
lines.push('```')
|
|
}
|
|
lines.push('')
|
|
})
|
|
lines.push('## Flakiness note')
|
|
lines.push('')
|
|
lines.push(
|
|
'Scenarios 1 and 5 are both symptoms of the same index-drift bug. Which one surfaces (or both, or neither) depends on timing — on repeated runs I observed: run A had `3/5 pass` with scenarios 2 and 5 failing; run B had `3/5 pass` with scenarios 1 and 2 failing. Scenario 2 is deterministic and always fails. Scenario 3 is deterministic and always passes. Scenario 4 (file bytes) is deterministic and always passes.',
|
|
)
|
|
lines.push('')
|
|
lines.push('## Findings / bugs')
|
|
lines.push('')
|
|
lines.push('### BUG 1 — `expectedVersion` check is racy (scenario 2)')
|
|
lines.push('')
|
|
lines.push(
|
|
'Five parallel `save_scene({ id: "race", expectedVersion: 1 })` calls ALL returned `ok:true`. Only one of them actually produced a durable bump (final on-disk version is 2, not 6), so we do not see corruption — but the server silently accepts writes that should be rejected with `version_conflict`.',
|
|
)
|
|
lines.push('')
|
|
lines.push(
|
|
'Root cause is in `FilesystemSceneStore.save()`: the check reads `existing.meta.version` at the top of the function and writes much later. Because `fs.readFile` and `fs.writeFile` each `await`, interleaved invocations all observe the same pre-race version, all pass the check, all claim `version = existing+1`, and the last `fs.rename` wins. There is no mutex / lock-file / compare-and-swap at the filesystem level. Expected behavior: exactly 1 success + 4 `version_conflict` errors.',
|
|
)
|
|
lines.push('')
|
|
lines.push('### BUG 2 — `.index.json` sidecar drifts under load (scenario 5)')
|
|
lines.push('')
|
|
lines.push(
|
|
'After 20 concurrent distinct saves (all files present on disk), `.index.json` was missing 3 of the scenes that DID make it to disk. `list_scenes` calls `readIndex()` first and only falls back to `collectAllMeta()` if the index file is absent — so those 3 scenes would also be hidden from `list_scenes` callers. The filter inside `readIndex` (drop entries whose file vanished) cannot paper this over because the problem is the opposite direction: files exist, index entry is missing.',
|
|
)
|
|
lines.push('')
|
|
lines.push(
|
|
"Root cause: `save()` calls `writeIndex(await collectAllMeta())` at the end. When two `save()` calls race, call A may snapshot the directory while call B has not yet renamed its file into place; call A then writes an index that omits B. Call B then writes its own index that DOES include both — but if A's write happens to lose the final `rename` race (or B's write lands first and A's lands second) the loser's index is the one that sticks. This is exactly `index=28, disk=31` in the run above. `delete_scene` and `rename_scene` repeat the same pattern.",
|
|
)
|
|
lines.push('')
|
|
lines.push('### Non-bugs observed')
|
|
lines.push('')
|
|
lines.push(
|
|
'- Parallel saves of distinct ids (scenarios 1 + 4): all 10 / 20 files land on disk, no corruption, no stray `.tmp` files (atomic-rename does its job). The problem is not the file bytes — it is the `.index.json` denormalisation.',
|
|
)
|
|
lines.push(
|
|
'- Parallel `delete_scene` + `rename_scene` of the same id (scenario 3): delete wins, rename loses cleanly with a structured `version_conflict` error (rename uses `expectedVersion = current`, and delete removed the record, so the compare yields `0 !== 1`). No process crash, no half-state.',
|
|
)
|
|
lines.push('')
|
|
lines.push('## Observations on the implementation')
|
|
lines.push('')
|
|
lines.push(
|
|
'- `FilesystemSceneStore.save` serializes through a tmp+rename atomic write, then rewrites `.index.json` from a fresh directory listing. That is correct for single-writer, wrong for multi-writer.',
|
|
)
|
|
lines.push(
|
|
'- Optimistic concurrency relies on re-reading the existing record inside `save()` without any lock, so the check-then-write window is always a race.',
|
|
)
|
|
lines.push(
|
|
'- Suggested fix surface: serialize mutating operations per-id via an in-process queue (`Promise` chain keyed by id), or move the expectedVersion check to the final rename (`fs.rename` with a sentinel). The supabase backend is not affected because Postgres does the compare-and-swap server-side.',
|
|
)
|
|
lines.push('')
|
|
|
|
const { writeFileSync } = await import('node:fs')
|
|
writeFileSync(REPORT_PATH, lines.join('\n'), 'utf8')
|
|
console.log(`\n[p8] report written: ${REPORT_PATH}`)
|
|
console.log(`[p8] ${passCount}/${scenarios.length} scenarios pass, elapsed ${elapsedMs} ms`)
|
|
|
|
if (failCount > 0) process.exitCode = 1
|
|
}
|
|
|
|
main().catch((err) => {
|
|
console.error('[p8] fatal:', err instanceof Error ? (err.stack ?? err.message) : err)
|
|
process.exit(2)
|
|
})
|