fix(discord): a native fs error never prints its path: CLI choke point and journal labels (#1873)
The discord CLI's top-level handler prints a native Node error that is not already a labelled refusal as 'cannot <syscall> <label> (<CODE>)'. The journal and approvals readers and writers name the file by label at the source, so conversation, control-board and webui callers get the label too. The session directory check moves to engine-pi.mjs as sessionContinues(). Exit codes and refusal codes are unchanged.
This commit is contained in:
1 parent
d9023e28db
commit
63e2a574af
6 files changed
+383
-55
No files matched your search
@@ -22,7 +22,7 @@
|
|||||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { DiscordError, quietRead } from "./errors.mjs";
|
import { DiscordError, quietFs, quietRead } from "./errors.mjs";
|
||||||
|
|
||||||
export const APPROVE_WORD = "approve";
|
export const APPROVE_WORD = "approve";
|
||||||
export const CUSTOM_ID_PREFIX = "approve:";
|
export const CUSTOM_ID_PREFIX = "approve:";
|
||||||
@@ -95,7 +95,11 @@ export function appendApproval(dir, entry) {
|
|||||||
if (!isObject(entry) || typeof entry.kind !== "string") throw new DiscordError("approvals: entry needs a kind", 1);
|
if (!isObject(entry) || typeof entry.kind !== "string") throw new DiscordError("approvals: entry needs a kind", 1);
|
||||||
const line = JSON.stringify(entry);
|
const line = JSON.stringify(entry);
|
||||||
if (line.includes("\n")) throw new DiscordError("approvals: line must not contain a newline", 1);
|
if (line.includes("\n")) throw new DiscordError("approvals: line must not contain a newline", 1);
|
||||||
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
|
try {
|
||||||
|
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
|
||||||
|
} catch (e) {
|
||||||
|
throw quietFs(e, "approvals.jsonl", "append to");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readApprovals(dir) {
|
export function readApprovals(dir) {
|
||||||
|
|||||||
@@ -46,17 +46,17 @@
|
|||||||
// 3 refused by a brake (STOP present or the binding held; a supervisor must
|
// 3 refused by a brake (STOP present or the binding held; a supervisor must
|
||||||
// not retry); 4 usage.
|
// not retry); 4 usage.
|
||||||
|
|
||||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSync } from "node:fs";
|
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||||
import { dirname, join, resolve } from "node:path";
|
import { dirname, join, resolve } from "node:path";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { DiscordError } from "./errors.mjs";
|
import { DiscordError, printable } from "./errors.mjs";
|
||||||
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
||||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
|
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
|
||||||
import { loadSeatSets } from "../../organization/src/library-seats.mjs";
|
import { loadSeatSets } from "../../organization/src/library-seats.mjs";
|
||||||
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
|
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
|
||||||
import { createRest } from "./rest.mjs";
|
import { createRest } from "./rest.mjs";
|
||||||
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
|
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
|
||||||
import { createEngine, buildPiArgs } from "./engine-pi.mjs";
|
import { createEngine, buildPiArgs, sessionContinues } from "./engine-pi.mjs";
|
||||||
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
|
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
|
||||||
import { createSetsparkApi } from "./setspark.mjs";
|
import { createSetsparkApi } from "./setspark.mjs";
|
||||||
import { assembleContext } from "./context.mjs";
|
import { assembleContext } from "./context.mjs";
|
||||||
@@ -114,7 +114,7 @@ function checkLibraryEngine(configDirectory, binding) {
|
|||||||
try {
|
try {
|
||||||
sets = loadSeatSets({ dir: configDirectory });
|
sets = loadSeatSets({ dir: configDirectory });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw new DiscordError(`discord-business-unreadable: ${err.message}`, 2);
|
throw new DiscordError(`discord-business-unreadable: ${printable(err, "a business file")}`, 2);
|
||||||
}
|
}
|
||||||
if (!sets.librarySeats.has(binding.seat)) return;
|
if (!sets.librarySeats.has(binding.seat)) return;
|
||||||
if (binding.engine.provider !== "mosaic-bus") {
|
if (binding.engine.provider !== "mosaic-bus") {
|
||||||
@@ -234,7 +234,7 @@ async function run(opts) {
|
|||||||
const promptFile = join(launch, "context.md");
|
const promptFile = join(launch, "context.md");
|
||||||
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
|
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
|
||||||
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
|
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
|
||||||
const continueSession = existsSync(sessionDir) && statSync(sessionDir).isDirectory() && hasJsonl(sessionDir);
|
const continueSession = sessionContinues(sessionDir);
|
||||||
|
|
||||||
const rest = createRest({ token, log: warn });
|
const rest = createRest({ token, log: warn });
|
||||||
const gw = await rest.getGatewayBot();
|
const gw = await rest.getGatewayBot();
|
||||||
@@ -272,7 +272,7 @@ async function run(opts) {
|
|||||||
clearInterval(processingWatch);
|
clearInterval(processingWatch);
|
||||||
exitCode = code;
|
exitCode = code;
|
||||||
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
|
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
|
||||||
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
|
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${printable(err)}`)).finally(() => {
|
||||||
cleanupPid();
|
cleanupPid();
|
||||||
process.exit(exitCode);
|
process.exit(exitCode);
|
||||||
});
|
});
|
||||||
@@ -317,8 +317,8 @@ async function run(opts) {
|
|||||||
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
|
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
|
||||||
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
|
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
appendReload(journalDir, { at, outcome: "refused", error: err.message });
|
appendReload(journalDir, { at, outcome: "refused", error: printable(err) });
|
||||||
warn(`reload refused, binding unchanged: ${err.message}`);
|
warn(`reload refused, binding unchanged: ${printable(err)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let reloadChain = Promise.resolve();
|
let reloadChain = Promise.resolve();
|
||||||
@@ -332,13 +332,6 @@ async function run(opts) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hasJsonl(dir) {
|
|
||||||
try {
|
|
||||||
return readdirSync(dir).some((f) => f.endsWith(".jsonl"));
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stop(opts) {
|
function stop(opts) {
|
||||||
const dataRoot = loadDataRoot(opts.config);
|
const dataRoot = loadDataRoot(opts.config);
|
||||||
@@ -413,7 +406,8 @@ main().then((code) => {
|
|||||||
if (code !== null) process.exit(code);
|
if (code !== null) process.exit(code);
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
|
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
|
||||||
warn(err.message);
|
// Never a native error's message: it carries the path (#1873).
|
||||||
|
warn(printable(err));
|
||||||
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
|
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
|
||||||
process.exit(code);
|
process.exit(code);
|
||||||
});
|
});
|
||||||
@@ -32,8 +32,9 @@
|
|||||||
// without the connector noticing: the contract is start(), prompt(), stop().
|
// without the connector noticing: the contract is start(), prompt(), stop().
|
||||||
|
|
||||||
import { spawn as nodeSpawn } from "node:child_process";
|
import { spawn as nodeSpawn } from "node:child_process";
|
||||||
|
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { DiscordError } from "./errors.mjs";
|
import { DiscordError, printable, quietFs } from "./errors.mjs";
|
||||||
import { enabledToolNames } from "./tools.mjs";
|
import { enabledToolNames } from "./tools.mjs";
|
||||||
import { assertProcessing, processingStatus } from "../../processing-control/src/state.mjs";
|
import { assertProcessing, processingStatus } from "../../processing-control/src/state.mjs";
|
||||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, runtime, runtimeGroupState, createRuntimeSignaller } from "../../processing-control/src/runtimes.mjs";
|
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, runtime, runtimeGroupState, createRuntimeSignaller } from "../../processing-control/src/runtimes.mjs";
|
||||||
@@ -51,6 +52,25 @@ export const TOOLS_EXTENSION = fileURLToPath(new URL("../extension/tools.mjs", i
|
|||||||
// Older name, kept for the tests and docs that still use it.
|
// Older name, kept for the tests and docs that still use it.
|
||||||
export const READONLY_TOOLS_EXTENSION = TOOLS_EXTENSION;
|
export const READONLY_TOOLS_EXTENSION = TOOLS_EXTENSION;
|
||||||
|
|
||||||
|
// Whether pi continues the session in sessionDir: it is a directory holding a
|
||||||
|
// .jsonl session file. A stat that fails names the binding's session directory,
|
||||||
|
// never its path (#1873); a listing that fails means a new session, as before.
|
||||||
|
export function sessionContinues(sessionDir) {
|
||||||
|
if (!existsSync(sessionDir)) return false;
|
||||||
|
let st;
|
||||||
|
try {
|
||||||
|
st = statSync(sessionDir);
|
||||||
|
} catch (e) {
|
||||||
|
throw quietFs(e, "the binding's session directory");
|
||||||
|
}
|
||||||
|
if (!st.isDirectory()) return false;
|
||||||
|
try {
|
||||||
|
return readdirSync(sessionDir).some((f) => f.endsWith(".jsonl"));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function buildPiArgs({ provider, model, thinking, sessionDir, appendSystemPromptFile, continueSession, tools = null }) {
|
export function buildPiArgs({ provider, model, thinking, sessionDir, appendSystemPromptFile, continueSession, tools = null }) {
|
||||||
const args = [...PI_FIXED_ARGS];
|
const args = [...PI_FIXED_ARGS];
|
||||||
if (tools) args.push("--no-builtin-tools", "--extension", TOOLS_EXTENSION, "--tools", enabledToolNames(tools).join(","));
|
if (tools) args.push("--no-builtin-tools", "--extension", TOOLS_EXTENSION, "--tools", enabledToolNames(tools).join(","));
|
||||||
@@ -393,9 +413,11 @@ export function createEngine({
|
|||||||
child.stderr.on("data", (chunk) => log(`pi: ${chunk.trimEnd()}`));
|
child.stderr.on("data", (chunk) => log(`pi: ${chunk.trimEnd()}`));
|
||||||
child.on("error", (err) => {
|
child.on("error", (err) => {
|
||||||
releaseRuntime();
|
releaseRuntime();
|
||||||
log(`engine spawn error: ${err.message}`);
|
// A spawn error's message carries the command's path (#1873).
|
||||||
state.exited = { code: null, signal: null, error: err.message };
|
const why = printable(err, "pi");
|
||||||
failAll("engine-down", `engine failed: ${err.message}`);
|
log(`engine spawn error: ${why}`);
|
||||||
|
state.exited = { code: null, signal: null, error: why };
|
||||||
|
failAll("engine-down", `engine failed: ${why}`);
|
||||||
onExit(state.exited);
|
onExit(state.exited);
|
||||||
});
|
});
|
||||||
child.on("exit", (code, signal) => {
|
child.on("exit", (code, signal) => {
|
||||||
|
|||||||
@@ -9,13 +9,39 @@ export const plainKey = (k) => (/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(String(k))
|
|||||||
// journal.mjs + errors.mjs (the queue test repo) stay self-contained. binding.mjs re-exports it.
|
// journal.mjs + errors.mjs (the queue test repo) stay self-contained. binding.mjs re-exports it.
|
||||||
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||||
|
|
||||||
// A read that fails names the file by label and the error code, never the
|
// A filesystem call that fails names the file by label, the call and the
|
||||||
// path Node puts in the message. The same error object is rethrown, so its
|
// error code, never the path Node puts in the message. The same error object
|
||||||
// type, code and the exit it maps to are unchanged (#1873).
|
// is rethrown, so its type, code and the exit it maps to are unchanged, and
|
||||||
export function quietRead(e, label) {
|
// it is marked so the CLI's top-level handler prints it as it is (#1873).
|
||||||
if (e && typeof e === "object" && typeof e.code === "string") e.message = `cannot read ${label} (${e.code})`;
|
const QUIET = Symbol("discord.quiet");
|
||||||
|
const SYSCALL = /^[a-z][a-z0-9_]{0,31}$/;
|
||||||
|
const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||||
|
const callOf = (e) => {
|
||||||
|
const word = typeof e.syscall === "string" ? e.syscall.split(" ")[0] : "";
|
||||||
|
return SYSCALL.test(word) ? word : "access";
|
||||||
|
};
|
||||||
|
const codeOf = (e) => (typeof e.code === "string" && CODE.test(e.code) ? e.code : "UNKNOWN");
|
||||||
|
export function quietFs(e, label, verb = null) {
|
||||||
|
if (e && typeof e === "object" && typeof e.code === "string") {
|
||||||
|
e.message = `cannot ${verb ?? callOf(e)} ${label} (${codeOf(e)})`;
|
||||||
|
e[QUIET] = true;
|
||||||
|
}
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
export const quietRead = (e, label) => quietFs(e, label, "read");
|
||||||
|
|
||||||
|
// What a top-level handler prints for an error (#1873). Our refusals and an
|
||||||
|
// error already labelled at its source print their own message. A native
|
||||||
|
// Node error (it carries a syscall, or a code and a path) that slipped past
|
||||||
|
// every source label prints only its call and code, never its message,
|
||||||
|
// which carries the path; `label` names the file when the error has one.
|
||||||
|
export function printable(err, label = "a discord file") {
|
||||||
|
if (err instanceof DiscordError || (err && err[QUIET] === true)) return String(err.message);
|
||||||
|
if (err && typeof err === "object" && (typeof err.syscall === "string" || (typeof err.code === "string" && typeof err.path === "string"))) {
|
||||||
|
return typeof err.path === "string" ? `cannot ${callOf(err)} ${label} (${codeOf(err)})` : `cannot ${callOf(err)} (${codeOf(err)})`;
|
||||||
|
}
|
||||||
|
return err && typeof err.message === "string" ? err.message : String(err);
|
||||||
|
}
|
||||||
|
|
||||||
export class DiscordError extends Error {
|
export class DiscordError extends Error {
|
||||||
constructor(message, exitCode = 2, details = undefined) {
|
constructor(message, exitCode = 2, details = undefined) {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
unlinkSync, writeFileSync, writeSync,
|
unlinkSync, writeFileSync, writeSync,
|
||||||
} from "node:fs";
|
} from "node:fs";
|
||||||
import { basename, join } from "node:path";
|
import { basename, join } from "node:path";
|
||||||
import { BINDING_NAME, DiscordError, quietRead } from "./errors.mjs";
|
import { BINDING_NAME, DiscordError, quietFs, quietRead } from "./errors.mjs";
|
||||||
|
|
||||||
// Where a refusal says a binding's journal is: its place under the data root
|
// Where a refusal says a binding's journal is: its place under the data root
|
||||||
// when the directory carries a valid binding name (bindingDataDir() makes it
|
// when the directory carries a valid binding name (bindingDataDir() makes it
|
||||||
@@ -46,7 +46,11 @@ export function ensureJournal(dir) {
|
|||||||
function appendLine(path, record) {
|
function appendLine(path, record) {
|
||||||
const line = JSON.stringify(record);
|
const line = JSON.stringify(record);
|
||||||
if (line.includes("\n")) throw new DiscordError("journal line must not contain a newline", 1);
|
if (line.includes("\n")) throw new DiscordError("journal line must not contain a newline", 1);
|
||||||
appendFileSync(path, line + "\n", { mode: 0o600 });
|
try {
|
||||||
|
appendFileSync(path, line + "\n", { mode: 0o600 });
|
||||||
|
} catch (e) {
|
||||||
|
throw quietFs(e, basename(path), "append to");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function readLines(path) {
|
function readLines(path) {
|
||||||
@@ -126,6 +130,10 @@ export function turnPath(dir, id) {
|
|||||||
return join(dir, "turns", `${id}.json`);
|
return join(dir, "turns", `${id}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A parse refusal or a failed read or write names the turn by its id, never by
|
||||||
|
// host path (PR #1916, #1873); a listed file's id is named only when it passes TURN_ID.
|
||||||
|
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
|
||||||
|
|
||||||
export function writeTurn(dir, id, record) {
|
export function writeTurn(dir, id, record) {
|
||||||
const path = turnPath(dir, id);
|
const path = turnPath(dir, id);
|
||||||
let fd;
|
let fd;
|
||||||
@@ -133,31 +141,46 @@ export function writeTurn(dir, id, record) {
|
|||||||
fd = openSync(path, "wx", 0o600);
|
fd = openSync(path, "wx", 0o600);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${id}`, 1);
|
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${id}`, 1);
|
||||||
throw err;
|
throw quietFs(err, turnLabel(String(id)), "write");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
|
try {
|
||||||
} finally {
|
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
|
||||||
closeSync(fd);
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw quietFs(err, turnLabel(String(id)), "write");
|
||||||
}
|
}
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A parse refusal names the turn by its id, never by host path (PR #1916); a
|
function readTurnFile(path, id) {
|
||||||
// listed file's id is named only when it passes TURN_ID.
|
let text;
|
||||||
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
|
try {
|
||||||
|
text = readFileSync(path, "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
throw quietRead(e, turnLabel(id));
|
||||||
|
}
|
||||||
|
return parseJsonFileText(text, path, turnLabel(id));
|
||||||
|
}
|
||||||
|
|
||||||
export function readTurn(dir, id) {
|
export function readTurn(dir, id) {
|
||||||
const path = turnPath(dir, id);
|
return readTurnFile(turnPath(dir, id), String(id));
|
||||||
return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listTurns(dir) {
|
export function listTurns(dir) {
|
||||||
const turns = join(dir, "turns");
|
const turns = join(dir, "turns");
|
||||||
if (!existsSync(turns)) return [];
|
if (!existsSync(turns)) return [];
|
||||||
return readdirSync(turns)
|
let names;
|
||||||
|
try {
|
||||||
|
names = readdirSync(turns);
|
||||||
|
} catch (e) {
|
||||||
|
throw quietRead(e, `the turns directory in ${journalLabel(dir)}`);
|
||||||
|
}
|
||||||
|
return names
|
||||||
.filter((f) => f.endsWith(".json"))
|
.filter((f) => f.endsWith(".json"))
|
||||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), turnLabel(f.slice(0, -".json".length))));
|
.map((f) => readTurnFile(join(turns, f), f.slice(0, -".json".length)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The daily ceiling counts admissions on the current UTC date. An admission
|
// The daily ceiling counts admissions on the current UTC date. An admission
|
||||||
@@ -202,21 +225,38 @@ function stopLock(dir, action) {
|
|||||||
const lock = join(dir, '.stop.lock');
|
const lock = join(dir, '.stop.lock');
|
||||||
try { mkdirSync(lock, { mode: 0o700 }); }
|
try { mkdirSync(lock, { mode: 0o700 }); }
|
||||||
catch { throw new DiscordError('STOP update is locked; refusing', 1); }
|
catch { throw new DiscordError('STOP update is locked; refusing', 1); }
|
||||||
try { return action(); } finally { rmdirSync(lock); }
|
try { return action(); } finally {
|
||||||
|
try { rmdirSync(lock); } catch (e) { throw quietFs(e, `.stop.lock in ${journalLabel(dir)}`, "remove"); }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A STOP write or removal that fails names STOP in the journal, never its path (#1873).
|
||||||
|
const stopLabel = (dir) => `STOP in ${journalLabel(dir)}`;
|
||||||
|
|
||||||
function appendStop(dir, reason) {
|
function appendStop(dir, reason) {
|
||||||
const path = stopPath(dir);
|
const path = stopPath(dir);
|
||||||
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
|
||||||
try {
|
try {
|
||||||
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
|
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||||
fsyncSync(fd);
|
try {
|
||||||
} finally {
|
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
|
||||||
closeSync(fd);
|
fsyncSync(fd);
|
||||||
|
} finally {
|
||||||
|
closeSync(fd);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
throw quietFs(e, stopLabel(dir), "write");
|
||||||
}
|
}
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeStop(dir) {
|
||||||
|
try {
|
||||||
|
unlinkSync(stopPath(dir));
|
||||||
|
} catch (e) {
|
||||||
|
throw quietFs(e, stopLabel(dir), "remove");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const PROCESSING_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
const PROCESSING_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||||
function processingStops(dir) {
|
function processingStops(dir) {
|
||||||
let fd;
|
let fd;
|
||||||
@@ -248,14 +288,13 @@ export function clearProcessingStop(dir, generation) {
|
|||||||
if (!rows?.length || rows.some(r => !r || Object.keys(r).sort().join(',') !== 'at,reason' ||
|
if (!rows?.length || rows.some(r => !r || Object.keys(r).sort().join(',') !== 'at,reason' ||
|
||||||
r.reason !== `processing:${generation}` || !Number.isFinite(Date.parse(r.at)) ||
|
r.reason !== `processing:${generation}` || !Number.isFinite(Date.parse(r.at)) ||
|
||||||
new Date(r.at).toISOString() !== r.at)) return false;
|
new Date(r.at).toISOString() !== r.at)) return false;
|
||||||
unlinkSync(stopPath(dir));
|
removeStop(dir);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearStop(dir) {
|
export function clearStop(dir) {
|
||||||
const path = stopPath(dir);
|
if (existsSync(stopPath(dir))) removeStop(dir);
|
||||||
if (existsSync(path)) unlinkSync(path);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The STOP lines as written, oldest first. A line that does not parse is
|
// The STOP lines as written, oldest first. A line that does not parse is
|
||||||
@@ -263,7 +302,13 @@ export function clearStop(dir) {
|
|||||||
// treated as ours.
|
// treated as ours.
|
||||||
export function readStop(dir) {
|
export function readStop(dir) {
|
||||||
if (!stopRequested(dir)) return [];
|
if (!stopRequested(dir)) return [];
|
||||||
return readFileSync(stopPath(dir), "utf8").split("\n").filter((l) => l.trim() !== "").map((l) => {
|
let text;
|
||||||
|
try {
|
||||||
|
text = readFileSync(stopPath(dir), "utf8");
|
||||||
|
} catch (e) {
|
||||||
|
throw quietRead(e, stopLabel(dir));
|
||||||
|
}
|
||||||
|
return text.split("\n").filter((l) => l.trim() !== "").map((l) => {
|
||||||
try {
|
try {
|
||||||
const v = JSON.parse(l);
|
const v = JSON.parse(l);
|
||||||
return v && typeof v === "object" && typeof v.reason === "string" ? v : { reason: null };
|
return v && typeof v === "object" && typeof v.reason === "string" ? v : { reason: null };
|
||||||
@@ -422,12 +467,16 @@ export function writePid(dir, pid, { now = Date.now(), identity = identityOf } =
|
|||||||
try {
|
try {
|
||||||
mkdirSync(lock, { mode: 0o700 });
|
mkdirSync(lock, { mode: 0o700 });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code !== "EEXIST") throw err;
|
if (err.code !== "EEXIST") throw quietFs(err, `run.lock in ${journalLabel(dir)}`, "create");
|
||||||
throw lockRefusal(dir, { identity });
|
throw lockRefusal(dir, { identity });
|
||||||
}
|
}
|
||||||
const tmp = join(lock, "owner.json.tmp");
|
const tmp = join(lock, "owner.json.tmp");
|
||||||
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
|
try {
|
||||||
renameSync(tmp, ownerPath(dir));
|
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
|
||||||
|
renameSync(tmp, ownerPath(dir));
|
||||||
|
} catch (e) {
|
||||||
|
throw quietFs(e, `run.lock/owner.json in ${journalLabel(dir)}`, "write");
|
||||||
|
}
|
||||||
// The gate: STOP written before this point (by `stop` or `unlock`) means
|
// The gate: STOP written before this point (by `stop` or `unlock`) means
|
||||||
// this claim must not stand, however it interleaved with an unlock.
|
// this claim must not stand, however it interleaved with an unlock.
|
||||||
if (stopRequested(dir)) {
|
if (stopRequested(dir)) {
|
||||||
@@ -454,7 +503,7 @@ export function unlock(dir, { beforeRemove = null, identity = identityOf, reason
|
|||||||
if (state === "unknown") throw new DiscordError(`refusing to unlock: pid ${rec.pid} is alive and its identity cannot be verified; nothing removed. Stop that process first`, 1);
|
if (state === "unknown") throw new DiscordError(`refusing to unlock: pid ${rec.pid} is alive and its identity cannot be verified; nothing removed. Stop that process first`, 1);
|
||||||
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, 1);
|
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, 1);
|
||||||
if (beforeRemove) beforeRemove();
|
if (beforeRemove) beforeRemove();
|
||||||
rmSync(lock, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
removeLock(dir, { maxRetries: 10, retryDelay: 20 });
|
||||||
return rec;
|
return rec;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,7 +551,15 @@ export function stopTarget(dir, opts) {
|
|||||||
|
|
||||||
export function clearPid(dir, pid) {
|
export function clearPid(dir, pid) {
|
||||||
const rec = readPid(dir);
|
const rec = readPid(dir);
|
||||||
if (rec !== null && !rec.invalid && rec.pid === pid) rmSync(lockPath(dir), { recursive: true, force: true });
|
if (rec !== null && !rec.invalid && rec.pid === pid) removeLock(dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeLock(dir, retry = {}) {
|
||||||
|
try {
|
||||||
|
rmSync(lockPath(dir), { recursive: true, force: true, ...retry });
|
||||||
|
} catch (e) {
|
||||||
|
throw quietFs(e, `run.lock in ${journalLabel(dir)}`, "remove");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- reloads ---
|
// --- reloads ---
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// #1873 (redact6, re-check 2): a native fs error never prints its path. The journal and
|
||||||
|
// approvals readers and writers name the file by label at the source, and the CLI's
|
||||||
|
// top-level handler prints any native error that slipped through as its call and code
|
||||||
|
// only. The sentinel is a component of every scratch path, so an error message that
|
||||||
|
// quotes a path quotes the sentinel too.
|
||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs, { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||||
|
import { syncBuiltinESMExports } from 'node:module';
|
||||||
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { appendApproval } from '../src/approvals.mjs';
|
||||||
|
import { DiscordError } from '../src/errors.mjs';
|
||||||
|
import {
|
||||||
|
appendInbox, clearStop, ensureJournal, listTurns, lockPath, readTurn, recover, requestStop, stopPath, turnPath, writePid, writeTurn,
|
||||||
|
} from '../src/journal.mjs';
|
||||||
|
import { makeDeployment, makeRepo } from './helpers.mjs';
|
||||||
|
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||||
|
|
||||||
|
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||||
|
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||||
|
|
||||||
|
// A journal directory named like a binding, under a sentinel path component, so its
|
||||||
|
// label is "discord/seat under the data root".
|
||||||
|
function scratch(t) {
|
||||||
|
const root = mkdtempSync(join(tmpdir(), 'r6n-'));
|
||||||
|
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||||
|
const dir = join(root, secret, 'seat');
|
||||||
|
mkdirSync(dir, { recursive: true });
|
||||||
|
return { root, dir };
|
||||||
|
}
|
||||||
|
const where = 'discord/seat under the data root';
|
||||||
|
function quiet(text, root) {
|
||||||
|
assert.ok(text.includes(root) === false, text);
|
||||||
|
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
||||||
|
}
|
||||||
|
function labelled(label, root) {
|
||||||
|
return error => {
|
||||||
|
assert.ok(error instanceof Error, String(error));
|
||||||
|
assert.equal(error instanceof DiscordError, false, 'a native fs error keeps its type');
|
||||||
|
assert.ok(error.message.includes(label), error.message);
|
||||||
|
quiet(error.message, root);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Swap one node:fs function so the target goes away right before the real call, which
|
||||||
|
// then fails with a genuine error that names the path. One swap per test.
|
||||||
|
function race(t, fn, target, remove) {
|
||||||
|
const original = fs[fn];
|
||||||
|
const swapped = function (p, ...rest) {
|
||||||
|
if (String(p) === target) remove();
|
||||||
|
return original.call(this, p, ...rest);
|
||||||
|
};
|
||||||
|
if (original.native) swapped.native = original.native;
|
||||||
|
fs[fn] = swapped;
|
||||||
|
syncBuiltinESMExports();
|
||||||
|
t.after(() => {
|
||||||
|
fs[fn] = original;
|
||||||
|
syncBuiltinESMExports();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- the four named sites ---
|
||||||
|
|
||||||
|
test('recover: a STOP removed between its check and its read names STOP in the journal', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
ensureJournal(dir);
|
||||||
|
writeFileSync(stopPath(dir), JSON.stringify({ at: new Date().toISOString(), reason: 'cli stop' }) + '\n', { mode: 0o600 });
|
||||||
|
race(t, 'readFileSync', stopPath(dir), () => rmSync(stopPath(dir)));
|
||||||
|
assert.throws(() => recover(dir), labelled(`cannot read STOP in ${where} (ENOENT)`, root));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readTurn: a turn record removed before its read names the turn by id', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
ensureJournal(dir);
|
||||||
|
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
|
||||||
|
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
|
||||||
|
assert.throws(() => readTurn(dir, 't1'), labelled('cannot read turn record t1 (ENOENT)', root));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listTurns: a turns directory removed between its check and its listing names the turns directory', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
ensureJournal(dir);
|
||||||
|
const turns = join(dir, 'turns');
|
||||||
|
race(t, 'readdirSync', turns, () => rmSync(turns, { recursive: true }));
|
||||||
|
assert.throws(() => listTurns(dir), labelled(`cannot read the turns directory in ${where} (ENOENT)`, root));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listTurns: a turn record removed between the listing and its read names the turn by id', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
ensureJournal(dir);
|
||||||
|
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
|
||||||
|
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
|
||||||
|
assert.throws(() => listTurns(dir), labelled('cannot read turn record t1 (ENOENT)', root));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("run: a session directory removed between its check and its stat names the binding's session directory", async t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
const { sessionContinues } = await import('../src/engine-pi.mjs');
|
||||||
|
assert.equal(typeof sessionContinues, 'function', 'engine-pi.mjs exports sessionContinues');
|
||||||
|
const session = join(dir, 'session');
|
||||||
|
assert.equal(sessionContinues(session), false, 'no directory: a new session');
|
||||||
|
mkdirSync(session);
|
||||||
|
assert.equal(sessionContinues(session), false, 'an empty directory: a new session');
|
||||||
|
writeFileSync(join(session, 'a.jsonl'), '');
|
||||||
|
assert.equal(sessionContinues(session), true, 'a .jsonl file: the session continues');
|
||||||
|
race(t, 'statSync', session, () => rmSync(session, { recursive: true }));
|
||||||
|
assert.throws(() => sessionContinues(session), labelled("cannot stat the binding's session directory (ENOENT)", root));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- journal and approvals writers (the connector logs err.message at runtime) ---
|
||||||
|
|
||||||
|
test('a journal line that cannot be appended names the journal file, never its path', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
ensureJournal(dir);
|
||||||
|
mkdirSync(join(dir, 'inbox.jsonl'));
|
||||||
|
assert.throws(() => appendInbox(dir, { id: '1' }), labelled('cannot append to inbox.jsonl (EISDIR)', root));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an approvals line that cannot be appended names approvals.jsonl, never its path', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
ensureJournal(dir);
|
||||||
|
mkdirSync(join(dir, 'approvals.jsonl'));
|
||||||
|
assert.throws(() => appendApproval(dir, { kind: 'opened' }), labelled('cannot append to approvals.jsonl (EISDIR)', root));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a turn record that cannot be created names the turn by id; an existing one keeps its refusal', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
assert.throws(() => writeTurn(dir, 't1', {}), labelled('cannot write turn record t1 (ENOENT)', root));
|
||||||
|
ensureJournal(dir);
|
||||||
|
writeTurn(dir, 't1', {});
|
||||||
|
assert.throws(() => writeTurn(dir, 't1', {}), e => e instanceof DiscordError && e.exitCode === 1 && /turn record already exists: t1/.test(e.message));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a STOP that cannot be written names STOP in the journal; the lock is released', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
mkdirSync(stopPath(dir));
|
||||||
|
assert.throws(() => requestStop(dir, 'cli stop'), labelled(`cannot write STOP in ${where} (EISDIR)`, root));
|
||||||
|
assert.equal(existsSync(join(dir, '.stop.lock')), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a STOP removed between its check and its removal names STOP in the journal', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
writeFileSync(stopPath(dir), '{}\n', { mode: 0o600 });
|
||||||
|
race(t, 'unlinkSync', stopPath(dir), () => rmSync(stopPath(dir)));
|
||||||
|
assert.throws(() => clearStop(dir), labelled(`cannot remove STOP in ${where} (ENOENT)`, root));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a run.lock that cannot be created names run.lock in the journal', t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
const gone = join(dir, 'gone');
|
||||||
|
assert.throws(() => writePid(gone, process.pid), e => {
|
||||||
|
assert.ok(e.message.includes('cannot create run.lock in discord/gone under the data root (ENOENT)'), e.message);
|
||||||
|
quiet(e.message, root);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
assert.equal(existsSync(lockPath(gone)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('engine: a pi that cannot be spawned is logged and recorded as "cannot spawn pi", never its path', async t => {
|
||||||
|
const { root, dir } = scratch(t);
|
||||||
|
const { createEngine } = await import('../src/engine-pi.mjs');
|
||||||
|
const logs = [];
|
||||||
|
let exited = null;
|
||||||
|
const engine = createEngine({ command: join(dir, 'pi'), args: [], cwd: dir, env: {}, log: (m) => logs.push(m), onExit: (e) => (exited = e) });
|
||||||
|
engine.start();
|
||||||
|
for (let i = 0; i < 200 && exited === null; i += 1) await new Promise((res) => setTimeout(res, 10));
|
||||||
|
assert.ok(exited, 'the spawn error reached onExit');
|
||||||
|
assert.equal(exited.error, 'cannot spawn pi (ENOENT)');
|
||||||
|
assert.ok(logs.includes('engine spawn error: cannot spawn pi (ENOENT)'), logs.join('\n'));
|
||||||
|
quiet(JSON.stringify({ logs, exited }), root);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- the choke point ---
|
||||||
|
|
||||||
|
test('printable: a native error prints its call and code, a refusal or a labelled error its own message', async () => {
|
||||||
|
const { printable, quietRead } = await import('../src/errors.mjs');
|
||||||
|
assert.equal(typeof printable, 'function', 'errors.mjs exports printable');
|
||||||
|
const path = `/x/${secret}/discord/seat/STOP`;
|
||||||
|
const native = Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), { code: 'ENOENT', syscall: 'open', path, errno: -2 });
|
||||||
|
assert.equal(printable(native), 'cannot open a discord file (ENOENT)');
|
||||||
|
const pathOnly = Object.assign(new Error(`EACCES ${path}`), { code: 'EACCES', path });
|
||||||
|
assert.equal(printable(pathOnly), 'cannot access a discord file (EACCES)');
|
||||||
|
const spawn = Object.assign(new Error(`spawn ${path} ENOENT`), { code: 'ENOENT', syscall: `spawn ${path}`, path });
|
||||||
|
assert.equal(printable(spawn), 'cannot spawn a discord file (ENOENT)');
|
||||||
|
const net = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:1'), { code: 'ECONNREFUSED', syscall: 'connect' });
|
||||||
|
assert.equal(printable(net), 'cannot connect (ECONNREFUSED)');
|
||||||
|
const odd = Object.assign(new Error(path), { code: `X ${path}`, syscall: path, path });
|
||||||
|
assert.equal(printable(odd), 'cannot access a discord file (UNKNOWN)');
|
||||||
|
const labelledRead = quietRead(Object.assign(new Error(path), { code: 'ENOENT', syscall: 'open', path }), 'token file');
|
||||||
|
assert.equal(printable(labelledRead), 'cannot read token file (ENOENT)');
|
||||||
|
assert.equal(printable(new DiscordError('STOP is present in discord/seat under the data root; remove it to run', 3)), 'STOP is present in discord/seat under the data root; remove it to run');
|
||||||
|
assert.equal(printable(new Error('plain refusal')), 'plain refusal');
|
||||||
|
for (const v of [native, pathOnly, spawn, odd]) assert.doesNotMatch(printable(v), /sk-test|NOT-A-SECRET/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cli: a native fs error reaching the top-level handler prints its call and code, never the path, and exits 1', t => {
|
||||||
|
const { root } = scratch(t);
|
||||||
|
const base = join(root, secret, 'dep');
|
||||||
|
mkdirSync(base, { recursive: true });
|
||||||
|
const repo = makeRepo(base);
|
||||||
|
const dep = makeDeployment(base);
|
||||||
|
initializeControl(dep.dataRoot);
|
||||||
|
const run = (args) => spawnSync(process.execPath, [cli, ...args, '--config', dep.config, '--repo', repo], { encoding: 'utf8' });
|
||||||
|
|
||||||
|
// stop: the journal directory is a regular file, so ensureJournal's mkdir fails.
|
||||||
|
writeFileSync(dep.journalDir, 'not a directory');
|
||||||
|
let r = run(['stop', 'test-seat']);
|
||||||
|
assert.equal(r.status, 1, r.stderr);
|
||||||
|
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
|
||||||
|
quiet(r.stderr + r.stdout, root);
|
||||||
|
rmSync(dep.journalDir);
|
||||||
|
|
||||||
|
// run: the sessions directory is a regular file, so the claimed run's mkdir fails
|
||||||
|
// after the claim; the claim is released and the exit stays 1.
|
||||||
|
writeFileSync(join(dep.dataRoot, 'sessions'), 'not a directory');
|
||||||
|
r = run(['run', 'test-seat']);
|
||||||
|
assert.equal(r.status, 1, r.stderr);
|
||||||
|
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
|
||||||
|
quiet(r.stderr + r.stdout, root);
|
||||||
|
assert.equal(existsSync(lockPath(dep.journalDir)), false, 'the claim is released');
|
||||||
|
});
|
||||||
Reference in new issue
Block a user