Files
gbrain/test/sync-walker-symlink.test.ts
T
eec2d2bf7b v0.31.2 fix: gbrain sync --strategy code no longer hangs on big symlink-rich repos (#773)
* fix: bound tree-sitter chunker + harden walker + plumb strategy

`gbrain sync --strategy code` against a 1500-file repo could pin one
thread at 99% CPU for hours with zero disk writes and a `page_count`
that stayed at 0. Three real defects, all closed in one commit:

1. **Tree-sitter chunker had no wall-clock cap.** A single pathological
   file could wedge the whole sync inside WASM. New `parseWithTimeout`
   helper in src/core/chunkers/code.ts wraps `parser.parse()` with
   `setTimeoutMicros(timeoutMs * 1000)`, throws `ChunkerTimeoutError`
   on null, and the caller's try/finally reaps parser+tree (closes the
   leak codex flagged where the catch block returned without delete()).
   Default 30s, override via `GBRAIN_CHUNKER_TIMEOUT_MS`. Falls back to
   recursive chunks on timeout — degrades search quality on that one
   file, doesn't wedge sync.

2. **Code-strategy first-sync silently no-op'd on code files.**
   `performFullSync` called `runImport(repoPath)` with no strategy;
   `runImport` only ever walked `.md`/`.mdx`. Now `opts.strategy`
   threads end-to-end (full-sync write path AND dry-run). Code files
   actually reach the dispatcher, which already routes them to
   `importCodeFile` correctly.

3. **Walker was thrice-redundant.** `collectMarkdownFiles` (lstat-safe,
   import path) and `walkSyncableFiles` (statSync, cost-preview path,
   weaker for no good reason) collapsed into one hardened
   `collectSyncableFiles` in src/commands/import.ts: lstat + symlink-
   skip with canonical log line; inode-cycle Map keyed on
   `${st_dev}:${st_ino}` (defense-in-depth for non-symlink loops);
   `MAX_WALK_DEPTH=32` structural backstop with `GBRAIN_MAX_WALK_DEPTH`
   override; `.sort()` output (codex C8: `runImport`'s checkpoint
   resume is index-based against a sorted list). Walker-context
   multimodal carve-out preserved at one site (codex C5).

Plus structured `[gbrain phase] <name> start/done` stderr lines on
git_pull, fullsync.import, collect_files, and per-file slow path
(>5s). When the next hang lands, log says which phase wedged.

Tests:
- `test/sync-walker-symlink.test.ts` — 7 cases (self-symlink loop,
  symlink-chain inode cycle, max-depth bailout, strategy filter,
  dot-dir skip, multimodal preservation, deterministic ordering)
- `test/chunker-timeout.test.ts` — 7 cases (parser-stub seam,
  ChunkerTimeoutError shape, env wiring, fallback behavior, fail-loud
  if setTimeoutMicros API missing, cleanup contract under exception)

Smoke against the user's actual amarillo-v2 repo: 494 code files
walked in 22ms, 2 symlinks skipped with the canonical log line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: bump version 0.30.1 → 0.31.2 + CHANGELOG + TODOS

VERSION 0.31.2, package.json synced. CHANGELOG entry under [0.31.2]
with full release-summary + numbers + upgrader-cost note + To take
advantage block. v0.30.2 entry preserved below from master. TODOS.md
files the gbrain query <common-keyword> 7-day-zombie investigation
(PIDs 39429, 46624) and the deferred amarillo-shape PGLite + Postgres
E2E as v0.31.3 follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(test): use withEnv() helper instead of direct process.env mutation

CI's check-test-isolation lint (rule R1) flagged the two new test files
for mutating process.env directly. The repo-wide convention is to wrap
env mutations in withEnv() (test/helpers/with-env.ts), which saves +
restores prior values via try/finally even when the callback throws.
Direct process.env writes leak across files in the same bun test
process (parallel runner loads multiple files into one shard process).

Both files refactored:
- test/sync-walker-symlink.test.ts (GBRAIN_EMBEDDING_MULTIMODAL)
- test/chunker-timeout.test.ts (GBRAIN_CHUNKER_TIMEOUT_MS)

All 14 cases still pass. `bun run verify` clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 21:18:53 -07:00

165 lines
7.0 KiB
TypeScript

/**
* v0.31.2 walker hardening regression tests.
*
* Closes the bug class where `gbrain sync --strategy code` could hang on
* repos with self-referencing symlinks (the gstack `.claude/skills/gstack
* -> repo-root` dev pattern was the trigger). Pins:
*
* 1. Self-referencing symlink does not loop.
* 2. Symlink chain through real dirs does not loop (inode-set defense).
* 3. MAX_WALK_DEPTH=32 bailout is structural backstop.
* 4. Strategy filter (code/markdown/auto) admits the right files.
* 5. Dot-prefixed dirs (.git/.claude/.raw) and node_modules still skipped.
* 6. Multimodal preservation under markdown-strategy (codex C5).
* 7. Deterministic ordering — runImport's index-based resume depends on it
* (codex C8).
*/
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { collectSyncableFiles } from '../src/commands/import.ts';
import { withEnv } from './helpers/with-env.ts';
let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), 'gbrain-walker-'));
});
afterEach(() => {
rmSync(tmp, { recursive: true, force: true });
});
describe('collectSyncableFiles symlink + cycle hardening', () => {
test('1. self-referencing symlink does not loop', async () => {
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
writeFileSync(join(tmp, 'README.md'), '# top\n');
// Symlink "loop" inside tempdir pointing back to itself.
symlinkSync(tmp, join(tmp, 'loop'));
const t0 = Date.now();
const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
const ms = Date.now() - t0;
expect(ms).toBeLessThan(1000); // would hang if walker followed the loop
expect(files).toContain(join(tmp, 'README.md'));
expect(files.every(f => !f.includes('/loop/'))).toBe(true);
});
});
test('2. symlink chain through real dirs does not loop', async () => {
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
// a/ contains a real subdir b/, which contains a symlink "back" -> a.
// The lstat skip catches "back" before recursion. If somehow it
// missed, the inode-cycle Map catches the second visit to a/.
mkdirSync(join(tmp, 'a/b'), { recursive: true });
writeFileSync(join(tmp, 'a/b/leaf.md'), 'leaf\n');
symlinkSync(join(tmp, 'a'), join(tmp, 'a/b/back'));
const t0 = Date.now();
const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
const ms = Date.now() - t0;
expect(ms).toBeLessThan(2000);
expect(files).toContain(join(tmp, 'a/b/leaf.md'));
});
});
test('3. max-depth bailout terminates pathological deep trees', async () => {
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
// Build a 40-level real directory tree. 32 is the default cap; the
// file at the deepest level is intentionally past the bailout so we
// can assert it is NOT collected (and the walker still terminates).
let cur = tmp;
for (let i = 0; i < 40; i++) {
cur = join(cur, `d${i}`);
mkdirSync(cur);
}
writeFileSync(join(cur, 'deep.md'), 'deep\n');
writeFileSync(join(tmp, 'shallow.md'), 'shallow\n');
const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
expect(files).toContain(join(tmp, 'shallow.md'));
expect(files.every(f => !f.includes('/d35/'))).toBe(true); // past depth 32
});
});
test('4. strategy filter admits the right files', async () => {
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
writeFileSync(join(tmp, 'README.md'), '# r\n');
writeFileSync(join(tmp, 'foo.ts'), '// f\n');
writeFileSync(join(tmp, 'bar.py'), '# b\n');
const code = collectSyncableFiles(tmp, { strategy: 'code' });
const markdown = collectSyncableFiles(tmp, { strategy: 'markdown' });
const auto = collectSyncableFiles(tmp, { strategy: 'auto' });
expect(code.map(f => f.split('/').pop()).sort()).toEqual(['bar.py', 'foo.ts']);
expect(markdown.map(f => f.split('/').pop())).toEqual(['README.md']);
expect(auto.map(f => f.split('/').pop()).sort()).toEqual(['README.md', 'bar.py', 'foo.ts']);
});
});
test('5. dot-prefixed dirs and node_modules still skipped', async () => {
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
writeFileSync(join(tmp, 'real.md'), 'r\n');
mkdirSync(join(tmp, '.git'));
writeFileSync(join(tmp, '.git/HEAD'), 'ref: refs/heads/main\n');
mkdirSync(join(tmp, '.claude/skills'), { recursive: true });
writeFileSync(join(tmp, '.claude/skills/SKILL.md'), 's\n');
mkdirSync(join(tmp, 'node_modules/foo'), { recursive: true });
writeFileSync(join(tmp, 'node_modules/foo/index.md'), 'no\n');
const files = collectSyncableFiles(tmp, { strategy: 'markdown' });
const names = files.map(f => f.replace(tmp, ''));
expect(names).toContain('/real.md');
expect(names.every(n => !n.startsWith('/.git'))).toBe(true);
expect(names.every(n => !n.startsWith('/.claude'))).toBe(true);
expect(names.every(n => !n.startsWith('/node_modules'))).toBe(true);
});
});
test('6. multimodal preservation under markdown strategy (codex C5)', async () => {
writeFileSync(join(tmp, 'r.md'), 'r\n');
writeFileSync(join(tmp, 'p.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
writeFileSync(join(tmp, 'j.jpg'), Buffer.from([0xff, 0xd8, 0xff]));
// Off → markdown only.
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
const off = collectSyncableFiles(tmp, { strategy: 'markdown' });
expect(off.map(f => f.split('/').pop()).sort()).toEqual(['r.md']);
});
// On → markdown + images (preserves v0.27.1 F2 collectMarkdownFiles
// behavior; codex C5 carve-out).
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: 'true' }, () => {
const on = collectSyncableFiles(tmp, { strategy: 'markdown' });
expect(on.map(f => f.split('/').pop()).sort()).toEqual(['j.jpg', 'p.png', 'r.md']);
});
});
test('7. deterministic ordering — two walks return identical arrays (codex C8)', async () => {
await withEnv({ GBRAIN_EMBEDDING_MULTIMODAL: undefined }, () => {
// runImport's checkpoint resume at import.ts:68-74 is index-based
// against a sorted file list. Unstable order skips the wrong files
// on resume.
writeFileSync(join(tmp, 'b.md'), 'b\n');
writeFileSync(join(tmp, 'a.md'), 'a\n');
mkdirSync(join(tmp, 'sub'));
writeFileSync(join(tmp, 'sub/c.md'), 'c\n');
const first = collectSyncableFiles(tmp, { strategy: 'markdown' });
const second = collectSyncableFiles(tmp, { strategy: 'markdown' });
expect(first).toEqual(second);
// Sorted: a.md, b.md, sub/c.md (lexicographic on absolute paths).
expect(first.map(f => f.replace(tmp, ''))).toEqual([
'/a.md', '/b.md', '/sub/c.md',
]);
});
});
});