@stigmer/runner 3.0.8-dev.20260613071809 → 3.0.8-dev.20260613072749
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.build-fingerprint +1 -1
- package/dist/main.js +9 -4
- package/dist/main.js.map +1 -1
- package/dist/pipe-safety.d.ts +55 -0
- package/dist/pipe-safety.js +103 -0
- package/dist/pipe-safety.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/pipe-safety.test.ts +119 -0
- package/src/main.ts +10 -4
- package/src/pipe-safety.ts +117 -0
package/dist/.build-fingerprint
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"hash":"
|
|
1
|
+
{"hash":"b6dffd4185a32e0f","builtAt":"2026-06-13T07:29:17.517Z","fileCount":194}
|
package/dist/main.js
CHANGED
|
@@ -26,22 +26,27 @@ import { createStigmerRunner } from "./runner.js";
|
|
|
26
26
|
import { createStigmerRunnerManager } from "./runner-manager.js";
|
|
27
27
|
import { buildReadyMessage } from "./ipc-protocol.js";
|
|
28
28
|
import { handleUnhandledRejection } from "./activities/execute-cursor/rejection-capture.js";
|
|
29
|
+
import { installProcessPipeGuards, reportFatal } from "./pipe-safety.js";
|
|
30
|
+
// Guard the host pipes before anything writes to them. A dropped stderr/stdout
|
|
31
|
+
// reader otherwise turns the next write into an uncaught EPIPE loop that starves
|
|
32
|
+
// Temporal heartbeats and kills the in-flight execution (issue #177).
|
|
33
|
+
const { writeStdout, writeStderr } = installProcessPipeGuards();
|
|
29
34
|
process.on("unhandledRejection", (reason) => {
|
|
30
35
|
handleUnhandledRejection(reason);
|
|
31
36
|
});
|
|
32
37
|
process.on("uncaughtException", (err) => {
|
|
33
|
-
|
|
38
|
+
reportFatal(writeStderr, "Uncaught exception in runner:", err);
|
|
34
39
|
});
|
|
35
40
|
// ─── IPC Helpers ─────────────────────────────────────────────────────────────
|
|
36
41
|
function sendIpc(msg) {
|
|
37
|
-
|
|
42
|
+
writeStdout(JSON.stringify(msg) + "\n");
|
|
38
43
|
}
|
|
39
44
|
// ─── Manager Mode ────────────────────────────────────────────────────────────
|
|
40
45
|
async function runManagerMode(config) {
|
|
41
46
|
// In manager mode, redirect console.log to stderr so stdout is reserved for IPC
|
|
42
47
|
const originalLog = console.log;
|
|
43
48
|
console.log = (...args) => {
|
|
44
|
-
|
|
49
|
+
writeStderr(args.map(String).join(" ") + "\n");
|
|
45
50
|
};
|
|
46
51
|
let manager;
|
|
47
52
|
try {
|
|
@@ -286,7 +291,7 @@ async function main() {
|
|
|
286
291
|
}
|
|
287
292
|
}
|
|
288
293
|
main().catch((err) => {
|
|
289
|
-
|
|
294
|
+
reportFatal(writeStderr, "Fatal error in runner:", err);
|
|
290
295
|
process.exit(1);
|
|
291
296
|
});
|
|
292
297
|
//# sourceMappingURL=main.js.map
|
package/dist/main.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AACpD,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AAC3C,OAAO,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACrD,OAAO,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AAClD,OAAO,EAAE,0BAA0B,EAAE,MAAM,qBAAqB,CAAC;AAEjE,OAAO,EAAE,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAGtD,OAAO,EAAE,wBAAwB,EAA0B,MAAM,kDAAkD,CAAC;
|
|
1
|
+
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes the runner's stdout/stderr writes fail-safe so a dead host pipe degrades
|
|
3
|
+
* logging instead of killing execution.
|
|
4
|
+
*
|
|
5
|
+
* Background (issue #177): the runner is embedded as a subprocess whose stdout is
|
|
6
|
+
* the JSON IPC channel and whose stderr is the host's log channel. When the host
|
|
7
|
+
* drops a pipe's read end, the runner's next write raises an async `EPIPE` error
|
|
8
|
+
* event. With no `error` listener that event escalates to `uncaughtException` —
|
|
9
|
+
* and the uncaught handler logs to the very stream that just failed, raising EPIPE
|
|
10
|
+
* again. That re-entrant loop pegs the event loop at ~125% CPU, starves Temporal
|
|
11
|
+
* heartbeats, and the in-flight execution dies as "Activity task timed out" while
|
|
12
|
+
* the runner process still looks alive.
|
|
13
|
+
*
|
|
14
|
+
* The fix is one Node idiom: attach an `error` listener to each stream. Once a
|
|
15
|
+
* listener exists, EPIPE is delivered to it and can never escalate, which both
|
|
16
|
+
* breaks the loop and makes every raw `console.*` call in the codebase safe. The
|
|
17
|
+
* guarded writers below add the intent-revealing layer on top: once a pipe breaks
|
|
18
|
+
* we detach (writes become silent no-ops) rather than keep trying.
|
|
19
|
+
*
|
|
20
|
+
* See <https://github.com/stigmer/stigmer/issues/177>.
|
|
21
|
+
*/
|
|
22
|
+
import type { Writable } from "node:stream";
|
|
23
|
+
export declare function isBrokenPipeError(err: unknown): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Guards a single writable stream and returns a writer that can never crash the
|
|
26
|
+
* process. The returned writer no-ops once the stream's reader is gone; callers
|
|
27
|
+
* include their own trailing newline so the byte stream is unchanged on the happy
|
|
28
|
+
* path. `onUnexpectedError` reports a non-broken-pipe failure through a channel
|
|
29
|
+
* the caller knows is still safe (see {@link installProcessPipeGuards}).
|
|
30
|
+
*
|
|
31
|
+
* Pure with respect to the injected stream (no `process` coupling) so the
|
|
32
|
+
* detach-on-EPIPE behavior is unit-testable with a fake writable.
|
|
33
|
+
*/
|
|
34
|
+
export declare function guardStream(stream: Writable, onUnexpectedError?: (err: Error) => void): (chunk: string) => boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Installs broken-pipe guards on `process.stdout` and `process.stderr` and returns
|
|
37
|
+
* the safe writers for each. Idempotent — the first call wins so repeated imports
|
|
38
|
+
* never stack duplicate `error` listeners.
|
|
39
|
+
*
|
|
40
|
+
* Channel asymmetry is deliberate: stdout is the JSON IPC channel, so an
|
|
41
|
+
* unexpected stdout error is reported on stderr (the log channel); an unexpected
|
|
42
|
+
* stderr error has no safe place to go, so it detaches silently rather than
|
|
43
|
+
* corrupting the IPC stream with diagnostics.
|
|
44
|
+
*/
|
|
45
|
+
export declare function installProcessPipeGuards(): {
|
|
46
|
+
writeStdout: (chunk: string) => boolean;
|
|
47
|
+
writeStderr: (chunk: string) => boolean;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Reports a fatal/uncaught error through a guarded writer, guaranteeing the report
|
|
51
|
+
* itself can never throw. A fatal handler that throws while reporting is the exact
|
|
52
|
+
* re-entrancy trap behind issue #177, so the write is wrapped defensively even
|
|
53
|
+
* though the writer is already broken-pipe safe.
|
|
54
|
+
*/
|
|
55
|
+
export declare function reportFatal(write: (chunk: string) => boolean, label: string, err: unknown): void;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes the runner's stdout/stderr writes fail-safe so a dead host pipe degrades
|
|
3
|
+
* logging instead of killing execution.
|
|
4
|
+
*
|
|
5
|
+
* Background (issue #177): the runner is embedded as a subprocess whose stdout is
|
|
6
|
+
* the JSON IPC channel and whose stderr is the host's log channel. When the host
|
|
7
|
+
* drops a pipe's read end, the runner's next write raises an async `EPIPE` error
|
|
8
|
+
* event. With no `error` listener that event escalates to `uncaughtException` —
|
|
9
|
+
* and the uncaught handler logs to the very stream that just failed, raising EPIPE
|
|
10
|
+
* again. That re-entrant loop pegs the event loop at ~125% CPU, starves Temporal
|
|
11
|
+
* heartbeats, and the in-flight execution dies as "Activity task timed out" while
|
|
12
|
+
* the runner process still looks alive.
|
|
13
|
+
*
|
|
14
|
+
* The fix is one Node idiom: attach an `error` listener to each stream. Once a
|
|
15
|
+
* listener exists, EPIPE is delivered to it and can never escalate, which both
|
|
16
|
+
* breaks the loop and makes every raw `console.*` call in the codebase safe. The
|
|
17
|
+
* guarded writers below add the intent-revealing layer on top: once a pipe breaks
|
|
18
|
+
* we detach (writes become silent no-ops) rather than keep trying.
|
|
19
|
+
*
|
|
20
|
+
* See <https://github.com/stigmer/stigmer/issues/177>.
|
|
21
|
+
*/
|
|
22
|
+
/** Error codes that mean the downstream reader is gone — logging must degrade, not crash. */
|
|
23
|
+
const BROKEN_PIPE_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "ERR_STREAM_WRITE_AFTER_END"]);
|
|
24
|
+
export function isBrokenPipeError(err) {
|
|
25
|
+
const code = err?.code;
|
|
26
|
+
return typeof code === "string" && BROKEN_PIPE_CODES.has(code);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Guards a single writable stream and returns a writer that can never crash the
|
|
30
|
+
* process. The returned writer no-ops once the stream's reader is gone; callers
|
|
31
|
+
* include their own trailing newline so the byte stream is unchanged on the happy
|
|
32
|
+
* path. `onUnexpectedError` reports a non-broken-pipe failure through a channel
|
|
33
|
+
* the caller knows is still safe (see {@link installProcessPipeGuards}).
|
|
34
|
+
*
|
|
35
|
+
* Pure with respect to the injected stream (no `process` coupling) so the
|
|
36
|
+
* detach-on-EPIPE behavior is unit-testable with a fake writable.
|
|
37
|
+
*/
|
|
38
|
+
export function guardStream(stream, onUnexpectedError) {
|
|
39
|
+
let detached = false;
|
|
40
|
+
stream.on("error", (err) => {
|
|
41
|
+
// Any error means the stream is no longer usable; stop writing to it. The
|
|
42
|
+
// listener's existence is what prevents EPIPE from escalating to
|
|
43
|
+
// uncaughtException — that is the actual fix for issue #177.
|
|
44
|
+
const wasAttached = !detached;
|
|
45
|
+
detached = true;
|
|
46
|
+
// Report an unexpected (non-broken-pipe) failure at most once, on the first
|
|
47
|
+
// error — later errors are just noise from the same dead stream.
|
|
48
|
+
if (wasAttached && !isBrokenPipeError(err)) {
|
|
49
|
+
onUnexpectedError?.(err);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
return (chunk) => {
|
|
53
|
+
if (detached)
|
|
54
|
+
return false;
|
|
55
|
+
try {
|
|
56
|
+
return stream.write(chunk);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A synchronous throw (e.g. write-after-end) means the stream is unusable;
|
|
60
|
+
// detach so we never retry into the same failure.
|
|
61
|
+
detached = true;
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
let installed = null;
|
|
67
|
+
/**
|
|
68
|
+
* Installs broken-pipe guards on `process.stdout` and `process.stderr` and returns
|
|
69
|
+
* the safe writers for each. Idempotent — the first call wins so repeated imports
|
|
70
|
+
* never stack duplicate `error` listeners.
|
|
71
|
+
*
|
|
72
|
+
* Channel asymmetry is deliberate: stdout is the JSON IPC channel, so an
|
|
73
|
+
* unexpected stdout error is reported on stderr (the log channel); an unexpected
|
|
74
|
+
* stderr error has no safe place to go, so it detaches silently rather than
|
|
75
|
+
* corrupting the IPC stream with diagnostics.
|
|
76
|
+
*/
|
|
77
|
+
export function installProcessPipeGuards() {
|
|
78
|
+
if (installed)
|
|
79
|
+
return installed;
|
|
80
|
+
const writeStderr = guardStream(process.stderr);
|
|
81
|
+
const writeStdout = guardStream(process.stdout, (err) => {
|
|
82
|
+
writeStderr(`[pipe-safety] stdout (IPC) channel error, detaching: ${err.stack ?? err}\n`);
|
|
83
|
+
});
|
|
84
|
+
installed = { writeStdout, writeStderr };
|
|
85
|
+
return installed;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Reports a fatal/uncaught error through a guarded writer, guaranteeing the report
|
|
89
|
+
* itself can never throw. A fatal handler that throws while reporting is the exact
|
|
90
|
+
* re-entrancy trap behind issue #177, so the write is wrapped defensively even
|
|
91
|
+
* though the writer is already broken-pipe safe.
|
|
92
|
+
*/
|
|
93
|
+
export function reportFatal(write, label, err) {
|
|
94
|
+
try {
|
|
95
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
96
|
+
write(`${label} ${detail}\n`);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Intentionally empty: the reporter is the last line of defense and must
|
|
100
|
+
// never propagate.
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=pipe-safety.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"pipe-safety.js","sourceRoot":"","sources":["../src/pipe-safety.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AAIH,6FAA6F;AAC7F,MAAM,iBAAiB,GAAG,IAAI,GAAG,CAAC,CAAC,OAAO,EAAE,sBAAsB,EAAE,4BAA4B,CAAC,CAAC,CAAC;AAEnG,MAAM,UAAU,iBAAiB,CAAC,GAAY;IAC5C,MAAM,IAAI,GAAI,GAA6C,EAAE,IAAI,CAAC;IAClE,OAAO,OAAO,IAAI,KAAK,QAAQ,IAAI,iBAAiB,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;AACjE,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,UAAU,WAAW,CACzB,MAAgB,EAChB,iBAAwC;IAExC,IAAI,QAAQ,GAAG,KAAK,CAAC;IAErB,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAU,EAAE,EAAE;QAChC,0EAA0E;QAC1E,iEAAiE;QACjE,6DAA6D;QAC7D,MAAM,WAAW,GAAG,CAAC,QAAQ,CAAC;QAC9B,QAAQ,GAAG,IAAI,CAAC;QAChB,4EAA4E;QAC5E,iEAAiE;QACjE,IAAI,WAAW,IAAI,CAAC,iBAAiB,CAAC,GAAG,CAAC,EAAE,CAAC;YAC3C,iBAAiB,EAAE,CAAC,GAAG,CAAC,CAAC;QAC3B,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,OAAO,CAAC,KAAa,EAAW,EAAE;QAChC,IAAI,QAAQ;YAAE,OAAO,KAAK,CAAC;QAC3B,IAAI,CAAC;YACH,OAAO,MAAM,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QAC7B,CAAC;QAAC,MAAM,CAAC;YACP,2EAA2E;YAC3E,kDAAkD;YAClD,QAAQ,GAAG,IAAI,CAAC;YAChB,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED,IAAI,SAAS,GACX,IAAI,CAAC;AAEP;;;;;;;;;GASG;AACH,MAAM,UAAU,wBAAwB;IAItC,IAAI,SAAS;QAAE,OAAO,SAAS,CAAC;IAEhC,MAAM,WAAW,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAChD,MAAM,WAAW,GAAG,WAAW,CAAC,OAAO,CAAC,MAAM,EAAE,CAAC,GAAG,EAAE,EAAE;QACtD,WAAW,CAAC,wDAAwD,GAAG,CAAC,KAAK,IAAI,GAAG,IAAI,CAAC,CAAC;IAC5F,CAAC,CAAC,CAAC;IAEH,SAAS,GAAG,EAAE,WAAW,EAAE,WAAW,EAAE,CAAC;IACzC,OAAO,SAAS,CAAC;AACnB,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,WAAW,CAAC,KAAiC,EAAE,KAAa,EAAE,GAAY;IACxF,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,KAAK,IAAI,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAC/E,KAAK,CAAC,GAAG,KAAK,IAAI,MAAM,IAAI,CAAC,CAAC;IAChC,CAAC;IAAC,MAAM,CAAC;QACP,yEAAyE;QACzE,mBAAmB;IACrB,CAAC;AACH,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stigmer/runner",
|
|
3
|
-
"version": "3.0.8-dev.
|
|
3
|
+
"version": "3.0.8-dev.20260613072749",
|
|
4
4
|
"description": "Embeddable Temporal worker for the Stigmer AI agent platform — handles agent execution, workflow orchestration, and MCP server management",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -86,7 +86,7 @@
|
|
|
86
86
|
"@opentelemetry/resources": "^2.0.0",
|
|
87
87
|
"@opentelemetry/sdk-trace-base": "^2.0.0",
|
|
88
88
|
"@opentelemetry/sdk-trace-node": "^2.0.0",
|
|
89
|
-
"@stigmer/protos": "3.0.8-dev.
|
|
89
|
+
"@stigmer/protos": "3.0.8-dev.20260613072749",
|
|
90
90
|
"@temporalio/activity": "^1.11.0",
|
|
91
91
|
"@temporalio/client": "^1.11.0",
|
|
92
92
|
"@temporalio/common": "^1.11.0",
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import type { Writable } from "node:stream";
|
|
4
|
+
import { isBrokenPipeError, guardStream, reportFatal } from "../pipe-safety.js";
|
|
5
|
+
|
|
6
|
+
// Minimal stand-in for a writable host pipe: records writes and lets a test drive
|
|
7
|
+
// `error` events deterministically, which is exactly the EPIPE condition from #177.
|
|
8
|
+
class FakeWritable extends EventEmitter {
|
|
9
|
+
public writes: string[] = [];
|
|
10
|
+
public throwOnWrite: Error | null = null;
|
|
11
|
+
write(chunk: string): boolean {
|
|
12
|
+
if (this.throwOnWrite) throw this.throwOnWrite;
|
|
13
|
+
this.writes.push(chunk);
|
|
14
|
+
return true;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function asWritable(fake: FakeWritable): Writable {
|
|
19
|
+
return fake as unknown as Writable;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function pipeError(code: string): Error {
|
|
23
|
+
return Object.assign(new Error(code), { code });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("isBrokenPipeError", () => {
|
|
27
|
+
it.each([
|
|
28
|
+
["EPIPE", true],
|
|
29
|
+
["ERR_STREAM_DESTROYED", true],
|
|
30
|
+
["ERR_STREAM_WRITE_AFTER_END", true],
|
|
31
|
+
["ECONNRESET", false],
|
|
32
|
+
["ENOENT", false],
|
|
33
|
+
])("classifies code %s as %s", (code, expected) => {
|
|
34
|
+
expect(isBrokenPipeError(pipeError(code))).toBe(expected);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("is false for non-error / code-less values", () => {
|
|
38
|
+
expect(isBrokenPipeError(undefined)).toBe(false);
|
|
39
|
+
expect(isBrokenPipeError(null)).toBe(false);
|
|
40
|
+
expect(isBrokenPipeError(new Error("no code"))).toBe(false);
|
|
41
|
+
expect(isBrokenPipeError("EPIPE")).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("guardStream", () => {
|
|
46
|
+
it("writes through on the happy path", () => {
|
|
47
|
+
const fake = new FakeWritable();
|
|
48
|
+
const write = guardStream(asWritable(fake));
|
|
49
|
+
|
|
50
|
+
expect(write("hello\n")).toBe(true);
|
|
51
|
+
expect(fake.writes).toEqual(["hello\n"]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("detaches to a silent no-op after an EPIPE error (the #177 regression)", () => {
|
|
55
|
+
const fake = new FakeWritable();
|
|
56
|
+
const onUnexpected = vi.fn();
|
|
57
|
+
const write = guardStream(asWritable(fake), onUnexpected);
|
|
58
|
+
|
|
59
|
+
// Emitting `error` must not throw — a listener is what stops EPIPE from
|
|
60
|
+
// escalating to uncaughtException and forming the busy loop.
|
|
61
|
+
expect(() => fake.emit("error", pipeError("EPIPE"))).not.toThrow();
|
|
62
|
+
|
|
63
|
+
expect(write("after\n")).toBe(false);
|
|
64
|
+
expect(fake.writes).toEqual([]);
|
|
65
|
+
expect(onUnexpected).not.toHaveBeenCalled();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("reports an unexpected (non-broken-pipe) error exactly once", () => {
|
|
69
|
+
const fake = new FakeWritable();
|
|
70
|
+
const onUnexpected = vi.fn();
|
|
71
|
+
const write = guardStream(asWritable(fake), onUnexpected);
|
|
72
|
+
|
|
73
|
+
fake.emit("error", pipeError("ECONNRESET"));
|
|
74
|
+
fake.emit("error", pipeError("ECONNRESET"));
|
|
75
|
+
|
|
76
|
+
expect(onUnexpected).toHaveBeenCalledTimes(1);
|
|
77
|
+
expect(write("after\n")).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("detaches when stream.write throws synchronously instead of propagating", () => {
|
|
81
|
+
const fake = new FakeWritable();
|
|
82
|
+
fake.throwOnWrite = pipeError("ERR_STREAM_WRITE_AFTER_END");
|
|
83
|
+
const write = guardStream(asWritable(fake));
|
|
84
|
+
|
|
85
|
+
expect(() => write("boom\n")).not.toThrow();
|
|
86
|
+
expect(write("boom\n")).toBe(false);
|
|
87
|
+
|
|
88
|
+
// Once detached, even a now-healthy stream is left alone (we never retry).
|
|
89
|
+
fake.throwOnWrite = null;
|
|
90
|
+
expect(write("recovered\n")).toBe(false);
|
|
91
|
+
expect(fake.writes).toEqual([]);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("reportFatal", () => {
|
|
96
|
+
it("writes the label and error detail", () => {
|
|
97
|
+
const writes: string[] = [];
|
|
98
|
+
reportFatal((chunk) => (writes.push(chunk), true), "Uncaught:", new Error("kaboom"));
|
|
99
|
+
|
|
100
|
+
expect(writes).toHaveLength(1);
|
|
101
|
+
expect(writes[0]).toContain("Uncaught:");
|
|
102
|
+
expect(writes[0]).toContain("kaboom");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("stringifies non-Error values", () => {
|
|
106
|
+
const writes: string[] = [];
|
|
107
|
+
reportFatal((chunk) => (writes.push(chunk), true), "Uncaught:", "plain string");
|
|
108
|
+
|
|
109
|
+
expect(writes[0]).toContain("plain string");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("never throws even when the writer itself throws (the re-entrancy trap)", () => {
|
|
113
|
+
const throwingWriter = (): boolean => {
|
|
114
|
+
throw pipeError("EPIPE");
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
expect(() => reportFatal(throwingWriter, "Uncaught:", new Error("x"))).not.toThrow();
|
|
118
|
+
});
|
|
119
|
+
});
|
package/src/main.ts
CHANGED
|
@@ -31,19 +31,25 @@ import { buildReadyMessage } from "./ipc-protocol.js";
|
|
|
31
31
|
import type { IpcCommand, IpcResponse } from "./ipc-protocol.js";
|
|
32
32
|
|
|
33
33
|
import { handleUnhandledRejection, setExecutionContextRef } from "./activities/execute-cursor/rejection-capture.js";
|
|
34
|
+
import { installProcessPipeGuards, reportFatal } from "./pipe-safety.js";
|
|
35
|
+
|
|
36
|
+
// Guard the host pipes before anything writes to them. A dropped stderr/stdout
|
|
37
|
+
// reader otherwise turns the next write into an uncaught EPIPE loop that starves
|
|
38
|
+
// Temporal heartbeats and kills the in-flight execution (issue #177).
|
|
39
|
+
const { writeStdout, writeStderr } = installProcessPipeGuards();
|
|
34
40
|
|
|
35
41
|
process.on("unhandledRejection", (reason) => {
|
|
36
42
|
handleUnhandledRejection(reason);
|
|
37
43
|
});
|
|
38
44
|
|
|
39
45
|
process.on("uncaughtException", (err) => {
|
|
40
|
-
|
|
46
|
+
reportFatal(writeStderr, "Uncaught exception in runner:", err);
|
|
41
47
|
});
|
|
42
48
|
|
|
43
49
|
// ─── IPC Helpers ─────────────────────────────────────────────────────────────
|
|
44
50
|
|
|
45
51
|
function sendIpc(msg: IpcResponse): void {
|
|
46
|
-
|
|
52
|
+
writeStdout(JSON.stringify(msg) + "\n");
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
// ─── Manager Mode ────────────────────────────────────────────────────────────
|
|
@@ -52,7 +58,7 @@ async function runManagerMode(config: import("./config.js").Config): Promise<voi
|
|
|
52
58
|
// In manager mode, redirect console.log to stderr so stdout is reserved for IPC
|
|
53
59
|
const originalLog = console.log;
|
|
54
60
|
console.log = (...args: unknown[]) => {
|
|
55
|
-
|
|
61
|
+
writeStderr(args.map(String).join(" ") + "\n");
|
|
56
62
|
};
|
|
57
63
|
|
|
58
64
|
let manager: StigmerRunnerManager;
|
|
@@ -319,6 +325,6 @@ async function main(): Promise<void> {
|
|
|
319
325
|
}
|
|
320
326
|
|
|
321
327
|
main().catch((err) => {
|
|
322
|
-
|
|
328
|
+
reportFatal(writeStderr, "Fatal error in runner:", err);
|
|
323
329
|
process.exit(1);
|
|
324
330
|
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Makes the runner's stdout/stderr writes fail-safe so a dead host pipe degrades
|
|
3
|
+
* logging instead of killing execution.
|
|
4
|
+
*
|
|
5
|
+
* Background (issue #177): the runner is embedded as a subprocess whose stdout is
|
|
6
|
+
* the JSON IPC channel and whose stderr is the host's log channel. When the host
|
|
7
|
+
* drops a pipe's read end, the runner's next write raises an async `EPIPE` error
|
|
8
|
+
* event. With no `error` listener that event escalates to `uncaughtException` —
|
|
9
|
+
* and the uncaught handler logs to the very stream that just failed, raising EPIPE
|
|
10
|
+
* again. That re-entrant loop pegs the event loop at ~125% CPU, starves Temporal
|
|
11
|
+
* heartbeats, and the in-flight execution dies as "Activity task timed out" while
|
|
12
|
+
* the runner process still looks alive.
|
|
13
|
+
*
|
|
14
|
+
* The fix is one Node idiom: attach an `error` listener to each stream. Once a
|
|
15
|
+
* listener exists, EPIPE is delivered to it and can never escalate, which both
|
|
16
|
+
* breaks the loop and makes every raw `console.*` call in the codebase safe. The
|
|
17
|
+
* guarded writers below add the intent-revealing layer on top: once a pipe breaks
|
|
18
|
+
* we detach (writes become silent no-ops) rather than keep trying.
|
|
19
|
+
*
|
|
20
|
+
* See <https://github.com/stigmer/stigmer/issues/177>.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import type { Writable } from "node:stream";
|
|
24
|
+
|
|
25
|
+
/** Error codes that mean the downstream reader is gone — logging must degrade, not crash. */
|
|
26
|
+
const BROKEN_PIPE_CODES = new Set(["EPIPE", "ERR_STREAM_DESTROYED", "ERR_STREAM_WRITE_AFTER_END"]);
|
|
27
|
+
|
|
28
|
+
export function isBrokenPipeError(err: unknown): boolean {
|
|
29
|
+
const code = (err as { code?: unknown } | null | undefined)?.code;
|
|
30
|
+
return typeof code === "string" && BROKEN_PIPE_CODES.has(code);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Guards a single writable stream and returns a writer that can never crash the
|
|
35
|
+
* process. The returned writer no-ops once the stream's reader is gone; callers
|
|
36
|
+
* include their own trailing newline so the byte stream is unchanged on the happy
|
|
37
|
+
* path. `onUnexpectedError` reports a non-broken-pipe failure through a channel
|
|
38
|
+
* the caller knows is still safe (see {@link installProcessPipeGuards}).
|
|
39
|
+
*
|
|
40
|
+
* Pure with respect to the injected stream (no `process` coupling) so the
|
|
41
|
+
* detach-on-EPIPE behavior is unit-testable with a fake writable.
|
|
42
|
+
*/
|
|
43
|
+
export function guardStream(
|
|
44
|
+
stream: Writable,
|
|
45
|
+
onUnexpectedError?: (err: Error) => void,
|
|
46
|
+
): (chunk: string) => boolean {
|
|
47
|
+
let detached = false;
|
|
48
|
+
|
|
49
|
+
stream.on("error", (err: Error) => {
|
|
50
|
+
// Any error means the stream is no longer usable; stop writing to it. The
|
|
51
|
+
// listener's existence is what prevents EPIPE from escalating to
|
|
52
|
+
// uncaughtException — that is the actual fix for issue #177.
|
|
53
|
+
const wasAttached = !detached;
|
|
54
|
+
detached = true;
|
|
55
|
+
// Report an unexpected (non-broken-pipe) failure at most once, on the first
|
|
56
|
+
// error — later errors are just noise from the same dead stream.
|
|
57
|
+
if (wasAttached && !isBrokenPipeError(err)) {
|
|
58
|
+
onUnexpectedError?.(err);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
return (chunk: string): boolean => {
|
|
63
|
+
if (detached) return false;
|
|
64
|
+
try {
|
|
65
|
+
return stream.write(chunk);
|
|
66
|
+
} catch {
|
|
67
|
+
// A synchronous throw (e.g. write-after-end) means the stream is unusable;
|
|
68
|
+
// detach so we never retry into the same failure.
|
|
69
|
+
detached = true;
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let installed: { writeStdout: (chunk: string) => boolean; writeStderr: (chunk: string) => boolean } | null =
|
|
76
|
+
null;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Installs broken-pipe guards on `process.stdout` and `process.stderr` and returns
|
|
80
|
+
* the safe writers for each. Idempotent — the first call wins so repeated imports
|
|
81
|
+
* never stack duplicate `error` listeners.
|
|
82
|
+
*
|
|
83
|
+
* Channel asymmetry is deliberate: stdout is the JSON IPC channel, so an
|
|
84
|
+
* unexpected stdout error is reported on stderr (the log channel); an unexpected
|
|
85
|
+
* stderr error has no safe place to go, so it detaches silently rather than
|
|
86
|
+
* corrupting the IPC stream with diagnostics.
|
|
87
|
+
*/
|
|
88
|
+
export function installProcessPipeGuards(): {
|
|
89
|
+
writeStdout: (chunk: string) => boolean;
|
|
90
|
+
writeStderr: (chunk: string) => boolean;
|
|
91
|
+
} {
|
|
92
|
+
if (installed) return installed;
|
|
93
|
+
|
|
94
|
+
const writeStderr = guardStream(process.stderr);
|
|
95
|
+
const writeStdout = guardStream(process.stdout, (err) => {
|
|
96
|
+
writeStderr(`[pipe-safety] stdout (IPC) channel error, detaching: ${err.stack ?? err}\n`);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
installed = { writeStdout, writeStderr };
|
|
100
|
+
return installed;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Reports a fatal/uncaught error through a guarded writer, guaranteeing the report
|
|
105
|
+
* itself can never throw. A fatal handler that throws while reporting is the exact
|
|
106
|
+
* re-entrancy trap behind issue #177, so the write is wrapped defensively even
|
|
107
|
+
* though the writer is already broken-pipe safe.
|
|
108
|
+
*/
|
|
109
|
+
export function reportFatal(write: (chunk: string) => boolean, label: string, err: unknown): void {
|
|
110
|
+
try {
|
|
111
|
+
const detail = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
112
|
+
write(`${label} ${detail}\n`);
|
|
113
|
+
} catch {
|
|
114
|
+
// Intentionally empty: the reporter is the last line of defense and must
|
|
115
|
+
// never propagate.
|
|
116
|
+
}
|
|
117
|
+
}
|