mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* 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>
165 lines
7.0 KiB
TypeScript
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',
|
|
]);
|
|
});
|
|
});
|
|
});
|