mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-07 05:56:21 -10:00
A second daemon can open another home's database without anyone noticing: on 2026-10-22 a test daemon inherited OPENRIG_DB, opened the parent's database, and its startup reconcile marked sessions detached there. Nothing said which database it had opened. Now a starting daemon warns, naming both paths, when its database selection resolves outside OPENRIG_HOME, and the advice names --db, db.path, OPENRIG_DB and RIGGED_DB so the operator can correct whichever override chose it. It also warns when another observed holder of the database is a live daemon. A matching daemon.json's path, mtime and recorded home are labelled as recorded, never as the live holder's current home. `rig status` shows the boot observations in text and in an optional `daemon.databaseWarnings` JSON field. Explicit overrides are still honoured, and startup, reconcile and writes continue: these are diagnostics only. No warning is not proof of exclusive use. Measured on macOS with Node 22: about 58 ms added at boot with no other holder, 79 ms with a live holder. Holder inspections are capped at four and reuse daemon.json, open-file and health evidence. No dependency, lock or schema. Cost: source +85/-11, tests +80/-0. No new refusal or error code.
This commit is contained in:
1 parent
514f68bafd
commit
7c19588d4e
6 files changed
+165
-11
No files matched your search
@@ -40,7 +40,7 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
// JSON mode: one object, same keys on every path; sections the command did not
|
||||
// (or could not) read are null rather than absent.
|
||||
const emit = (daemon: Record<string, unknown>, rest: Record<string, unknown> = {}): void => {
|
||||
console.log(JSON.stringify({ daemon, kernel: null, workspaceRoot: null, rigs: null, cmux: null, ...rest }, null, 2));
|
||||
console.log(JSON.stringify({ daemon: { ...daemon, ...(status.databaseWarnings ? { databaseWarnings: status.databaseWarnings } : {}) }, kernel: null, workspaceRoot: null, rigs: null, cmux: null, ...rest }, null, 2));
|
||||
};
|
||||
|
||||
const deps = depsOverride ?? {
|
||||
@@ -49,6 +49,7 @@ export function statusCommand(depsOverride?: StatusDeps): Command {
|
||||
};
|
||||
|
||||
const status = await getDaemonStatus(deps.lifecycleDeps);
|
||||
if (!json) for (const warning of status.databaseWarnings ?? []) console.log(`Warning: ${warning}`);
|
||||
|
||||
if (json && status.state !== "running") {
|
||||
// reachable: false = positively down (stopped, stale); null = not confirmed (unverified).
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface DaemonEventLoopEvidence {
|
||||
}
|
||||
|
||||
export interface DaemonStatus {
|
||||
databaseWarnings?: string[];
|
||||
/** RULING 1ae863d2 — C3 3-state semantics (canonical: daemon crash-cart-detect.ts, kept in
|
||||
* lockstep): "stopped" requires POSITIVE evidence (connection refused); a probe TIMEOUT or
|
||||
* other non-refusal failure is "unverified" — never a down assertion. */
|
||||
@@ -732,7 +733,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
if (hasExited()) throw new Error(`Daemon child ${pid} exited before startup completed`);
|
||||
};
|
||||
const healthzUrl = `http://${formatDaemonHostForUrl(probeHost)}:${port}/healthz`;
|
||||
type StartHealth = { pid?: unknown; bind?: { mode: "explicit" | "default"; hosts: string[]; tailscaleDetected: boolean } };
|
||||
type StartHealth = { databaseWarnings?: unknown; pid?: unknown; bind?: { mode: "explicit" | "default"; hosts: string[]; tailscaleDetected: boolean } };
|
||||
const readOwnedHealth = async (url: string): Promise<StartHealth | null> => {
|
||||
assertChild();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
@@ -764,6 +765,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
assertChild();
|
||||
lock.recordChild(pid!);
|
||||
let healthy = false;
|
||||
let databaseWarnings: string[] = [];
|
||||
let lastProbe = "health endpoint not responding";
|
||||
let unverified: string[] | null = null;
|
||||
for (let i = 0; i < HEALTHZ_RETRIES; i++) {
|
||||
@@ -771,6 +773,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
try {
|
||||
const body = await readOwnedHealth(healthzUrl);
|
||||
if (body) {
|
||||
databaseWarnings = readDatabaseWarnings(body.databaseWarnings);
|
||||
const bind = body.bind;
|
||||
if (!bind || !["explicit", "default"].includes(bind.mode) || !Array.isArray(bind.hosts)
|
||||
|| bind.hosts.length === 0 || !bind.hosts.every((host) => typeof host === "string" && host.length > 0)
|
||||
@@ -821,6 +824,7 @@ async function startOwnedDaemon(opts: StartOptions, deps: LifecycleDeps, lock: D
|
||||
removeMatchingState(deps, STATE_FILE, state);
|
||||
throw error;
|
||||
}
|
||||
for (const warning of databaseWarnings) console.error(`Warning: ${warning}`);
|
||||
if (unverifiedSecondary) {
|
||||
console.error(`Warning: daemon started (pid ${pid}), but its listener on ${unverified!.join(", ")} could not be verified: the check got no clear answer, not a refusal. Check /healthz on that address before relying on it.`);
|
||||
}
|
||||
@@ -1016,7 +1020,7 @@ export async function getDaemonStatus(
|
||||
const res = await probeHealthzWithSettle(deps, `${openrigUrl}/healthz`);
|
||||
const ev = await readHealthEvidence(res);
|
||||
const url = new URL(openrigUrl);
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop };
|
||||
return { state: "running", port: Number(url.port) || DEFAULT_PORT, host: url.hostname || DEFAULT_HOST, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", ...endpoint, ...probeErrorCodeOf(err) };
|
||||
@@ -1036,6 +1040,7 @@ export async function getDaemonStatus(
|
||||
healthy: ev.healthy,
|
||||
reason: ev.reason,
|
||||
eventLoop: ev.eventLoop,
|
||||
...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}),
|
||||
};
|
||||
} catch (err) {
|
||||
// 1ae863d2: the resolved home has NO daemon state — before asserting anything,
|
||||
@@ -1068,7 +1073,7 @@ export async function getDaemonStatus(
|
||||
try {
|
||||
const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`);
|
||||
const ev = await readHealthEvidence(res);
|
||||
return { state: "running", port: state.port, host, pid: state.pid, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop };
|
||||
return { state: "running", port: state.port, host, pid: state.pid, healthy: ev.healthy, reason: ev.reason, eventLoop: ev.eventLoop, ...(ev.databaseWarnings ? { databaseWarnings: ev.databaseWarnings } : {}) };
|
||||
} catch (err) {
|
||||
// 1ae863d2: refusal = positive down; anything else (timeout/wedged/blocked) = unverified.
|
||||
return isRefusedError(err) ? { state: "stopped" } : { state: "unverified", pid: state.pid, port: state.port, host, ...probeErrorCodeOf(err) };
|
||||
@@ -1079,6 +1084,7 @@ export async function getDaemonStatus(
|
||||
let healthy = false;
|
||||
let reason: DaemonStatus["reason"];
|
||||
let eventLoop: DaemonEventLoopEvidence | undefined;
|
||||
let databaseWarnings: string[] | undefined;
|
||||
let probeFailure: Pick<DaemonStatus, "probeErrorCode"> = {};
|
||||
try {
|
||||
const res = await probeHealthzWithSettle(deps, `http://${formatDaemonHostForUrl(host)}:${state.port}/healthz`);
|
||||
@@ -1086,6 +1092,7 @@ export async function getDaemonStatus(
|
||||
healthy = ev.healthy;
|
||||
reason = ev.reason;
|
||||
eventLoop = ev.eventLoop;
|
||||
databaseWarnings = ev.databaseWarnings;
|
||||
} catch (err) {
|
||||
// OPR.0.4.3.21 — pid alive but healthz timed out: the honest wedged-loop
|
||||
// signal. Report process-present/unhealthy (NOT "stopped") with the
|
||||
@@ -1095,7 +1102,7 @@ export async function getDaemonStatus(
|
||||
}
|
||||
|
||||
// pid alive = running (state file preserved either way)
|
||||
return { state: "running", port: state.port, host, pid: state.pid, healthy, reason, eventLoop, ...probeFailure };
|
||||
return { state: "running", port: state.port, host, pid: state.pid, healthy, reason, eventLoop, ...(databaseWarnings ? { databaseWarnings } : {}), ...probeFailure };
|
||||
}
|
||||
|
||||
/** #275 — keep the OS code of a failed health probe (absent for a timeout). */
|
||||
@@ -1123,6 +1130,10 @@ export function tailLogs(deps: LifecycleDeps, opts: { follow: boolean }): void {
|
||||
child.unref();
|
||||
}
|
||||
|
||||
function readDatabaseWarnings(value: unknown): string[] {
|
||||
return Array.isArray(value) && value.every(item => typeof item === "string") ? value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* OPR.0.4.3.21 — read event-loop wedge evidence from a healthz probe result.
|
||||
* A plain `{ ok: true }` (mocks, or a monitor-less daemon) → healthy with no
|
||||
@@ -1132,11 +1143,13 @@ export function tailLogs(deps: LifecycleDeps, opts: { follow: boolean }): void {
|
||||
*/
|
||||
async function readHealthEvidence(
|
||||
res: { ok: boolean; json?: () => Promise<unknown> },
|
||||
): Promise<{ healthy: boolean; reason?: DaemonStatus["reason"]; eventLoop?: DaemonEventLoopEvidence }> {
|
||||
): Promise<{ healthy: boolean; reason?: DaemonStatus["reason"]; eventLoop?: DaemonEventLoopEvidence; databaseWarnings?: string[] }> {
|
||||
if (!res.ok) return { healthy: false };
|
||||
if (!res.json) return { healthy: true };
|
||||
try {
|
||||
const body = (await res.json()) as { eventLoop?: Partial<DaemonEventLoopEvidence> } | null;
|
||||
const body = (await res.json()) as { eventLoop?: Partial<DaemonEventLoopEvidence>; databaseWarnings?: unknown } | null;
|
||||
const warnings = readDatabaseWarnings(body?.databaseWarnings);
|
||||
const context = warnings.length ? { databaseWarnings: warnings } : {};
|
||||
const el = body?.eventLoop;
|
||||
if (el && typeof el.healthy === "boolean" && typeof el.lagMeanMs === "number") {
|
||||
const eventLoop: DaemonEventLoopEvidence = {
|
||||
@@ -1147,9 +1160,10 @@ async function readHealthEvidence(
|
||||
healthy: el.healthy,
|
||||
};
|
||||
return el.healthy
|
||||
? { healthy: true, eventLoop }
|
||||
: { healthy: false, reason: "event-loop-starved", eventLoop };
|
||||
? { healthy: true, eventLoop, ...context }
|
||||
: { healthy: false, reason: "event-loop-starved", eventLoop, ...context };
|
||||
}
|
||||
return { healthy: true, ...context };
|
||||
} catch {
|
||||
// Body absent / not JSON — treat as plain healthy (res.ok already true).
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { lstatSync, readlinkSync, realpathSync } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { lstatSync, readFileSync, readlinkSync, realpathSync, statSync } from "node:fs";
|
||||
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||||
|
||||
/** Resolve existing path components, including a dangling final symlink, while
|
||||
@@ -66,3 +68,52 @@ export function resolveDaemonDbPath(explicitDb: string | undefined | null, openr
|
||||
}
|
||||
return dbPath;
|
||||
}
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
async function lsof(args: string[]): Promise<string> {
|
||||
try {
|
||||
return (await execFileAsync("lsof", args, { timeout: 1000, maxBuffer: 64 * 1024 })).stdout;
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
/** Boot-time diagnostics only, never exclusion or permission to share a DB.
|
||||
* Missing tools, inaccessible records and races leave ownership unknown. */
|
||||
export async function daemonDatabaseWarnings(dbPath: string, home: string, selectedBy?: string): Promise<string[]> {
|
||||
if (dbPath === ":memory:") return [];
|
||||
const warnings: string[] = [];
|
||||
let db: string;
|
||||
let resolvedHome: string;
|
||||
try {
|
||||
db = resolveForContainment(dbPath);
|
||||
resolvedHome = resolveForContainment(home);
|
||||
} catch { return warnings; }
|
||||
if (selectedBy && !isWithin(resolvedHome, db)) {
|
||||
warnings.push(`${selectedBy} selects database ${db} outside OPENRIG_HOME ${resolvedHome}. This explicit path is honoured. The effective daemon environment can come from --db, db.path, OPENRIG_DB or RIGGED_DB. Remove or correct the selecting override to use this home's database.`);
|
||||
}
|
||||
// The OS supplies actual open-file holders; daemon.json and the listener must
|
||||
// independently agree on the PID before a holder is described as a daemon.
|
||||
const holders = await lsof(["-t", "--", db]);
|
||||
const pids = [...new Set(holders.trim().split(/\s+/).map(Number))]
|
||||
.filter(pid => Number.isSafeInteger(pid) && pid > 0 && pid !== process.pid).slice(0, 4);
|
||||
const conflicts = await Promise.all(pids.map(async (pid) => {
|
||||
const files = await lsof(["-a", "-p", String(pid), "-d", "1,2", "-Fn"]);
|
||||
const homes = new Set([dirname(db)]);
|
||||
for (const line of files.split("\n")) {
|
||||
if (line.startsWith("n") && basename(line.slice(1)) === "daemon.log") homes.add(dirname(line.slice(1)));
|
||||
}
|
||||
for (const candidate of homes) {
|
||||
try {
|
||||
const recordPath = join(candidate, "daemon.json");
|
||||
const record = JSON.parse(readFileSync(recordPath, "utf8"));
|
||||
const modifiedAt = statSync(recordPath).mtime.toISOString();
|
||||
if (record.pid !== pid || typeof record.db !== "string" || resolveForContainment(record.db) !== db
|
||||
|| !Number.isInteger(record.port) || record.port < 1 || record.port > 65535) continue;
|
||||
const response = await fetch(`http://127.0.0.1:${record.port}/healthz`, { signal: AbortSignal.timeout(500) });
|
||||
if (!response.ok || (await response.json() as { pid?: unknown }).pid !== pid) continue;
|
||||
return `At startup: Another live daemon (pid ${pid}) had database ${db} open. Matching record ${recordPath}, modified ${modifiedAt}: recorded home ${resolveForContainment(candidate)} (current home unconfirmed). This daemon's OPENRIG_HOME is ${resolvedHome}. Startup and database writes continue; use separate database paths for independent instances.`;
|
||||
} catch { /* A stale/unreadable record or failed probe is not a live-daemon claim. */ }
|
||||
}
|
||||
return undefined;
|
||||
}));
|
||||
return [...warnings, ...conflicts.filter((message): message is string => message !== undefined)];
|
||||
}
|
||||
@@ -145,6 +145,8 @@ import { createRouteTimingMiddleware } from "./domain/route-timing-recorder.js";
|
||||
import { browserBoundary, type BrowserBoundaryOptions } from "./middleware/browser-boundary.js";
|
||||
|
||||
export interface AppDeps {
|
||||
/** Boot-time DB context observations, not a live exclusion guarantee. */
|
||||
databaseWarnings?: string[];
|
||||
proofSourceWatch?: import("./domain/proof/source-watch.js").ProofSourceWatch;
|
||||
/** S20 — effective bind plan for the health surface (absent = legacy body). */
|
||||
bindPlan?: { mode: "explicit" | "default"; hosts: string[]; tailscaleDetected: boolean; ignoredRoutingHost?: string };
|
||||
@@ -673,7 +675,8 @@ export function createApp(deps: AppDeps): Hono {
|
||||
const stamp = stampFields();
|
||||
// Self-report for the optional CLI effective-user check, not authentication.
|
||||
const euid = process.geteuid?.();
|
||||
const processIdentity = { pid: process.pid, ...(euid === undefined ? {} : { euid }) };
|
||||
const databaseContext = deps.databaseWarnings?.length ? { databaseWarnings: deps.databaseWarnings } : {};
|
||||
const processIdentity = { ...databaseContext, pid: process.pid, ...(euid === undefined ? {} : { euid }) };
|
||||
// 51-09 increment 3 (arch ruling 2e1b737f; local always-suffix rendering since
|
||||
// superseded by the 2026-08-27 root invariant) — expose the daemon's boot-reconciled
|
||||
// self-host id so the CLI edge can detect cross-host targets, self-resolve a
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { daemonDatabaseWarnings } from "./daemon-db-path.js";
|
||||
import { physicalSessionName } from "./domain/session-registry.js";
|
||||
import { makeWatchdogDeliver } from "./domain/watchdog-delivery.js";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -317,6 +318,9 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
// Only the explicit selection overrides a pane rc or the legacy PID-home
|
||||
// reader. The resolved default remains the writer/network-reader/session root.
|
||||
const dbPath = opts?.dbPath ?? ":memory:";
|
||||
const databaseWarnings = await daemonDatabaseWarnings(dbPath, OPENRIG_HOME,
|
||||
process.env.OPENRIG_DB === dbPath ? "OPENRIG_DB" : process.env.RIGGED_DB === dbPath ? "RIGGED_DB" : undefined);
|
||||
for (const warning of databaseWarnings) console.warn(`Warning: ${warning}`);
|
||||
const db = createDb(dbPath);
|
||||
migrate(db, ALL_MIGRATIONS);
|
||||
|
||||
@@ -1308,6 +1312,7 @@ export async function createDaemon(opts?: DaemonOptions): Promise<DaemonResult>
|
||||
|
||||
|
||||
const deps: AppDeps = {
|
||||
databaseWarnings,
|
||||
rigRepo,
|
||||
sessionRegistry,
|
||||
daemonLifecycleStore,
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { copyFileSync, existsSync, readFileSync, realpathSync, utimesSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { prepareHermeticEnv, type HermeticScaffold } from "./helpers/hermetic-env.js";
|
||||
import { findFreePort, runRig } from "./helpers/scenario-daemon.js";
|
||||
|
||||
const rigBin = resolve(dirname(fileURLToPath(import.meta.url)), "../../cli/dist/bin-wrapper.js");
|
||||
const fixture = () => prepareHermeticEnv({ baseEnv: { PATH: process.env.PATH, TERM: "xterm-256color" } });
|
||||
async function start(home: HermeticScaffold, db: string) {
|
||||
home.env.OPENRIG_DB = db;
|
||||
const result = await runRig(["daemon", "start", "--port", String(await findFreePort()), "--no-kernel"], home.env, rigBin, 60_000);
|
||||
expect(result.code, result.stdout + result.stderr).toBe(0);
|
||||
return result.stdout + result.stderr;
|
||||
}
|
||||
async function stop(home: HermeticScaffold) {
|
||||
await runRig(["daemon", "stop"], home.env, rigBin);
|
||||
home.cleanup();
|
||||
}
|
||||
|
||||
it("honours an outside OPENRIG_DB and explains it at start and in text/JSON status", async () => {
|
||||
const home = fixture();
|
||||
const db = join(home.stateDir, "outside.sqlite");
|
||||
try {
|
||||
const started = await start(home, db);
|
||||
expect(started).toContain("OPENRIG_DB");
|
||||
expect(started).not.toContain("Another live daemon");
|
||||
expect(started).toContain(realpathSync(home.openrigHome));
|
||||
expect(started).toContain(realpathSync(db));
|
||||
expect(existsSync(join(home.openrigHome, "openrig.sqlite"))).toBe(false);
|
||||
// Status describes the serving daemon, even if this shell selects a different DB.
|
||||
home.env.OPENRIG_DB = join(home.stateDir, "unused.sqlite");
|
||||
const text = await runRig(["status"], home.env, rigBin);
|
||||
expect(text.code, text.stdout + text.stderr).toBe(0);
|
||||
expect(text.stdout).toContain("OPENRIG_DB");
|
||||
expect(text.stdout).toContain(realpathSync(db));
|
||||
const json = await runRig(["status", "--json"], home.env, rigBin);
|
||||
expect(json.code, json.stdout + json.stderr).toBe(0);
|
||||
expect(JSON.parse(json.stdout).daemon.databaseWarnings.join("\n")).toContain(realpathSync(home.openrigHome));
|
||||
} finally { await stop(home); }
|
||||
}, 120_000);
|
||||
|
||||
it("warns when another live daemon has the same database open and still starts", async () => {
|
||||
const first = fixture();
|
||||
const second = fixture();
|
||||
const db = join(first.stateDir, "shared.sqlite");
|
||||
try {
|
||||
expect(await start(first, db)).not.toContain("Another live daemon");
|
||||
const pid = JSON.parse(readFileSync(join(first.openrigHome, "daemon.json"), "utf8")).pid;
|
||||
// A matching stale record beside the DB does not attest the live holder's home.
|
||||
const staleRecord = join(first.stateDir, "daemon.json");
|
||||
copyFileSync(join(first.openrigHome, "daemon.json"), staleRecord);
|
||||
const oldTime = new Date("2020-01-01T00:00:00Z");
|
||||
utimesSync(staleRecord, oldTime, oldTime);
|
||||
const started = await start(second, db);
|
||||
expect(started).toContain("Another live daemon");
|
||||
expect(started).toContain(String(pid));
|
||||
expect(started).toContain("current home unconfirmed");
|
||||
expect(started).toContain("2020-01-01T00:00:00.000Z");
|
||||
expect(started).toContain(staleRecord);
|
||||
expect(started).toContain(realpathSync(db));
|
||||
const status = await runRig(["status", "--json"], second.env, rigBin);
|
||||
expect(status.code, status.stdout + status.stderr).toBe(0);
|
||||
expect(JSON.parse(status.stdout).daemon.databaseWarnings.join("\n")).toContain("Another live daemon");
|
||||
} finally { await stop(second); await stop(first); }
|
||||
}, 120_000);
|
||||
|
||||
it("explains a --db override without promising that unsetting OPENRIG_DB resets it", async () => {
|
||||
const home = fixture();
|
||||
delete home.env.OPENRIG_DB;
|
||||
delete home.env.RIGGED_DB;
|
||||
const db = join(home.stateDir, "explicit.sqlite");
|
||||
try {
|
||||
const result = await runRig(["daemon", "start", "--port", String(await findFreePort()), "--no-kernel", "--db", db], home.env, rigBin, 60_000);
|
||||
expect(result.code, result.stdout + result.stderr).toBe(0);
|
||||
expect(result.stderr).toContain("--db, db.path, OPENRIG_DB or RIGGED_DB");
|
||||
expect(result.stderr).toContain("Remove or correct the selecting override");
|
||||
expect(result.stderr).not.toContain("Unset OPENRIG_DB to use");
|
||||
} finally { await stop(home); }
|
||||
}, 120_000);
|
||||
Reference in new issue
Block a user