* fix(#1008): tolerate EAGAIN + short writes in io output()/error() The I/O Module wrote stdout/stderr with a bare fs.writeSync(fd, data), assuming it blocks until the kernel accepts every byte. That is false when the fd is a non-blocking pipe (as under the parallel node:test runner on Linux CI): a full pipe throws EAGAIN and a partially-drained pipe returns a short count. The former caused spurious failures (e.g. bug-974 graphify property test threw EAGAIN); the latter risked silently truncating output. Add writeAllSync(fd, data): loop on short counts and retry EAGAIN/EINTR with a bounded backoff. The backoff sleep buffer is allocated lazily on the first retry (rare) and reused — keeping it out of module load avoids perturbing the SharedArrayBuffer-allocation accounting in perf-316 and costs nothing on the common no-retry path. Route output() and error() through it; non-transient errors (EPIPE) still propagate. Mirrors the transient-errno handling already applied to STATE.md lock acquisition (ACQUIRE_LOCK_RETRY_ERRNOS / #3776). Regression cases live in tests/io.test.cjs (the owning module's file, per the regression-test-name placement policy) and inject fs.writeSync via mock.method: EAGAIN/EINTR retry, short-write no-truncation, EPIPE still surfaces, and error() retries while still exit(1). Red against the pre-fix bare-writeSync io.cjs. * chore(#1008): add Fixed changeset for io EAGAIN/short-write fix
232 lines
8.8 KiB
TypeScript
232 lines
8.8 KiB
TypeScript
/**
|
|
* CLI I/O primitives — output(), error(), ERROR_REASON, JSON-error mode,
|
|
* and the temp-file helpers that output() depends on.
|
|
*
|
|
* Extracted from core.cts (ADR-857 rollout phase 1 / issue #859).
|
|
* The hand-written bodies are preserved byte-for-behaviour; only the module
|
|
* boundary moved. core.cts re-exports every symbol here under its own
|
|
* `export =` object so existing consumers are unaffected.
|
|
*
|
|
* New imports should pull I/O primitives from io.cjs directly.
|
|
*/
|
|
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { platformWriteSync, platformEnsureDir } from './shell-command-projection.cjs';
|
|
|
|
// ─── Temp-file helpers (needed by output()) ──────────────────────────────────
|
|
|
|
/**
|
|
* Dedicated GSD temp directory: path.join(os.tmpdir(), 'gsd').
|
|
* Created on first use. Keeps GSD temp files isolated from the system
|
|
* temp directory so reap scans only GSD files (#1975).
|
|
*/
|
|
const GSD_TEMP_DIR = path.join(os.tmpdir(), 'gsd');
|
|
|
|
function ensureGsdTempDir(): void {
|
|
platformEnsureDir(GSD_TEMP_DIR);
|
|
}
|
|
|
|
interface ReapOptions {
|
|
maxAgeMs?: number;
|
|
dirsOnly?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Remove stale gsd-* temp files/dirs older than maxAgeMs (default: 5 minutes).
|
|
* Runs opportunistically before each new temp file write to prevent unbounded accumulation.
|
|
* @param prefix - filename prefix to match (e.g., 'gsd-')
|
|
* @param opts
|
|
* @param opts.maxAgeMs - max age in ms before removal (default: 5 min)
|
|
* @param opts.dirsOnly - if true, only remove directories (default: false)
|
|
*/
|
|
function reapStaleTempFiles(prefix = 'gsd-', { maxAgeMs = 5 * 60 * 1000, dirsOnly = false }: ReapOptions = {}): void {
|
|
try {
|
|
ensureGsdTempDir();
|
|
const now = Date.now();
|
|
const entries = fs.readdirSync(GSD_TEMP_DIR);
|
|
for (const entry of entries) {
|
|
if (!entry.startsWith(prefix)) continue;
|
|
const fullPath = path.join(GSD_TEMP_DIR, entry);
|
|
try {
|
|
const stat = fs.statSync(fullPath);
|
|
if (now - stat.mtimeMs > maxAgeMs) {
|
|
if (stat.isDirectory()) {
|
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
|
} else if (!dirsOnly) {
|
|
fs.unlinkSync(fullPath);
|
|
}
|
|
}
|
|
} catch {
|
|
// File may have been removed between readdir and stat — ignore
|
|
}
|
|
}
|
|
} catch {
|
|
// Non-critical — don't let cleanup failures break output
|
|
}
|
|
}
|
|
|
|
// ─── Output helpers ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Transient write errnos. When stdout/stderr is a NON-BLOCKING pipe — as it is
|
|
* under the parallel `node --test` runner on Linux CI — a full pipe buffer makes
|
|
* `fs.writeSync` throw EAGAIN, and a signal can interrupt it with EINTR. Both
|
|
* clear on retry once the reader drains. This is the same transient class the
|
|
* STATE.md lock path already retries (ACQUIRE_LOCK_RETRY_ERRNOS, #3776); #1008.
|
|
*/
|
|
const WRITE_RETRY_ERRNOS = new Set(['EAGAIN', 'EINTR']);
|
|
|
|
// Bounded so a pathological never-draining fd cannot spin forever. Each retry
|
|
// yields the thread for ~1ms via Atomics.wait (the project's sync-sleep idiom —
|
|
// see clock.cts realClock.sleep), so the cap is ~1s of total back-pressure wait.
|
|
const WRITE_MAX_RETRIES = 1000;
|
|
const WRITE_RETRY_BACKOFF_MS = 1;
|
|
|
|
// Sleep buffer is lazily allocated on the FIRST back-pressure retry (rare — only
|
|
// when a non-blocking pipe is full) and then reused. Keeping it out of module
|
|
// load costs nothing on the overwhelmingly common no-retry path and avoids
|
|
// perturbing SharedArrayBuffer-allocation accounting in other modules (perf-316).
|
|
let _writeSleepBuf: Int32Array | null = null;
|
|
function backoffOnce(): void {
|
|
if (_writeSleepBuf === null) _writeSleepBuf = new Int32Array(new SharedArrayBuffer(4));
|
|
Atomics.wait(_writeSleepBuf, 0, 0, WRITE_RETRY_BACKOFF_MS);
|
|
}
|
|
|
|
/**
|
|
* Write the entire payload to `fd`, tolerating non-blocking-pipe back-pressure.
|
|
*
|
|
* `fs.writeSync` does NOT block on a non-blocking pipe: a full buffer throws
|
|
* EAGAIN, and a partially-drained buffer returns a SHORT count (fewer bytes than
|
|
* requested). The previous bare `fs.writeSync(fd, string)` call assumed it always
|
|
* blocked until the kernel accepted every byte — false under load, which both
|
|
* threw spurious errors and risked silently truncating output (#1008).
|
|
*
|
|
* This loops on short counts (advancing the offset) and retries EAGAIN/EINTR with
|
|
* a brief Atomics.wait backoff that yields the thread so the reader can drain.
|
|
* Non-transient errors (e.g. EPIPE) propagate unchanged.
|
|
*/
|
|
function writeAllSync(fd: number, data: string): void {
|
|
const buf = Buffer.from(data, 'utf8');
|
|
let offset = 0;
|
|
let retries = 0;
|
|
while (offset < buf.length) {
|
|
try {
|
|
offset += fs.writeSync(fd, buf, offset, buf.length - offset);
|
|
} catch (err) {
|
|
const code = (err as NodeJS.ErrnoException).code ?? '';
|
|
if (WRITE_RETRY_ERRNOS.has(code) && retries < WRITE_MAX_RETRIES) {
|
|
retries += 1;
|
|
backoffOnce();
|
|
continue;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
}
|
|
|
|
function output(result: unknown, raw: boolean, rawValue?: unknown): void {
|
|
let data: string;
|
|
if (raw && rawValue !== undefined) {
|
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
data = String(rawValue);
|
|
} else {
|
|
const json = JSON.stringify(result, null, 2);
|
|
// Large payloads exceed Claude Code's Bash tool buffer (~50KB).
|
|
// Write to tmpfile and output the path prefixed with @file: so callers can detect it.
|
|
if (json.length > 50000) {
|
|
reapStaleTempFiles();
|
|
ensureGsdTempDir();
|
|
const tmpPath = path.join(GSD_TEMP_DIR, `gsd-${Date.now()}.json`);
|
|
platformWriteSync(tmpPath, json);
|
|
data = '@file:' + tmpPath;
|
|
} else {
|
|
data = json;
|
|
}
|
|
}
|
|
// process.stdout.write() is async when stdout is a pipe — process.exit()
|
|
// can tear down the process before the reader consumes the buffer. writeAllSync
|
|
// pushes every byte synchronously (looping short counts, retrying EAGAIN/EINTR),
|
|
// and skipping process.exit() lets the event loop drain naturally.
|
|
writeAllSync(1, data);
|
|
}
|
|
|
|
/**
|
|
* Frozen enum of typed reason codes used by error() for structured errors.
|
|
* Each subcommand contributes its own codes; the enum exists so tests can
|
|
* assert against typed values instead of grepping stderr (#2974).
|
|
*
|
|
* Adding a new code:
|
|
* - Pick a snake_case lowercase value (the JSON wire form)
|
|
* - Group by subsystem prefix (CONFIG_*, SDK_*, etc)
|
|
* - Pass it to error(msg, ERROR_REASON.NEW_CODE) at the call site
|
|
*/
|
|
const ERROR_REASON = Object.freeze({
|
|
// config-get / config-set
|
|
CONFIG_KEY_NOT_FOUND: 'config_key_not_found',
|
|
CONFIG_NO_FILE: 'config_no_file',
|
|
CONFIG_PARSE_FAILED: 'config_parse_failed',
|
|
CONFIG_INVALID_KEY: 'config_invalid_key',
|
|
// SDK / gsd-tools dispatch
|
|
SDK_FAIL_FAST: 'sdk_fail_fast',
|
|
SDK_UNKNOWN_COMMAND: 'sdk_unknown_command',
|
|
SDK_MISSING_ARG: 'sdk_missing_arg',
|
|
// workflow / phase
|
|
PHASE_NOT_FOUND: 'phase_not_found',
|
|
SUMMARY_NO_PLANNING: 'summary_no_planning',
|
|
// graphify
|
|
GRAPHIFY_NO_GRAPH: 'graphify_no_graph',
|
|
GRAPHIFY_INVALID_QUERY: 'graphify_invalid_query',
|
|
// hooks
|
|
HOOKS_OPT_OUT: 'hooks_opt_out',
|
|
// security-scan
|
|
SECURITY_SCAN_FAILED: 'security_scan_failed',
|
|
// generic
|
|
USAGE: 'usage',
|
|
UNKNOWN: 'unknown',
|
|
});
|
|
|
|
type ErrorReasonValue = typeof ERROR_REASON[keyof typeof ERROR_REASON];
|
|
|
|
/**
|
|
* Process-level flag: when true, error() emits structured JSON to stderr
|
|
* instead of plain "Error: <message>" text. Set by gsd-tools.cjs when the
|
|
* CLI is invoked with `--json-errors`. Tests opt in to typed-IR error
|
|
* assertions by passing that flag and parsing the JSON.
|
|
*
|
|
* Default off so existing callers and human operators keep their plain-text
|
|
* diagnostics. The structured form is opt-in for tooling and tests (#2974).
|
|
*/
|
|
let _jsonErrorMode = false;
|
|
function setJsonErrorMode(v: unknown): void { _jsonErrorMode = !!v; }
|
|
function getJsonErrorMode(): boolean { return _jsonErrorMode; }
|
|
|
|
/**
|
|
* Emit an error and exit. When the second argument is provided it must be
|
|
* a value from ERROR_REASON; tests can assert on `result.reason`. When the
|
|
* process is in JSON-error mode, stderr receives `{ ok: false, reason,
|
|
* message }` so callers can parse it; otherwise stderr keeps the plain
|
|
* text form for human operators.
|
|
*/
|
|
function error(message: string, reason: ErrorReasonValue = ERROR_REASON.UNKNOWN): never {
|
|
if (_jsonErrorMode) {
|
|
const payload = JSON.stringify({ ok: false, reason, message }) + '\n';
|
|
writeAllSync(2, payload);
|
|
} else {
|
|
writeAllSync(2, 'Error: ' + message + '\n');
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
export = {
|
|
GSD_TEMP_DIR,
|
|
ensureGsdTempDir,
|
|
reapStaleTempFiles,
|
|
output,
|
|
ERROR_REASON,
|
|
setJsonErrorMode,
|
|
getJsonErrorMode,
|
|
error,
|
|
};
|