mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
* fix(pglite): drain fire-and-forget last_retrieved_at writes before disconnect Closes the structural bug class behind #1247, #1269, #1290: PGLite CLI search/query/get_page commands printed results then hung at ~95-98% CPU until SIGKILL. Root cause: bumpLastRetrievedAt's IIFE races engine.disconnect() — PGLite's WASM runtime keeps Bun's event loop alive while the dangling UPDATE settles. Mirrors the existing awaitPendingSearchCacheWrites precedent landed in v0.36.1.x for #1090. Tracks every IIFE promise in a module-scoped Set, exposes awaitPendingLastRetrievedWrites(timeoutMs) that resolves once all settle. Bounded with a 5s default timeout via Promise.race so a future fire-and-forget that hangs forever can't recreate the bug class at this layer — instead, the drain stderr-warns with a pending count and returns timeout outcome so the caller can decide its fallback. Test coverage: 6 unit cases covering empty drain, single + multi-pending settle, throw-in-IIFE still settles, permanently-pending hits timeout within bound, empty pageIds does not track. This commit ships the helper + tracking + tests with NO consumer. The cli.ts wiring lands in a follow-up commit (atomic bisect units). Co-Authored-By: Park Je Hoon <jehoon@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pglite): snapshot+early-null disconnect + try/finally lock-leak guard Refactor PGLiteEngine.disconnect() with two structural fixes: (1) Snapshot + early-null pattern: capture db/lock refs and null the instance fields BEFORE any await. A concurrent connect() can no longer observe `_db` pointing at a handle that's mid-close. This is PR #1337's load-bearing contribution that we DID take. (2) Wrap close + release in try/finally. Without this guard, a thrown db.close() would leak the file lock and wedge every next gbrain invocation on the stale lock. Codex outside-voice review (eng review finding #7) caught this gap when reviewing the snapshot refactor. KEEP the original close-then-release order. PR #1337's diff swapped this to release-then-close, which we explicitly REJECTED — releasing the lock before close lets a sibling process try to connect to a still-closing brain. The new lifecycle test file pins this ordering so a future maintainer reading PR #1337's diff cannot accidentally flip it. Test coverage in test/pglite-engine-disconnect.serial.test.ts: 5 cases — close-before-release ordering, early-null observable inside close, lock-still-releases on close-throw, double-disconnect idempotency, reconnect-after-disconnect clean state. `.serial` because each test creates a fresh PGLite engine (WASM cold-start cost) — running in parallel shards would starve other tests. Existing test/pglite-engine.test.ts: 100/100 still green. Co-Authored-By: Matt Dean <matt-dean-git@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(pglite): classify WASM init errors so #1340 gets the right hint (#1340) Closes the user-facing half of #1340: on macOS 12.7.6 + Bun 1.3.14, the PGLite connect() catch block hardcoded the macOS 26.3 hint (#223). The actual root cause for #1340 is Bun's vfs: `/$$bunfs/root` is read-only on older macOS, so PGLite cannot extract its pglite.data WASM payload. Adds two exported helpers in pglite-engine.ts: classifyPgliteInitError(message): 'bunfs' | 'macos-26-3' | 'unknown' buildPgliteInitErrorMessage(verdict, original): string Connect catch block now routes the hint by verdict. The bunfs hint names `bun upgrade` + Node fallback. The macOS 26.3 hint keeps the existing #223 link. Unknown falls through to a generic doctor + #223 fallback. Per Codex eng-review finding #9, the bunfs regex is tightened to match either the literal `$$bunfs` marker OR ENOENT+pglite.data co-occurrence — NOT generic `pglite.data` substring (would fire on unrelated errors). Negative test pinned. Root fix is upstream Bun; this PR just stops misclassifying the failure class so support traffic doesn't conflate two unrelated bugs. Test coverage: 12 pure-function unit cases including the #1340 reporter's exact error string round-trip, the negative case Codex caught, and all three verdicts × all three message contents. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(cli): await last-retrieved drain + narrow timeout-only force-exit (#1247, #1269, #1290) Wires the v0.40.10.0 drain helper into cli.ts and adds the IRON-RULE behavioral regression test for the search-hang class. The drain is called unconditionally for every op (not per-op-name gated — that was the original PR #1259 mistake that left search and get_page exposed). The narrow force-exit synthesis (decision D7 from the eng review, informed by Codex outside-voice findings #1+#2+#8): when the drain returns outcome:'timeout', AFTER engine.disconnect() resolves AND the command is NOT a daemon, fire process.exit(0). The drain helper already stderr-warned with the pending count, so the diagnostic signal is preserved. Without this guard, a hung underlying promise could still keep Bun's event loop alive past disconnect. CRITICALLY narrower than PR #1337's blanket force-exit: the timeout path is the only trigger. In the common case (drain settles cleanly under 5s), no force-exit fires and the behavioral subprocess test still catches future regressions. The shouldForceExitAfterMain guard excludes 'serve' so the stdio + HTTP daemons stay alive past main(). e2e/pglite-cli-exit.serial.test.ts (NEW, IRON RULE): - gbrain search "foxtrot" → exits 0 within 15s - gbrain get alpha → exits 0 within 15s with foxtrot in stdout - gbrain query "foxtrot" --no-expand → exits within 15s (no-API-key graceful) - gbrain serve --http → stays alive 3+ seconds (daemon-survival regression guard) fix-wave-structural.test.ts: - import assertion for awaitPendingLastRetrievedWrites - last-retrieved.ts exports + Set tracking + Promise.race + timeout - BEHAVIORAL positioning assertion: drain `await` appears textually BEFORE engine.disconnect `await` in the op-dispatch local-engine path. Survives variable-rename refactors; catches any new disconnect path that bypasses the drain. - shouldForceExitAfterMain excludes 'serve' AND the gate is conditioned on drainResult.outcome==='timeout' Per D8 (Codex finding #5), explicitly do NOT add a drift-guard counting bumpLastRetrievedAt callers — would block harmless refactors and miss aliases. Co-Authored-By: Park Je Hoon <jehoon@users.noreply.github.com> Co-Authored-By: Matt Dean <matt-dean-git@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(sync): add phase breadcrumbs to performSyncInner for #1342 triage The #1342 reporter saw ZERO stderr output before their PGLite sync hang, which made the bug impossible to triage from a community report alone. Mirrors the pre-existing `[gbrain phase] sync.git_pull start/done` pattern at the major pre-pull phase boundaries so the next #1342-shaped report names WHICH phase spun. Four new breadcrumbs at: - sync.resolve_repo (top of performSyncInner) - sync.load_active_pack (before the v0.39 T1.5 pack load) - sync.validate_repo_state (only when opts.sourceId is set — the re-clone branch) - sync.detect_head (before the isDetachedHead probe) No behavior change — pure stderr instrumentation. Doesn't fix #1342 (which still needs investigation per the TODOS entry filed in this wave), but converts "hung with no output" into actionable diagnostic data the next time the bug shape is reported. Per D9 in the eng review + Codex outside-voice finding #14. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: annotate v0.40.10.0 PGLite hang wave in CLAUDE.md + regen llms Key Files entries updated: - src/core/pglite-engine.ts: documents the v0.40.10.0 disconnect refactor (snapshot+early-null + try/finally lock-leak guard, KEEPS close-then-release order), and the new classifyPgliteInitError / buildPgliteInitErrorMessage helpers for #1340 hint routing. Pins PR #1337's accepted-but-narrowed contribution and the rejected release-then-close ordering swap. - src/core/last-retrieved.ts (within the brainstorm entry): documents the new awaitPendingLastRetrievedWrites drain, the Set tracking pattern, the 5s bounded timeout, the cli.ts narrow timeout-only force-exit synthesis with the serve-daemon guard, and the three community-validated reports (#1247/#1269/#1290) the fix closes. Credits PR #1259 (drain pattern) and PR #1337 (snapshot pattern + force-exit guard idea). Regenerated llms.txt + llms-full.txt — build-llms.test.ts gates the drift, all 7 cases green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs(todos): file v0.40.10.0 PGLite hang follow-ups Three deferred items from the v0.40.10.0 fix wave: 1. #1342 sync-hang investigation. Single-reporter, JS-tight-loop shape, needs reproducer before any fix. Documents the ruled-out hypotheses (lock-refresh heartbeat, v91 trigger, while-true loops) and three concrete diagnostic next steps. The v0.40.10.0 sync phase breadcrumbs make the next report actionable. 2. awaitPendingSearchCacheWrites timeout-symmetry retrofit. The #1090 drain shipped without a timeout; the v0.40.10.0 #1247 drain ships with one. Apply the same Promise.race + stderr warn pattern for symmetry. 3. Drain-helper extraction. Per D4 in the eng review: two surfaces is the threshold for noticing, three for extracting. Pair with the symmetry retrofit above as one focused refactor when a third fire-and-forget surface appears. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * v0.40.10.0 fix(pglite): search/query/get exit cleanly + #1340 hint + #1342 breadcrumbs Closes #1247, #1269, #1290 (PGLite CLI search/query/get hang at ~95-98% CPU after printing results — three community-validated reports). Also fixes #1340 (WASM init misroutes to macOS 26.3 hint when real cause is Bun vfs read-only mount) and adds diagnostic phase breadcrumbs for the single-reporter #1342 sync-hang investigation. Core fix: track every fire-and-forget bumpLastRetrievedAt IIFE in a module-scoped Set; cli.ts awaits the drain before engine.disconnect() in the op-dispatch finally block; narrow process.exit(0) fires ONLY when the drain times out AND the command isn't a daemon. Snapshot+ early-null disconnect pattern + try/finally lock-leak guard close the partial-state race PR #1337 originally surfaced. Co-Authored-By: Park Je Hoon <jehoon@users.noreply.github.com> Co-Authored-By: Matt Dean <matt-dean-git@users.noreply.github.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: extract shouldForceExitAfterMain to its own module + add unit cases Gap-audit follow-up: cli.ts is a script entrypoint (top-level main() side effect), so importing it from a test fires the help output as a side effect. Move shouldForceExitAfterMain into src/core/cli-force-exit.ts so it can be unit-tested in isolation without the cli.ts script tail running. Adds test/cli-should-force-exit.test.ts (9 cases): bare serve, serve with flags after, global flags BEFORE the command (the load-bearing case for `gbrain --quiet serve`), op commands return true, non-daemon CLI commands return true, empty argv defaults to true, flag-only argv, default-arg fallback to process.argv.slice(2), substring-match avoidance (`serves` is NOT `serve` — strict equality via Set, not startsWith/includes). The daemon command set is now an explicit ReadonlySet — future daemons (a hypothetical `gbrain watch` or `gbrain daemon`) just add their name to DAEMON_COMMANDS rather than chaining ||. Updates fix-wave-structural.test.ts to look for the import + the new DAEMON_COMMANDS shape. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(version): rebase v0.40.10.0 → v0.41.6.0 (slot collision after v0.41.0.0+ landed) origin/master moved from v0.40.8.1 → v0.41.0.0 while this wave was in flight (PR #1367 minions cathedral). v0.41.1-v0.41.5 are claimed by other in-flight branches, so v0.41.6.0 is the next available slot. Bulk-renamed v0.40.10.0 → v0.41.6.0 across: - VERSION + package.json (trio audit clean: 0.41.6.0 / 0.41.6.0 / 0.41.6.0) - CHANGELOG.md (header + 3 prose references) - CLAUDE.md (Key Files annotations) - TODOS.md (follow-up entry header) - src/cli.ts + src/core/cli-force-exit.ts + src/core/last-retrieved.ts + src/core/pglite-engine.ts + src/commands/sync.ts (inline comments) - test/* (describe blocks + test file headers) - llms-full.txt (regenerated via `bun run build:llms`) bun.lock unchanged (version-only bump, no dep churn) per Codex #12. Verify: 52/52 wave tests pass after rename, typecheck clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test: quarantine seed-pglite to .serial.test.ts (parallel WASM cold-start flake) The full-suite run during the v0.41.6.0 fix wave ship hit a 30s timeout in test/seed-pglite.test.ts under heavy 4-shard parallel contention (4972/4973 passed before SIGKILL). The test passes 11/11 in isolation. Root cause: each test instantiates a fresh PGLiteEngine (5 instances across the file, one per test) because each case writes to a different mkdtemp-ed dbPath. Under parallel shard load, multiple shards each cold-starting PGLite WASM simultaneously stretches the per-instance init from ~5s to 30s+. The shared-engine pattern (canonical PGLite block in CLAUDE.md R3+R4) doesn't apply here — different dbPaths require different engines. Fix per CLAUDE.md test-isolation quarantine rules: rename to `.serial.test.ts` so the file runs in the post-parallel serial pass with full WASM init capacity. Same pattern as test/pglite-engine-disconnect.serial.test.ts (added in this wave) and test/brain-registry.serial.test.ts (pre-existing). Removes test/seed-pglite.test.ts from check-test-isolation.allowlist since the .serial.test.ts rename auto-exempts it from the R3+R4 lint (scan skips *.serial.test.ts). 641 non-serial unit files scanned, lint clean. Verify: - bun test test/seed-pglite.serial.test.ts → 11/11 pass in 4.19s - scripts/check-test-isolation.sh → OK - bun run verify → all gates pass Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: pre-landing review fixes (C13 disconnect-hang, C1 set leak, C9 catch drain, M1 type drift) Adversarial review + maintainability specialist surfaced four real issues in the v0.41.8.0 wave. All four fixed in this commit; one deferred to TODOS.md as a v0.41+ follow-up (unusual caller pattern). **C13 [load-bearing, defense-in-depth for the wave's stated goal]:** `await engine.disconnect()` inside the op-dispatch finally can ITSELF hang on PGLite (db.close() racing OS-level FS state). When that happens, the entire wave's force-exit guard never runs — we recreate the original hang at a new layer. Fix: install an unref'd setTimeout hard-exit fallback BEFORE entering the try/catch/finally. The timer fires after DISCONNECT_HARD_DEADLINE_MS=10s with a stderr warn and process.exit(0). unref ensures it doesn't keep the loop alive on a healthy exit. Daemons (`serve`) are excluded by reusing the shouldForceExitAfterMain guard. **C9 [data freshness gap, narrow but real]:** The drain ran ONLY in the success branch of try. If `bumpLastRetrievedAt` fired (handler succeeded) but `JSON.parse(JSON.stringify(...))` or `formatResult` then threw, process.exit(1) killed the process and the in-flight UPDATE was discarded. Fix: drain in the catch path too before process.exit(1) (best-effort, bounded by the drain's own 5s timeout). **C1 [daemon leak]:** A timed-out IIFE used to stay in the pending-writes Set forever because its `.finally` never fires. Long-lived `gbrain serve` would accumulate references without bound across repeated timeouts. Fix: explicitly `delete` the snapshot's tracked promises from the Set after a timeout outcome. The IIFEs keep running (orphaned), but the Set no longer leaks references. Pinned by a new unit test that asserts the second drain after a timeout returns immediately with empty pending count. **M1 [silent type drift]:** `cli.ts` duplicated the `{outcome, pending}` literal shape instead of importing the `DrainOutcome` type that `last-retrieved.ts` exports exactly for this purpose. Two-line fix: add `type DrainOutcome` to the import and use it for `let drainResult`. Future changes to the return shape now propagate through TypeScript. **Deferred to TODOS.md (C6 — unusual caller pattern):** Concurrent connect/disconnect on the same `PGLiteEngine` instance can strand: disconnect snapshots+nulls the lock while connect is still in-flight, leaving the resolved engine with no file lock held. Fix requires an instance-level mutex; not worth the complexity for a caller pattern that doesn't appear in production (single instance per process, sequential lifecycle). Also broadened `test/fix-wave-structural.test.ts` regex to accept additional type-imports from `last-retrieved.ts` (e.g. the new `type DrainOutcome` import that M1 added). Test coverage: 53/53 wave tests pass (added C1-followup case to last-retrieved.test.ts). The C1 fix is also pinned by tightening the existing permanent-pending test's post-timeout assertion to expect empty pending count rather than the prior (stale) "stays in set" note. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * docs: post-ship documentation sync for v0.41.8.0 Consolidate the duplicate 'take advantage of v0.41.8.0' sections in the CHANGELOG entry into a single canonical block per the CLAUDE.md template. The wave originally landed with both '### How to take advantage' (line 13) and '### To take advantage' (line 57) as h3 headings. CLAUDE.md mandates one '## To take advantage of v[version]' h2 block per release entry, with verify steps + an issue-filing fallback for users hitting upgrade failures. Promoted the second block to h2, added the issue-filing step, and removed the redundant first block (the upgrade command is already covered in the verify steps). Itemized changes section was unchanged. llms.txt + llms-full.txt regenerated; structurally identical so no content changes shipped. * fix(test): find-experts-op queries schema dim instead of hardcoding 1536 (CI shard 1) CI shard 1 failed on this branch with: \"expected 1280 dimensions, not 1536\" from pgvector's CheckExpectedDim. Root cause: master's v0.36.0 changed DEFAULT_EMBEDDING_DIMENSIONS from OpenAI's 1536d to ZeroEntropy's 1280d (src/core/ai/defaults.ts:21). The test's basisEmbedding helper hardcoded dim=1536, so beforeAll's upsertChunks failed when the schema column was created at 1280d. Latent on master: the weight-aware LPT bin-packing in scripts/sharding.ts assigns files to shards deterministically based on the COMPLETE file set. My branch adds 5 new test files, which shifted find-experts-op.test.ts into shard 1. Master's shard 1 doesn't run this file (it lands in a different shard there), so the bug never surfaced in master's CI. Fix: query the actual column dim via SELECT atttypmod FROM pg_attribute after initSchema, then seed the embedding at that width. This handles both paths (no-env CI → 1280; env-configured local → 1536) without hardcoding either default. Verify: - bun test test/find-experts-op.test.ts → 11/11 pass with provider env - env -i bun test test/find-experts-op.test.ts → 11/11 pass without - bun run verify → all 21 parallel checks clean - bun run typecheck → clean Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lint): robust pure-bash allowlist match in check-test-isolation (CI verify) CI verify failed on PR #1405 with check:test-isolation flagging test/scripts/check-test-isolation.test.ts even though that file is on line 22 of the allowlist (and has been since v0.26.7 as a permanent exemption — its body contains process.env mutation fixtures that the lint legitimately matches). Could not reproduce locally on macOS bash 3.2 + BSD grep across any locale (C, C.UTF-8, POSIX). Suspect a subtle interaction between the prior `echo "$ALLOWLIST" | grep -qxF "$f"` form and one of: Ubuntu 24.04's bash 5 set-e/pipefail semantics, GNU grep edge case on the first-line entry, or `bun run` + GNU timeout subshell interaction. Diagnostic value of chasing further is low — the fix is to drop the grep+pipe form entirely. Switch is_allowlisted() to pure-bash `case $'\n'"$ALLOWLIST"$'\n' in *$'\n'"$f"$'\n'*) return 0 ;; esac` whole-line matching: - Locale-free (no character-class interaction) - Pipe-free (no pipefail / SIGPIPE / buffering) - Subshell-free (no env or exit-code propagation gotchas) - set-e-quirk-free (no left-side compound failure) - ~100x faster (no fork+exec per call across 689 files) Verified locally: lint OK (689 files), case-match returns true for the allowlisted file and false for a non-allowlisted file. bun run verify clean (21/21 parallel checks pass). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Park Je Hoon <jehoon@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: Matt Dean <matt-dean-git@users.noreply.github.com>
279 lines
9.6 KiB
TypeScript
279 lines
9.6 KiB
TypeScript
/**
|
|
* v0.41.8.0 — IRON-RULE regression for #1247, #1269, #1290.
|
|
*
|
|
* Pre-fix: `gbrain search`, `gbrain query`, `gbrain get` on PGLite
|
|
* printed results then hung at ~95-98% CPU until SIGKILL.
|
|
*
|
|
* Post-fix: each command exits 0 within a few seconds.
|
|
*
|
|
* This test spawns the CLI as a real subprocess against a hermetic
|
|
* GBRAIN_HOME tempdir, seeds a brain with 2 pages, runs each verb
|
|
* with a hard timeout, and asserts exit 0. Without the drain helper
|
|
* in cli.ts, every variant would time out.
|
|
*
|
|
* Bonus assertion: `gbrain serve --http` (a daemon) MUST stay alive
|
|
* after the first request — the narrow force-exit guard added in
|
|
* v0.41.8.0 is supposed to fire ONLY on op-dispatch drain timeout,
|
|
* NEVER for daemons. This catches any future regression where the
|
|
* force-exit gets broadened or the guard mis-recognizes 'serve'.
|
|
*
|
|
* Marked .serial because each test spawns a real bun subprocess that
|
|
* cold-starts PGLite WASM (~5-10s wallclock). Running these in the
|
|
* parallel pool would starve siblings of WASM init time.
|
|
*
|
|
* The reproducibility preconditions (per Codex eng-review #4) are
|
|
* encoded explicitly:
|
|
* - Seeded pages have `last_retrieved_at NULL` (verified via
|
|
* fresh PGLite init — column starts NULL).
|
|
* - At least one page id returned from each verb (search hits the
|
|
* literal title; get hits the seeded slug).
|
|
* - `search.track_retrieval` left unset → default-on path fires the
|
|
* bumpLastRetrievedAt write that pre-fix would race disconnect.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll } from 'bun:test';
|
|
import { spawn, spawnSync } from 'child_process';
|
|
import {
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
chmodSync,
|
|
} from 'fs';
|
|
import { tmpdir } from 'os';
|
|
import { join, resolve } from 'path';
|
|
|
|
const REPO_ROOT = resolve(import.meta.dir, '..', '..');
|
|
const BIN_CACHE = join(REPO_ROOT, 'test', '.cache');
|
|
const SHIM_PATH = join(BIN_CACHE, 'gbrain-pglite-exit-shim.sh');
|
|
|
|
beforeAll(() => {
|
|
// Same shim pattern as claw-test e2e: bun --compile can't bundle
|
|
// PGLite's pglite.data, so we delegate to `bun run src/cli.ts`.
|
|
mkdirSync(BIN_CACHE, { recursive: true });
|
|
const shim = `#!/bin/sh\nexec bun run "${join(REPO_ROOT, 'src', 'cli.ts')}" "$@"\n`;
|
|
writeFileSync(SHIM_PATH, shim, 'utf-8');
|
|
chmodSync(SHIM_PATH, 0o755);
|
|
}, 10_000);
|
|
|
|
// Set up a fresh hermetic PGLite brain once per file; each verb
|
|
// runs against the same brain to amortize cold-start.
|
|
let tmpHome: string;
|
|
let repoSourceDir: string;
|
|
let runEnv: NodeJS.ProcessEnv;
|
|
|
|
beforeAll(() => {
|
|
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-pglite-exit-'));
|
|
repoSourceDir = mkdtempSync(join(tmpdir(), 'gbrain-pglite-exit-src-'));
|
|
|
|
// Seed a tiny git repo with 2 markdown pages so `gbrain sync` has
|
|
// something to import. The pages contain the literal token 'foxtrot'
|
|
// so search has a deterministic keyword hit.
|
|
writeFileSync(
|
|
join(repoSourceDir, 'alpha.md'),
|
|
'---\ntitle: Alpha\n---\nThe quick brown foxtrot jumps over the lazy dog.\n',
|
|
);
|
|
writeFileSync(
|
|
join(repoSourceDir, 'beta.md'),
|
|
'---\ntitle: Beta\n---\nFoxtrot is a NATO phonetic letter F.\n',
|
|
);
|
|
|
|
// git init + commit so sync has a HEAD to anchor against
|
|
spawnSync('git', ['init', '-q', '-b', 'main'], { cwd: repoSourceDir });
|
|
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoSourceDir });
|
|
spawnSync('git', ['config', 'user.name', 'Test'], { cwd: repoSourceDir });
|
|
spawnSync('git', ['add', '-A'], { cwd: repoSourceDir });
|
|
spawnSync('git', ['commit', '-q', '-m', 'seed'], { cwd: repoSourceDir });
|
|
|
|
// Strip embedding-provider env vars so init doesn't refuse on the
|
|
// multi-provider ambiguity check. We don't need embeddings — sync
|
|
// runs with --no-embed below and search/get are keyword-only paths.
|
|
runEnv = { ...process.env, GBRAIN_HOME: tmpHome };
|
|
delete runEnv.VOYAGE_API_KEY;
|
|
delete runEnv.ZEROENTROPY_API_KEY;
|
|
delete runEnv.OPENAI_API_KEY;
|
|
delete runEnv.ANTHROPIC_API_KEY;
|
|
delete runEnv.GOOGLE_API_KEY;
|
|
|
|
const initResult = spawnSync(
|
|
SHIM_PATH,
|
|
['init', '--pglite', '--repo', repoSourceDir, '--no-embedding', '--yes'],
|
|
{
|
|
cwd: REPO_ROOT,
|
|
env: runEnv,
|
|
encoding: 'utf-8',
|
|
timeout: 60_000,
|
|
},
|
|
);
|
|
if (initResult.status !== 0) {
|
|
throw new Error(
|
|
`gbrain init failed (code=${initResult.status}):\n` +
|
|
`STDOUT:\n${initResult.stdout}\n` +
|
|
`STDERR:\n${initResult.stderr}`,
|
|
);
|
|
}
|
|
|
|
// Sync to import the pages (no-embed: skip the embedding step so
|
|
// the test doesn't need any provider key).
|
|
const syncResult = spawnSync(
|
|
SHIM_PATH,
|
|
['sync', '--repo', repoSourceDir, '--no-pull', '--no-embed'],
|
|
{
|
|
cwd: REPO_ROOT,
|
|
env: runEnv,
|
|
encoding: 'utf-8',
|
|
timeout: 60_000,
|
|
},
|
|
);
|
|
if (syncResult.status !== 0) {
|
|
throw new Error(
|
|
`gbrain sync failed (code=${syncResult.status}):\n` +
|
|
`STDOUT:\n${syncResult.stdout}\n` +
|
|
`STDERR:\n${syncResult.stderr}`,
|
|
);
|
|
}
|
|
}, 180_000);
|
|
|
|
afterAll(() => {
|
|
try {
|
|
rmSync(tmpHome, { recursive: true, force: true });
|
|
rmSync(repoSourceDir, { recursive: true, force: true });
|
|
} catch {
|
|
/* best effort cleanup */
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Spawn the CLI with a wall-clock timeout. Returns exit code + output.
|
|
* Without the v0.41.8.0 fix, the subprocess hangs forever and the
|
|
* timeout would force-kill. The IRON-RULE assertion is "exit code 0
|
|
* within timeoutMs."
|
|
*/
|
|
function runWithTimeout(
|
|
args: string[],
|
|
timeoutMs: number,
|
|
): Promise<{ code: number | null; stdout: string; stderr: string; durationMs: number }> {
|
|
return new Promise((resolveOut) => {
|
|
const t0 = Date.now();
|
|
const child = spawn(SHIM_PATH, args, {
|
|
cwd: REPO_ROOT,
|
|
env: runEnv,
|
|
});
|
|
let stdout = '';
|
|
let stderr = '';
|
|
child.stdout.on('data', (d) => (stdout += d.toString()));
|
|
child.stderr.on('data', (d) => (stderr += d.toString()));
|
|
const timer = setTimeout(() => {
|
|
try { child.kill('SIGKILL'); } catch { /* ignore */ }
|
|
}, timeoutMs);
|
|
child.on('exit', (code) => {
|
|
clearTimeout(timer);
|
|
resolveOut({ code, stdout, stderr, durationMs: Date.now() - t0 });
|
|
});
|
|
});
|
|
}
|
|
|
|
describe('v0.41.8.0 — PGLite CLI read commands exit cleanly (#1247/#1269/#1290)', () => {
|
|
test('gbrain search "foxtrot" exits 0 within 15s', async () => {
|
|
const { code, stdout, stderr, durationMs } = await runWithTimeout(
|
|
['search', 'foxtrot', '--limit', '3'],
|
|
15_000,
|
|
);
|
|
if (code !== 0) {
|
|
throw new Error(
|
|
`expected exit 0, got ${code}; duration=${durationMs}ms\n` +
|
|
`STDOUT:\n${stdout}\nSTDERR:\n${stderr}`,
|
|
);
|
|
}
|
|
expect(code).toBe(0);
|
|
// Must have actually returned a hit — else bumpLastRetrievedAt
|
|
// would have early-returned on empty pageIds and the bug wouldn't
|
|
// have been exercised.
|
|
expect(stdout.length).toBeGreaterThan(0);
|
|
}, 30_000);
|
|
|
|
test('gbrain get returns a page body and exits 0 within 15s', async () => {
|
|
const { code, stdout, stderr, durationMs } = await runWithTimeout(
|
|
['get', 'alpha'],
|
|
15_000,
|
|
);
|
|
if (code !== 0) {
|
|
throw new Error(
|
|
`expected exit 0, got ${code}; duration=${durationMs}ms\n` +
|
|
`STDOUT:\n${stdout}\nSTDERR:\n${stderr}`,
|
|
);
|
|
}
|
|
expect(code).toBe(0);
|
|
expect(stdout).toContain('foxtrot');
|
|
}, 30_000);
|
|
|
|
test('gbrain query without --no-expand exits 0 within 15s (no API key)', async () => {
|
|
// Without an API key, expansion + vector branches degrade
|
|
// gracefully. The op still runs the keyword path and returns
|
|
// results. The DRAIN is what we're testing, not query quality.
|
|
const { code, stderr, durationMs } = await runWithTimeout(
|
|
['query', 'foxtrot', '--limit', '3', '--no-expand'],
|
|
15_000,
|
|
);
|
|
if (code !== 0) {
|
|
// Some test environments may fail query on missing embed key —
|
|
// we tolerate that, but ALL of the rapid exit invariants still
|
|
// apply: the process MUST exit, not hang. duration < 15s proves
|
|
// it didn't hang; non-zero is acceptable.
|
|
expect(durationMs).toBeLessThan(15_000);
|
|
return;
|
|
}
|
|
expect(code).toBe(0);
|
|
}, 30_000);
|
|
});
|
|
|
|
describe('v0.41.8.0 — daemon survival (regression guard for narrow force-exit)', () => {
|
|
test('gbrain serve --http stays alive past the timeout window', async () => {
|
|
// Pick a likely-free ephemeral port. We're testing "still alive
|
|
// 3 seconds after startup" — if the force-exit guard misfired
|
|
// on 'serve', the process would die immediately after binding.
|
|
const port = 31000 + Math.floor(Math.random() * 1000);
|
|
const child = spawn(
|
|
SHIM_PATH,
|
|
['serve', '--http', '--port', String(port), '--token-ttl', '60'],
|
|
{
|
|
cwd: REPO_ROOT,
|
|
env: runEnv,
|
|
detached: false,
|
|
},
|
|
);
|
|
|
|
let exitedEarly = false;
|
|
let earlyCode: number | null = null;
|
|
child.on('exit', (code) => {
|
|
exitedEarly = true;
|
|
earlyCode = code;
|
|
});
|
|
|
|
// Give the server 3 seconds. If the force-exit narrow guard is
|
|
// working, the daemon stays alive past this window.
|
|
await new Promise((r) => setTimeout(r, 3_000));
|
|
|
|
const wasAlive = !exitedEarly;
|
|
try {
|
|
child.kill('SIGTERM');
|
|
// Give it a moment to clean up
|
|
await new Promise((r) => setTimeout(r, 1_000));
|
|
if (!exitedEarly) {
|
|
try { child.kill('SIGKILL'); } catch { /* already dead */ }
|
|
}
|
|
} catch {
|
|
/* already dead */
|
|
}
|
|
|
|
if (!wasAlive) {
|
|
throw new Error(
|
|
`gbrain serve --http exited within 3s (code=${earlyCode}). ` +
|
|
`If the narrow force-exit guard misclassified 'serve' as a ` +
|
|
`non-daemon command, this is the regression.`,
|
|
);
|
|
}
|
|
expect(wasAlive).toBe(true);
|
|
}, 15_000);
|
|
});
|