mirror of
https://github.com/garrytan/gbrain.git
synced 2026-08-14 00:48:18 +00:00
* fix(doctor): honor the recorded pid_file in supervisor_singleton check `gbrain doctor`'s `supervisor_singleton` check (#1849) compares the local pidfile holder against the queue-scoped DB lock holder. It read `readSupervisorPid(DEFAULT_PID_FILE)` unconditionally, even though the supervisor's own 'started' audit event already records the pid-file path actually in use (`this.opts.pidFile`). A supervisor launched with a custom `--pid-file` (e.g. a launchd-managed deployment) would then get a false "singleton mismatch" warning against its own healthy, single instance, because the pidfile doctor read was never the one the supervisor wrote. - doctor.ts now prefers `lastStarted.pid_file` when present, falling back to `DEFAULT_PID_FILE` for events that predate the field. - supervisor.ts resolves `pid_file` to an absolute path at emit time (the only cwd context in which a relative `--pid-file` is meaningful), so a later reader running from a different cwd doesn't misresolve it. The process's own internal pidfile guard/read/write paths are untouched. The DB lock (`gbrain_cycle_locks`) remains the sole singleton authority per #1849 — this only corrects which pidfile the diagnostic display reads. Added test/doctor-supervisor-singleton-pidfile.test.ts covering the fixed path, the still-mismatching absent-pidfile case, and a source-grep pin for the compatibility fallback. * docs(comments): correct pid_file fallback rationale and drop unsupported claims The 'started' audit event has carried pid_file since the supervisor's introduction, so the fallback comment no longer claims a predate case; DEFAULT_PID_FILE is env-overridable (GBRAIN_SUPERVISOR_PID_FILE), so the comments stop calling it HOME-derived; and the custom --pid-file scenario is now described as an example rather than asserted as common. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0126D3zLWL5RE3CVxnPANiiU --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
137 lines
6.4 KiB
TypeScript
137 lines
6.4 KiB
TypeScript
/**
|
|
* Regression test: `gbrain doctor`'s `supervisor_singleton` check should read
|
|
* the pid-file path that was used at supervisor start (recorded on the
|
|
* 'started' audit event as `pid_file` — `this.opts.pidFile` in
|
|
* `MinionSupervisor.start()`), not blindly re-derive `DEFAULT_PID_FILE`
|
|
* locally.
|
|
*
|
|
* Pre-fix, `doctor.ts` read `readSupervisorPid(DEFAULT_PID_FILE).pid` even
|
|
* though the 'started' event already carried the real path. A supervisor
|
|
* launched with a custom `--pid-file` (e.g. a launchd/systemd unit passing an
|
|
* explicit pidfile path) could then produce a false "singleton mismatch" warn against its
|
|
* own live DB lock, because the pidfile doctor.ts checked was never the one
|
|
* the supervisor actually wrote.
|
|
*
|
|
* #1849 established the queue-scoped DB lock as the real singleton
|
|
* authority — `supervisor_singleton` is a diagnostic display derived from
|
|
* that lock, not a second enforcement mechanism. This fix only corrects
|
|
* which pidfile the display reads; it does not touch the DB lock itself.
|
|
*/
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from 'bun:test';
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { withEnv } from './helpers/with-env.ts';
|
|
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
|
|
import { resetPgliteState } from './helpers/reset-pglite.ts';
|
|
import { buildChecks } from '../src/commands/doctor.ts';
|
|
import { supervisorLockId } from '../src/core/minions/supervisor.ts';
|
|
import { computeSupervisorAuditFilename } from '../src/core/minions/handlers/supervisor-audit.ts';
|
|
|
|
let engine: PGLiteEngine;
|
|
|
|
beforeAll(async () => {
|
|
engine = new PGLiteEngine();
|
|
await engine.connect({});
|
|
await engine.initSchema();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await engine.disconnect();
|
|
});
|
|
|
|
let auditDir: string;
|
|
let pidFileDir: string;
|
|
|
|
beforeEach(async () => {
|
|
await resetPgliteState(engine);
|
|
await engine.executeRaw(`DELETE FROM gbrain_cycle_locks`);
|
|
auditDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-supervisor-singleton-audit-'));
|
|
pidFileDir = fs.mkdtempSync(path.join(os.tmpdir(), 'doctor-supervisor-singleton-pidfile-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
try { fs.rmSync(auditDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
try { fs.rmSync(pidFileDir, { recursive: true, force: true }); } catch { /* best-effort */ }
|
|
});
|
|
|
|
function writeStartedEvent(fields: Record<string, unknown>): void {
|
|
const file = path.join(auditDir, computeSupervisorAuditFilename());
|
|
const row = { event: 'started', ts: new Date().toISOString(), supervisor_pid: process.pid, ...fields };
|
|
fs.writeFileSync(file, `${JSON.stringify(row)}\n`, 'utf8');
|
|
}
|
|
|
|
async function holdLiveLock(queue: string, holderPid: number, holderHost: string): Promise<void> {
|
|
await engine.executeRaw(
|
|
`INSERT INTO gbrain_cycle_locks (id, holder_pid, holder_host, acquired_at, ttl_expires_at, last_refreshed_at)
|
|
VALUES ($1, $2, $3, now(), now() + interval '30 minutes', now())`,
|
|
[supervisorLockId(queue), holderPid, holderHost],
|
|
);
|
|
}
|
|
|
|
async function findSingletonCheck() {
|
|
const checks = await buildChecks(engine, []);
|
|
return checks.find((c) => c.name === 'supervisor_singleton');
|
|
}
|
|
|
|
describe('doctor supervisor_singleton — honors the recorded pid_file (custom --pid-file)', () => {
|
|
test('custom --pid-file matching the live DB lock holder → single, not mismatch', async () => {
|
|
await withEnv({ GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
// A pid-file at a path OTHER than DEFAULT_PID_FILE, holding the pid of
|
|
// this very test process (guaranteed alive via process.kill(pid, 0)).
|
|
const customPidFile = path.join(pidFileDir, 'custom-supervisor.pid');
|
|
fs.writeFileSync(customPidFile, String(process.pid), 'utf8');
|
|
|
|
// The 'started' event records that custom path, matching what
|
|
// MinionSupervisor.start() actually emits (this.opts.pidFile).
|
|
writeStartedEvent({ pid_file: customPidFile, queue: 'default', max_rss_mb: 512 });
|
|
|
|
// The DB lock (the real singleton authority per #1849) is held by
|
|
// this same (host, pid) — a single, healthy supervisor.
|
|
await holdLiveLock('default', process.pid, os.hostname());
|
|
|
|
const check = await findSingletonCheck();
|
|
expect(check).toBeDefined();
|
|
expect(check?.status).toBe('ok');
|
|
expect(check?.message).toContain('Single supervisor');
|
|
// Pre-fix this would have read DEFAULT_PID_FILE (which this test never
|
|
// writes to), gotten pid=null, and warned 'mismatch' instead.
|
|
expect(check?.message).not.toContain('mismatch');
|
|
});
|
|
});
|
|
|
|
test('pid_file that does not resolve to the lock holder → mismatch (an absent/wrong local pidfile still warns)', async () => {
|
|
await withEnv({ GBRAIN_AUDIT_DIR: auditDir }, async () => {
|
|
// pid_file recorded, but nothing is written at that path — readSupervisorPid
|
|
// resolves to pid=null, so the DB lock holder can't be locally confirmed.
|
|
const customPidFile = path.join(pidFileDir, 'never-written-supervisor.pid');
|
|
writeStartedEvent({ pid_file: customPidFile, queue: 'default', max_rss_mb: 512 });
|
|
|
|
await holdLiveLock('default', process.pid, os.hostname());
|
|
|
|
const check = await findSingletonCheck();
|
|
expect(check).toBeDefined();
|
|
expect(check?.status).toBe('warn');
|
|
expect(check?.message).toContain('singleton lock is held by');
|
|
});
|
|
});
|
|
});
|
|
|
|
// Compatibility fallback: a 'started' event from before this fix (or any
|
|
// caller that never recorded `pid_file`) has no such field. doctor.ts falls
|
|
// back to DEFAULT_PID_FILE for those — behavior-preserving relative to
|
|
// pre-fix. Pinned as a source-grep (rather than a behavioral run against the
|
|
// real DEFAULT_PID_FILE) because DEFAULT_PID_FILE is a module-load-time
|
|
// constant derived from $HOME; asserting against it directly would make the
|
|
// test's outcome depend on whatever real supervisor state happens to exist
|
|
// on the machine running the suite.
|
|
describe('doctor supervisor_singleton — pid_file fallback (source-grep)', () => {
|
|
test('falls back to DEFAULT_PID_FILE when the started event has no pid_file', async () => {
|
|
const source = await Bun.file(new URL('../src/commands/doctor.ts', import.meta.url)).text();
|
|
expect(source).toMatch(
|
|
/typeof lastStarted\.pid_file === 'string' && lastStarted\.pid_file\.length > 0\s*\n\s*\? lastStarted\.pid_file\s*\n\s*: DEFAULT_PID_FILE/,
|
|
);
|
|
expect(source).toContain('readSupervisorPid(pidFilePath)');
|
|
});
|
|
});
|