@crewhaus/tool-proc 0.7.0 → 0.8.0

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/spawn.js CHANGED
@@ -1,57 +1,75 @@
1
- import { capText } from "./lib/format";
2
- /**
3
- * The one place this package starts a child process.
4
- *
5
- * Everything that runs a command — RunCommand, RunPipeline, Retry and the
6
- * background family — goes through here so a single set of rules holds:
7
- *
8
- * - argv is passed to the OS as an ARRAY. There is no shell anywhere in
9
- * this package, so a filename with a space or a `$(...)` in it is an
10
- * argument and never a command.
11
- * - stdin is `ignore` unless the caller supplied it, so a command that
12
- * reads stdin gets EOF immediately instead of hanging on a terminal
13
- * that is not there.
14
- * - every run has a deadline: SIGTERM on expiry, SIGKILL after a short
15
- * grace period for a child that ignores the first signal.
16
- * - both streams are capped, so one chatty command cannot fill a context
17
- * window.
18
- */
19
- /** Grace between the deadline's SIGTERM and the SIGKILL that follows it. */
20
- const KILL_GRACE_MS = 2_000;
21
- /**
22
- * A process that forks a grandchild keeps the pipe's write end open after
23
- * the child itself is gone, so reading to EOF can outlive the process. Give
24
- * each stream a bounded drain window and move on with what arrived.
25
- */
26
- const DRAIN_GRACE_MS = 500;
1
+ import { constants } from "node:os";
2
+ import { spawnBounded } from "@crewhaus/tool-safety/streams";
3
+ /** No UTF-16 code unit takes more than three bytes of UTF-8. */
4
+ const BYTES_PER_CHAR = 3;
5
+ const utf8Bytes = (text) => Buffer.byteLength(text, "utf8");
6
+ /** `text` cut to at most `chars` UTF-16 units from the front, never mid-pair. */
7
+ function headChars(text, chars) {
8
+ if (text.length <= chars)
9
+ return text;
10
+ const cut = text.slice(0, chars);
11
+ const last = cut.charCodeAt(cut.length - 1);
12
+ return last >= 0xd800 && last <= 0xdbff ? cut.slice(0, -1) : cut;
13
+ }
14
+ /** `text` cut to at most `chars` UTF-16 units from the end, never mid-pair. */
15
+ function tailChars(text, chars) {
16
+ if (chars <= 0)
17
+ return "";
18
+ if (text.length <= chars)
19
+ return text;
20
+ const cut = text.slice(text.length - chars);
21
+ const first = cut.charCodeAt(0);
22
+ return first >= 0xdc00 && first <= 0xdfff ? cut.slice(1) : cut;
23
+ }
27
24
  /**
28
- * After the SIGKILL, how long to keep waiting for the child to be reaped
29
- * before returning without it.
30
- *
31
- * SIGKILL cannot be caught, but it cannot interrupt a process wedged in an
32
- * uninterruptible kernel wait either — a stalled NFS read, a wayward driver.
33
- * `proc.exited` then never resolves, and every other bound in this file is
34
- * downstream of that await, so the whole call hangs with no deadline left to
35
- * save it. This is the last bound: past it the tool reports what it has and
36
- * lets go. (There is no portable way to put a process into that state, so
37
- * this is the one guarantee here with no test behind it.)
25
+ * One stream, capped to `maxChars`: its head and its tail — a command's head
26
+ * says what it started doing, its tail how it ended — with a marker stating
27
+ * exactly how many BYTES were dropped between them. Bytes, because the part
28
+ * past the byte budget was counted as it streamed past and never decoded.
38
29
  */
39
- const REAP_GRACE_MS = 1_000;
40
- export async function runOnce(argv, options) {
41
- const started = performance.now();
42
- const elapsed = () => Math.round(performance.now() - started);
43
- let proc;
44
- try {
45
- proc = Bun.spawn([...argv], {
46
- cwd: options.cwd,
47
- env: options.env,
48
- stdin: options.stdin === undefined ? "ignore" : new TextEncoder().encode(options.stdin),
49
- stdout: "pipe",
50
- stderr: "pipe",
51
- ...(options.signal !== undefined ? { signal: options.signal } : {}),
52
- });
30
+ function capStream(head, tail, truncated, omittedBytes, maxChars) {
31
+ const headRoom = Math.floor(maxChars / 2);
32
+ const tailRoom = maxChars - headRoom;
33
+ if (!truncated) {
34
+ if (head.length <= maxChars)
35
+ return { text: head, truncated: false };
36
+ const h = headChars(head, headRoom);
37
+ const t = tailChars(head, tailRoom);
38
+ const dropped = utf8Bytes(head) - utf8Bytes(h) - utf8Bytes(t);
39
+ return { text: `${h}\n...[${dropped} bytes dropped]...\n${t}`, truncated: true };
40
+ }
41
+ const h = headChars(head, headRoom);
42
+ const whole = tail ?? "";
43
+ const t = tailChars(whole, tailRoom);
44
+ const dropped = omittedBytes + (utf8Bytes(head) - utf8Bytes(h)) + (utf8Bytes(whole) - utf8Bytes(t));
45
+ return { text: `${h}\n...[${dropped} bytes dropped]...\n${t}`, truncated: true };
46
+ }
47
+ /** The exit status a shell would report: 128 + the signal's number for a signalled child. */
48
+ function exitStatus(r) {
49
+ if (r.exitCode !== null)
50
+ return r.exitCode;
51
+ if (r.signal !== null) {
52
+ const n = constants.signals[r.signal];
53
+ if (n !== undefined)
54
+ return 128 + n;
53
55
  }
54
- catch (err) {
56
+ return -1;
57
+ }
58
+ export async function runOnce(argv, options) {
59
+ const max = Math.max(0, options.maxOutputChars);
60
+ const tailRoom = max - Math.floor(max / 2);
61
+ const r = await spawnBounded({
62
+ cmd: argv,
63
+ cwd: options.cwd,
64
+ env: options.env,
65
+ ...(options.stdin !== undefined ? { stdin: options.stdin } : {}),
66
+ timeoutMs: options.timeoutMs,
67
+ maxStdoutBytes: max * BYTES_PER_CHAR,
68
+ maxStderrBytes: max * BYTES_PER_CHAR,
69
+ tailBytes: tailRoom * BYTES_PER_CHAR,
70
+ ...(options.signal !== undefined ? { signal: options.signal } : {}),
71
+ });
72
+ if (r.spawnError !== undefined) {
55
73
  return {
56
74
  argv,
57
75
  exitCode: -1,
@@ -60,78 +78,24 @@ export async function runOnce(argv, options) {
60
78
  stdoutTruncated: false,
61
79
  stderrTruncated: false,
62
80
  timedOut: false,
63
- durationMs: elapsed(),
64
- spawnError: err instanceof Error ? err.message : String(err),
65
- };
66
- }
67
- let timedOut = false;
68
- let reapGiveUp;
69
- const term = setTimeout(() => {
70
- timedOut = true;
71
- try {
72
- proc.kill("SIGTERM");
73
- }
74
- catch {
75
- // Already gone between the timer firing and the signal.
76
- }
77
- }, options.timeoutMs);
78
- const hardKill = setTimeout(() => {
79
- try {
80
- proc.kill("SIGKILL");
81
- }
82
- catch {
83
- // Already gone.
84
- }
85
- }, options.timeoutMs + KILL_GRACE_MS);
86
- try {
87
- // The spread options above widen Bun's stdio inference, so name the
88
- // piped shape explicitly rather than let the union leak outward.
89
- // `.catch` is attached HERE, not after the race: the loser of the race
90
- // below stays pending, and a stream torn down with the killed process
91
- // would otherwise reject with nobody listening — an unhandled rejection
92
- // that takes down the harness rather than the command that caused it.
93
- const stdoutText = new Response(proc.stdout)
94
- .text()
95
- .catch(() => "");
96
- const stderrText = new Response(proc.stderr)
97
- .text()
98
- .catch(() => "");
99
- const abandonedMarker = Symbol("abandoned");
100
- const reaped = await Promise.race([
101
- proc.exited,
102
- new Promise((resolve) => {
103
- reapGiveUp = setTimeout(() => resolve(abandonedMarker), options.timeoutMs + KILL_GRACE_MS + REAP_GRACE_MS);
104
- }),
105
- ]);
106
- const abandoned = reaped === abandonedMarker;
107
- const exitCode = abandoned ? -1 : reaped;
108
- const drainFallback = () => new Promise((resolve) => setTimeout(() => resolve(""), DRAIN_GRACE_MS));
109
- const [rawOut, rawErr] = await Promise.all([
110
- Promise.race([stdoutText, drainFallback()]),
111
- Promise.race([stderrText, drainFallback()]),
112
- ]);
113
- const out = capText(rawOut, options.maxOutputChars);
114
- const err = capText(rawErr, options.maxOutputChars);
115
- return {
116
- argv,
117
- exitCode,
118
- stdout: out.text,
119
- stderr: err.text,
120
- stdoutTruncated: out.truncated,
121
- stderrTruncated: err.truncated,
122
- timedOut: timedOut || abandoned,
123
- durationMs: elapsed(),
124
- ...(abandoned ? { abandoned: true } : {}),
81
+ durationMs: r.durationMs,
82
+ spawnError: r.spawnError,
125
83
  };
126
84
  }
127
- finally {
128
- clearTimeout(term);
129
- clearTimeout(hardKill);
130
- // Unconditional: left running, this one would hold the event loop open
131
- // for the whole timeout on every command that finished in a millisecond.
132
- if (reapGiveUp !== undefined)
133
- clearTimeout(reapGiveUp);
134
- }
85
+ const out = capStream(r.stdout, r.stdoutTail, r.stdoutTruncated, r.stdoutOmittedBytes, max);
86
+ const err = capStream(r.stderr, r.stderrTail, r.stderrTruncated, r.stderrOmittedBytes, max);
87
+ return {
88
+ argv,
89
+ exitCode: r.abandoned ? -1 : exitStatus(r),
90
+ stdout: out.text,
91
+ stderr: err.text,
92
+ stdoutTruncated: out.truncated,
93
+ stderrTruncated: err.truncated,
94
+ timedOut: r.timedOut || r.abandoned,
95
+ durationMs: r.durationMs,
96
+ ...(r.abandoned ? { abandoned: true } : {}),
97
+ ...(r.outputComplete ? {} : { outputIncomplete: true }),
98
+ };
135
99
  }
136
100
  /** A deadline-aware sleep: resolves early (and reports it) when aborted. */
137
101
  export function sleep(ms, signal) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/tool-proc",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Deterministic process tools: background processes, log tailing, port and file waits, and retry with backoff",
6
6
  "main": "dist/index.js",
@@ -8,16 +8,18 @@
8
8
  "exports": {
9
9
  ".": {
10
10
  "types": "./dist/index.d.ts",
11
- "import": "./dist/index.js"
11
+ "import": "./dist/index.js",
12
+ "default": "./dist/index.js"
12
13
  }
13
14
  },
14
15
  "scripts": {
15
16
  "test": "bun test src"
16
17
  },
17
18
  "dependencies": {
18
- "@crewhaus/errors": "0.7.0",
19
- "@crewhaus/tool-builder": "0.7.0",
20
- "@crewhaus/tool-catalog": "0.7.0",
19
+ "@crewhaus/errors": "0.8.0",
20
+ "@crewhaus/tool-builder": "0.8.0",
21
+ "@crewhaus/tool-catalog": "0.8.0",
22
+ "@crewhaus/tool-safety": "0.8.0",
21
23
  "zod": "^3.23.8"
22
24
  },
23
25
  "devDependencies": {
@@ -41,5 +43,8 @@
41
43
  "publishConfig": {
42
44
  "access": "public"
43
45
  },
44
- "files": ["dist", "README.md", "LICENSE", "NOTICE"]
46
+ "files": ["dist", "README.md", "LICENSE", "NOTICE"],
47
+ "engines": {
48
+ "bun": ">=1.3.14"
49
+ }
45
50
  }