Compare commits

...
Author SHA1 Message Date
Garry TanandClaude Fable 5 ee3c9080b4 Merge origin/master; re-bump to v0.42.68.2 (master took 0.42.68.1)
VERSION/package.json/CHANGELOG trio resolved per the recovery procedure;
no code overlap with master's 20 new commits. llms bundle regenerated.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-01 05:20:17 +08:00
Garry TanandClaude Opus 5 92a94dda9f fix(serve,import): close four defects an adversarial review found in this PR
Review of this branch was refuted with four findings. All four confirmed by
execution and fixed here.

The remediation block in the CHANGELOG gave a false all-clear. It told
operators to verify with `gbrain doctor`, which reports exit 0 and
"95/100, All checks OK" while a leftover process still holds the brain —
doctor has no check for the PGLite filesystem write lock. It also suggested
`pgrep -f "gbrain serve"`, which finds nothing when the server was started
from a source checkout. Both replaced with the brain's own lock file, which
records the holding pid and command; verified against a live holder, and the
stale-lock case verified to self-reclaim so the guidance does not send anyone
chasing a harmless leftover file. This was the same defect class this branch
had just criticized elsewhere: remediation text naming a command that does
not do what the text claims.

The importer hardened frontmatter against a hostile created_at and stopped
eleven lines short of the filename built from the same value, where
join(outDir, name) resolves `../`. A created_at of `../victim/p` wrote
outside the output directory entirely. `date` now goes through the same
slug() the conversation id already used. Regression test included; it fails
without the fix.

TrackedSocket was decorative. Typing the listener parameter proves nothing
because method parameters are bivariant — typecheck passed with a member no
socket could ever have. Bound to net.Socket via Pick, which is correct here
precisely because no typechecked double must satisfy it (fakes arrive through
`emit`). A key that is not a real Socket method now fails to compile.

The settle-exactly-once test was theater: it passed with the implementation's
guard deleted, because its own listenerCount check meant the second signal was
never delivered. A native Promise settles once regardless, so resolve-count
could never have tested anything. What the guard actually protects is
finish()'s side effects, so the test now drives the real double-finish path
(close callback reports failure while the server also emits close) and asserts
the shared-cleanup entry is deregistered once. Verified: fails with the guard
removed, 2 instead of 1.

The teardown in serve.ts — the half that actually stops the orphan, as
distinct from the socket severing — had no test and was unreachable from one.
Extracted as finishHttpServe using the exit/log seams ServeOptions already
provided, with three tests covering disconnect-before-exit ordering, a
throwing disconnect, and a disconnect outliving the deadline. The exit-once
guard is inert in production (process.exit does not return) and load-bearing
for the seam.

Signal matrix re-run after the refactor, unchanged: SIGINT 15ms and 11ms with
a connection held, SIGTERM 143 both ways. verify 33/33 green, typecheck clean,
55 tests pass across the touched suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 06:21:26 +08:00
Garry TanandClaude Opus 5 b6a03ff234 v0.42.68.0 fix(serve,import): stop the SIGINT orphan; quote imported frontmatter
Post-hoc fixes for defects in three PRs that were merged without review.

serve --http ignored the first SIGINT and left an orphaned process holding
the PGLite write lock. The port was released, so the server looked stopped
while the old process stayed alive and the next write could block on a lock
nobody appeared to hold. Two causes: close() waits for open connections to
drain and nothing severed them (one attached SSE stream wedged it forever),
and this path skips the teardown seam that disconnects the engine and exits.
Track live sockets and destroy them once close() has stopped the listener,
then disconnect the engine raced against CLEANUP_DEADLINE_MS before exiting.
Bun 1.3.x ships closeAllConnections()/closeIdleConnections() as no-op stubs,
so manual tracking is the only portable teardown. SIGTERM was unaffected and
stays on its existing path.

Measured, master vs fixed: SIGINT idle hung with an orphan -> exit 0 in 23ms;
SIGINT with a connection open hung with an orphan -> exit 0 in 57ms; SIGTERM
unchanged at 143. Adds a socket-severing test and a settle-exactly-once test;
both are change-detectors, since a fake whose close() always succeeds cannot
observe the real hang.

The conversation importer interpolated one frontmatter value unquoted while
quoting its neighbour, under a comment asserting that every value was quoted.
A value carrying unusual characters could reshape the frontmatter of the page
being written, and could cost the page its provenance fields with no error.
Quote it, keep an absent date as a real null, and pin both shapes with a
regression test that fails without the fix.

Also narrows three type definitions in serve-http.ts back to what the code
actually consumes. They had been widened and cast through `as unknown as T`
to clear a typecheck failure, which disabled checking of those tests
entirely; one of the three had no consumers at all. Verified the constraint
now binds: removing a fake's method, changing a field's type, and dropping a
callback parameter each fail typecheck, and each compiled clean before.

Filed #3614 separately for a pre-existing root cause found while auditing
this: the shared progress reporter installs a SIGINT listener that never
exits, which disables Ctrl-C for bulk commands that install no handler of
their own. Not addressed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 18:38:25 -07:00
9 changed files with 366 additions and 24 deletions
+36
View File
@@ -2,6 +2,42 @@
All notable changes to GBrain will be documented in this file.
## [0.42.68.2] - 2026-07-31
**If you run `gbrain serve --http`, Ctrl-C now stops it. Until this release the first Ctrl-C was ignored and left a process running in the background holding your brain's write lock.**
Pressing Ctrl-C released the port, so the server looked stopped and a new one would start on the same port. But the old process stayed alive, and because it never disconnected from the brain it kept the PGLite write lock. The next command that needed to write could then block or fail on a lock held by a process you thought you had closed. If a dashboard tab was open at the time, shutdown never completed at all. Two things caused it: closing the server waited for open connections to finish and nothing ever ended them, and this particular startup path skipped the teardown step that disconnects the brain and exits. Both are fixed, and shutdown now completes in well under a second whether or not anything is connected. `SIGTERM`, which supervisors and container runtimes use, was never affected.
Conversation imports now quote every frontmatter value taken from the source file. A value containing unusual characters could previously reshape the frontmatter of the page being written, and in the worst case the page lost its provenance fields — the record of where it came from — without reporting a problem. Values are quoted consistently now, and an absent date is still written as a real empty value rather than text.
Internally, three type definitions in the HTTP server were narrowed to describe only what the code actually uses. They had been widened to accommodate test code, in a way that stopped the compiler from checking those tests at all.
## To take advantage of v0.42.68.2
1. **Clear any leftover server process.** Anything started before this release may still be running. The brain's lock file names the process holding it, and is the reliable way to check — `gbrain doctor` does **not** detect this and will report the brain healthy while a leftover process still holds it:
```bash
cat ~/.gbrain/brain.pglite/.gbrain-lock/lock
```
No such file means nothing is holding the brain and you are done. Otherwise it prints JSON naming the `pid` and the exact `command`. What matters is whether that process is still alive:
```bash
ps -p <pid>
```
**Nothing listed** — the lock is stale. GBrain reclaims a stale lock by itself on the next command, so there is nothing to do.
**A process listed** — that is the leftover. Stop it:
```bash
kill <pid>
```
`kill` is enough; `SIGTERM` shutdown was never affected by this bug. The lock file may still exist afterwards, which is expected and harmless — once the recorded process is gone, the next command reclaims it.
Note that `pgrep -f "gbrain serve"` will not find a server started from a source checkout (`bun src/cli.ts serve`), which is why the lock file is the check to trust.
2. **If you imported conversations with `scripts/envelope-to-gbrain.mjs`,** re-run the import over the same envelopes and re-sync. Pages whose frontmatter was reshaped will be rewritten correctly; pages that were already fine are unchanged.
```bash
node scripts/envelope-to-gbrain.mjs <envelope.mve.json> <outDir>
gbrain sync
```
To check first, look for conversation pages missing their `source` or `origin` fields — those are the ones worth re-importing.
## [0.42.68.1] - 2026-07-30
**If you run `gbrain reindex-frontmatter` or `gbrain backfill` on the default embedded database, they now work. Until this release both failed every time, after waiting 30 seconds.**
+1 -1
View File
@@ -1 +1 @@
0.42.68.1
0.42.68.2
+1 -1
View File
@@ -147,7 +147,7 @@
"bun": ">=1.3.10"
},
"license": "MIT",
"version": "0.42.68.1",
"version": "0.42.68.2",
"overrides": {
"@hono/node-server": "^2.0.5",
"fast-uri": "^3.1.4",
+12 -5
View File
@@ -69,7 +69,10 @@ for (const [i, c] of conversations.entries()) {
// stops the two from disagreeing about whether an id exists.
const hasId = typeof c.id === 'string' && c.id.trim() !== '';
const convId = hasId ? c.id.trim() : `conv-${i + 1}`;
const name = `${date || '0000-00-00'}-${slug(convId, `conv-${i + 1}`)}.md`;
// `date` is third-party, exactly like `convId`, so it gets the same slug()
// treatment. Interpolating it raw let a `created_at` of `../…` resolve the
// join below outside outDir and write there.
const name = `${slug(date, '0000-00-00')}-${slug(convId, `conv-${i + 1}`)}.md`;
// gbrain reads YAML frontmatter + markdown body; keep provenance in frontmatter.
// Emit `type: conversation` so gbrain stores these as conversation pages rather
// than defaulting to the generic `concept`. gbrain is open-typed — it takes an
@@ -79,11 +82,15 @@ for (const [i, c] of conversations.entries()) {
const front = [
'---',
'type: conversation',
// Every interpolated value below is quoted. An envelope is a third-party
// file, so any string carrying a newline would otherwise close its scalar
// and inject arbitrary frontmatter keys into the page gbrain ingests — or
// duplicate an existing key, which makes the parse throw and silently
// strips every provenance field from the page.
`title: ${JSON.stringify(c.title || 'Untitled conversation')}`,
`date: ${date || 'null'}`,
// Every interpolated value is quoted. An envelope is a third-party file, so
// a provider string carrying a newline would otherwise close this scalar and
// inject arbitrary frontmatter keys into the page gbrain ingests.
// `date` is the first 10 chars of the envelope's `created_at`; 10 is plenty
// to smuggle a newline plus a short key. Absent stays an unquoted YAML null.
`date: ${date ? JSON.stringify(date) : 'null'}`,
`source: ${JSON.stringify(env.meta?.source_provider || 'unknown')}`,
// Omit the key entirely when the envelope carries no id, rather than
// emitting the literal `undefined` or a synthesized `conv-N` — the positional
+50 -7
View File
@@ -11,8 +11,8 @@
*/
import express from 'express';
import type { Socket } from 'net';
import type { Request, Response, NextFunction } from 'express';
import type { Server as HttpServer } from 'http';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import rateLimit from 'express-rate-limit';
@@ -57,10 +57,35 @@ import { registerCleanup } from '../core/process-cleanup.ts';
*/
export const HEALTH_TIMEOUT_MS = 3000;
/** Exported so tests can type their structural fakes exactly (#3599). */
export type HttpServerLifecycle = Pick<HttpServer, 'listening' | 'once' | 'off' | 'close'>;
/** Exported so tests can type their structural fakes exactly (#3599). */
export type SignalSource = Pick<NodeJS.Process, 'once' | 'off'>;
/**
* The narrowest contract this module actually consumes: subscribe, unsubscribe.
* Every return value is discarded, so it is `unknown` rather than `this` — a
* `Pick<>` of the full Node types would demand a fidelity no caller needs and
* no test double can honestly provide.
*/
type EventSubscriber = {
once(event: string, listener: (...args: any[]) => void): unknown;
off(event: string, listener: (...args: any[]) => void): unknown;
};
/**
* Only what socket teardown needs. This one IS a `Pick` of the real type, on
* purpose: no typechecked test double has to satisfy it (fakes reach it through
* `emit`, which is untyped), so binding it to `net.Socket` costs nothing and
* buys drift detection. A hand-written structural shape here would be an
* unchecked assertion — method parameters are bivariant, so annotating the
* listener param would match our own declaration whatever a real socket does.
*/
type TrackedSocket = Pick<Socket, 'destroy' | 'once'>;
type HttpServerLifecycle = EventSubscriber & {
readonly listening: boolean;
close(callback?: (error?: Error) => void): unknown;
// Narrowed to the one event this module subscribes with `on`, so the listener
// parameter is genuinely checked against TrackedSocket. A `(...args: any[])`
// signature here would make the annotation at the call site an unchecked
// assertion — the same defect this file was just cleaned of.
on(event: 'connection', listener: (socket: TrackedSocket) => void): unknown;
};
type SignalSource = EventSubscriber;
type CleanupRegistrar = typeof registerCleanup;
/**
@@ -79,6 +104,17 @@ export function waitForHttpServerLifecycle(
const signals = options.signals ?? process;
const register = options.register ?? registerCleanup;
// `close()` stops the listener and then waits for every open connection to
// drain. One attached admin-SSE EventSource — or any keep-alive socket —
// holds it open forever, so shutdown has to sever them itself. Bun 1.3.x
// ships `closeAllConnections()`/`closeIdleConnections()` as no-op stubs, so
// tracking is the only portable teardown.
const sockets = new Set<TrackedSocket>();
server.on('connection', (socket: TrackedSocket) => {
sockets.add(socket);
socket.once('close', () => sockets.delete(socket));
});
return new Promise<void>((resolve, reject) => {
let settled = false;
let closePromise: Promise<void> | null = null;
@@ -94,6 +130,9 @@ export function waitForHttpServerLifecycle(
if (error) closeReject(error);
else closeResolve();
});
// After close() so the listener stops accepting first, then in-flight
// connections are severed rather than waited on.
for (const socket of sockets) socket.destroy();
});
return closePromise;
};
@@ -202,8 +241,12 @@ export type ProbeHealthResult =
| { ok: true; status: 200; body: { status: 'ok'; version: string; engine: string; [k: string]: unknown } }
| { ok: false; status: 503; body: { error: 'service_unavailable'; error_description: string } };
/** Exported so tests can type their structural fakes exactly (#3598). */
export type AdminSseResponse = Pick<Response, 'setHeader' | 'flushHeaders' | 'write'>;
/** Narrowest contract the handshake consumes; see {@link EventSubscriber}. */
type AdminSseResponse = {
setHeader(name: string, value: string): unknown;
flushHeaders(): void;
write(chunk: string): unknown;
};
/**
* Complete the admin EventSource handshake immediately.
+55
View File
@@ -84,6 +84,59 @@ export interface ServeOptions {
bootTimeoutMs?: number;
}
/**
* Teardown for the HTTP serve path, reached once the server lifecycle resolves.
*
* `serve` deliberately skips both `finishCliTeardown` and the force-exit seam,
* so simply returning here leaves the never-disconnected engine's handles
* keeping an orphaned process alive — port released, but the PID still owning
* the PGLite write lock, which blocks every later CLI write. Disconnect first
* (checkpoint / pool drain) so the store is not left needing recovery, raced
* against the same deadline the stdio path uses in case a wedged WASM close
* would otherwise trap us.
*
* Extracted and seam-injected because this — not the socket severing in
* serve-http.ts — is the half that actually closes the orphan, and it was
* previously unreachable from a test.
*
* ponytail: on SIGTERM this races process-cleanup's own exit(143) and loses,
* because that path does not await a disconnect. That is the outcome we want.
* Plumb a settle-reason through `runServeHttp` if it ever needs to be
* guaranteed rather than merely reliable.
*/
export async function finishHttpServe(
engine: Pick<BrainEngine, 'disconnect'>,
opts: Pick<ServeOptions, 'exit' | 'log'> & { deadlineMs?: number } = {},
): Promise<void> {
const exit = opts.exit ?? ((code?: number) => process.exit(code));
const log = opts.log ?? ((msg: string) => console.error(msg));
const deadlineMs = opts.deadlineMs ?? CLEANUP_DEADLINE_MS;
let exited = false;
const exitOnce = (code: number) => {
if (exited) return;
exited = true;
exit(code);
};
const deadline = setTimeout(() => {
log(`GBrain MCP server: cleanup deadline (${deadlineMs}ms) exceeded — forcing exit`);
exitOnce(0);
}, deadlineMs);
deadline.unref?.();
try {
await engine.disconnect();
} catch (err: unknown) {
log(`GBrain MCP server: cleanup error: ${err instanceof Error ? err.message : String(err)}`);
}
clearTimeout(deadline);
// `process.exit` never returns, so the guard is inert in production. It
// matters for the injected seam: a disconnect that outlives the deadline
// must not exit a second time.
exitOnce(0);
}
export async function runServe(
engine: BrainEngine,
args: string[] = [],
@@ -144,6 +197,8 @@ export async function runServe(
const { runServeHttp } = await import('./serve-http.ts');
await runServeHttp(engine, { port, tokenTtl, enableDcr, enableDcrInsecure, publicUrl, logFullParams, bind, suppressBootstrapToken, printAdminToken });
await finishHttpServe(engine, opts);
return;
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from 'bun:test';
import { openAdminSseStream, type AdminSseResponse } from '../src/commands/serve-http.ts';
import { openAdminSseStream } from '../src/commands/serve-http.ts';
describe('admin SSE handshake', () => {
test('flushes a protocol-valid comment immediately after the headers', () => {
@@ -19,7 +19,7 @@ describe('admin SSE handshake', () => {
calls.push(`write:${String(chunk)}`);
return true;
},
} as unknown as AdminSseResponse);
});
expect(headers).toEqual(new Map([
['Content-Type', 'text/event-stream'],
+82 -2
View File
@@ -3,7 +3,7 @@
* provenance frontmatter, citation-bearing bodies, and loud collision handling.
*/
import { afterAll, describe, expect, test } from 'bun:test';
import { mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { mkdirSync, mkdtempSync, rmSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
// The same parser gbrain uses to ingest frontmatter (src/core/markdown.ts), so
@@ -72,7 +72,7 @@ describe('envelope-to-gbrain importer', () => {
expect(result.exitCode).toBe(0);
expect(page).toContain('type: conversation');
expect(page).toContain('title: "Onboarding Checklist Draft"');
expect(page).toContain('date: 2025-11-02');
expect(page).toContain('date: "2025-11-02"');
expect(page).toContain('source: "chatgpt"');
expect(page).toContain('memvelope_conversation_id: "c-3f9a2b"');
expect(page).toContain('origin: memvelope/envelope-v0');
@@ -223,4 +223,84 @@ describe('envelope-to-gbrain importer', () => {
expect(parsed.type).toBe('conversation');
expect(parsed.source).toBe('chatgpt\ntype: injected\nowner: attacker');
});
// `source` was hardened while `date` — derived from the same third-party
// envelope, in the line directly above it — was left unquoted. Both halves of
// the injection surface are pinned now so a future edit can't reopen one.
test.each([
['injects a new key', '1\nowner: z'],
['duplicates an existing key', 'x\ntype: a'],
])('a created_at that %s cannot alter the frontmatter', async (_label, createdAt) => {
const inputDir = tempDir();
const envelopePath = join(inputDir, 'injecting-created-at.mve.json');
// `date` is created_at.slice(0, 10) — 10 chars is plenty for a newline plus
// a short key. The duplicate-key case is the nastier of the two: it makes
// the YAML parse throw, so the page loses every provenance field silently.
writeFileSync(envelopePath, JSON.stringify({
memvelope: 'envelope-v0',
meta: { source_provider: 'chatgpt' },
conversations: [
{
id: 'c-date',
title: 'Date injection attempt',
created_at: createdAt,
messages: [{ id: 'm1', role: 'user', ts: '2025-11-02T14:22:51.000Z', text: 'alice-example sent a hostile created_at.' }],
},
],
}));
const result = await runImporter(envelopePath);
const page = readOnlyMarkdown(result.outDir);
const frontmatter = page.split('---')[1] ?? '';
const parsed = yamlSafeLoad(frontmatter) as Record<string, unknown>;
expect(result.exitCode).toBe(0);
expect(Object.keys(parsed).sort()).toEqual([
'date',
'memvelope_conversation_id',
'origin',
'source',
'title',
'type',
]);
// Still the real values — proves the parse succeeded rather than the
// frontmatter having been reduced to the injected subset.
expect(parsed.type).toBe('conversation');
expect(parsed.date).toBe(createdAt.slice(0, 10));
});
// `created_at` also prefixes the FILENAME, and `join(outDir, name)` resolves
// `../` — so hardening only the frontmatter left the same untrusted value
// able to write outside the output directory entirely.
test('a created_at containing path separators cannot write outside outDir', async () => {
const inputDir = tempDir();
const parent = tempDir();
const outDir = join(parent, 'outdir');
const sibling = join(parent, 'victim');
mkdirSync(outDir);
mkdirSync(sibling); // must exist, or the escape fails on ENOENT for the wrong reason
const envelopePath = join(inputDir, 'traversing-created-at.mve.json');
writeFileSync(envelopePath, JSON.stringify({
memvelope: 'envelope-v0',
meta: { source_provider: 'chatgpt' },
conversations: [
{
id: 'c-trav',
title: 'Traversal attempt',
created_at: '../victim/p',
messages: [{ id: 'm1', role: 'user', ts: '2025-11-02T14:22:51.000Z', text: 'alice-example sent a traversing created_at.' }],
},
],
}));
const result = await runImporter(envelopePath, outDir);
expect(result.exitCode).toBe(0);
expect(readdirSync(sibling)).toEqual([]);
expect(markdownFiles(outDir)).toHaveLength(1);
// Separators are slugged away rather than the write being rejected, so the
// conversation is still imported — just inside outDir where it belongs.
expect(markdownFiles(outDir)[0]).not.toContain('/');
});
});
+127 -6
View File
@@ -1,16 +1,19 @@
import { describe, expect, test } from 'bun:test';
import { EventEmitter } from 'events';
import { waitForHttpServerLifecycle, type HttpServerLifecycle } from '../src/commands/serve-http.ts';
import { waitForHttpServerLifecycle } from '../src/commands/serve-http.ts';
import { finishHttpServe } from '../src/commands/serve.ts';
class FakeHttpServer extends EventEmitter {
listening = true;
closeCalls = 0;
/** Real servers can fail the close callback and still emit 'close'. */
closeError: Error | undefined;
close(callback?: (error?: Error) => void): this {
this.closeCalls++;
this.listening = false;
queueMicrotask(() => {
callback?.();
callback?.(this.closeError);
this.emit('close');
});
return this;
@@ -25,8 +28,8 @@ describe('HTTP server lifecycle', () => {
let deregistered = false;
let resolved = false;
const lifecycle = waitForHttpServerLifecycle(server as unknown as HttpServerLifecycle, {
signals: signals as unknown as NodeJS.Process,
const lifecycle = waitForHttpServerLifecycle(server, {
signals,
register(_name, fn) {
cleanup = fn;
return () => { deregistered = true; };
@@ -49,8 +52,8 @@ describe('HTTP server lifecycle', () => {
const server = new FakeHttpServer();
const signals = new EventEmitter();
const lifecycle = waitForHttpServerLifecycle(server as unknown as HttpServerLifecycle, {
signals: signals as unknown as NodeJS.Process,
const lifecycle = waitForHttpServerLifecycle(server, {
signals,
register() {
return () => {};
},
@@ -61,4 +64,122 @@ describe('HTTP server lifecycle', () => {
expect(server.closeCalls).toBe(1);
});
// The shipped hang: `close()` waits for open connections to drain, and an
// attached admin-SSE stream never drains. A fake whose close() always
// succeeds on the next microtask cannot observe that, so pin the teardown
// itself — this is a change-detector for the severing, and the real proof is
// a spawned-process signal run.
test('severs live connections so close() cannot block on them', async () => {
const server = new FakeHttpServer();
const signals = new EventEmitter();
const live = { destroyed: false, destroy() { this.destroyed = true; }, once() {} };
const gone = { destroyed: false, destroy() { this.destroyed = true; }, once(_e: string, cb: () => void) { cb(); } };
const lifecycle = waitForHttpServerLifecycle(server, {
signals,
register() { return () => {}; },
});
server.emit('connection', live);
server.emit('connection', gone); // deregisters itself immediately via 'close'
signals.emit('SIGINT');
await lifecycle;
expect(live.destroyed).toBe(true);
// Already-closed sockets are dropped from the set, so shutdown does not
// touch them — destroying a dead socket is harmless but the bookkeeping
// leaking would not be.
expect(gone.destroyed).toBe(false);
});
// A native Promise already settles once, so asserting resolve-count proves
// nothing about the `settled` guard. What the guard actually protects is
// finish()'s SIDE EFFECTS — deregistering the shared-cleanup entry, and
// detaching listeners. Deregistering twice removes an entry a later caller
// may have re-registered.
//
// The real double-finish path: SIGINT calls closeServer(); the close callback
// reports an error (rejecting that promise, whose .catch routes to onError)
// while the server also emits 'close' (routing to onClose). Both reach
// finish() from the same close.
test('runs shutdown side effects once when close both fails and emits close', async () => {
const server = new FakeHttpServer();
server.closeError = new Error('close reported a failure');
const signals = new EventEmitter();
let deregisterCalls = 0;
let settlements = 0;
const lifecycle = waitForHttpServerLifecycle(server, {
signals,
register() { return () => { deregisterCalls++; }; },
}).then(() => { settlements++; }, () => { settlements++; });
signals.emit('SIGINT');
await lifecycle;
// Let the rejected closeServer promise deliver its .catch(onError) — the
// second finish() attempt lands here, after the first already settled.
await new Promise((r) => setTimeout(r, 0));
expect(deregisterCalls).toBe(1);
expect(settlements).toBe(1);
expect(server.closeCalls).toBe(1);
expect(server.listenerCount('close')).toBe(0);
expect(server.listenerCount('error')).toBe(0);
expect(signals.listenerCount('SIGINT')).toBe(0);
});
});
// Severing sockets lets close() finish; this is what actually stops the
// process. Without it the serve path returns to a caller that never tears the
// engine down, and the orphan keeps the PGLite write lock — which is the
// user-visible failure (every later CLI write is refused).
describe('HTTP serve teardown', () => {
const codes = () => {
const exits: number[] = [];
const logs: string[] = [];
return { exits, logs, opts: { exit: (c?: number) => { exits.push(c ?? 0); }, log: (m: string) => { logs.push(m); } } };
};
test('disconnects the engine before exiting', async () => {
const order: string[] = [];
const { exits, opts } = codes();
await finishHttpServe(
{ disconnect: async () => { order.push('disconnect'); } },
{ ...opts, exit: (c?: number) => { order.push('exit'); exits.push(c ?? 0); } },
);
// Disconnect FIRST — exiting before the checkpoint is what leaves a store
// needing recovery.
expect(order).toEqual(['disconnect', 'exit']);
expect(exits).toEqual([0]);
});
test('still exits when disconnect throws, and says why', async () => {
const { exits, logs, opts } = codes();
await finishHttpServe(
{ disconnect: async () => { throw new Error('pool already destroyed'); } },
opts,
);
expect(exits).toEqual([0]);
expect(logs.join('\n')).toContain('pool already destroyed');
});
test('exits exactly once when disconnect outlives the deadline', async () => {
const { exits, logs, opts } = codes();
let release: (() => void) | undefined;
const wedged = new Promise<void>((r) => { release = r; });
const done = finishHttpServe({ disconnect: () => wedged }, { ...opts, deadlineMs: 5 });
await new Promise((r) => setTimeout(r, 30)); // deadline fires here
expect(exits).toEqual([0]);
expect(logs.join('\n')).toContain('cleanup deadline');
release!(); // the wedged disconnect finally returns
await done;
// A second exit here would be the bug: production's process.exit never
// returns, so this path is only reachable through the injected seam.
expect(exits).toEqual([0]);
});
});