@codyswann/lisa 4.1.5 → 4.3.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/all/copy-overwrite/scripts/lib/bounded-spawn.mjs +182 -0
- package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
- package/dist/core/lisa-owned-hash-ledger.js +7 -0
- package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
- package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
- package/dist/core/upstream-evidence-manifest.js +15 -2
- package/dist/core/upstream-evidence-manifest.js.map +1 -1
- package/expo/copy-overwrite/scripts/lib/bounded-spawn.mjs +182 -0
- package/package.json +2 -1
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/scripts/build-plugins.sh +28 -0
- package/scripts/lib/bounded-spawn.mjs +179 -0
- package/typescript/copy-overwrite/scripts/lib/bounded-spawn.mjs +182 -0
- package/typescript/copy-overwrite/scripts/lisa-mutation.mjs +464 -14
package/scripts/build-plugins.sh
CHANGED
|
@@ -183,6 +183,34 @@ if [ -f "$ROOT_DIR/scripts/lib/invoked-as-script.mjs" ] &&
|
|
|
183
183
|
done
|
|
184
184
|
fi
|
|
185
185
|
|
|
186
|
+
# The shared child-start deadline, into the same lanes, for the same reason.
|
|
187
|
+
#
|
|
188
|
+
# A guard that starts a child and never bounds it does not hang loudly — it
|
|
189
|
+
# returns a VERDICT. `spawnSync` hands back empty streams and a null status when
|
|
190
|
+
# it kills a child at its deadline, which is indistinguishable from a program
|
|
191
|
+
# that ran and said no, so `status === 0 ? out : null` reads a busy machine as a
|
|
192
|
+
# clean negative and the guard says "allow" (#2980).
|
|
193
|
+
#
|
|
194
|
+
# Materialized rather than hand-copied on exactly the `invoked-as-script`
|
|
195
|
+
# reasoning above: downstream every lane lands flat in one `scripts/`, so a
|
|
196
|
+
# single `scripts/lib/bounded-spawn.mjs` serves them all and `./lib/...`
|
|
197
|
+
# resolves from each, while inside THIS repo the lanes are separate trees whose
|
|
198
|
+
# unit tests import the lane copies directly. One canonical source, a
|
|
199
|
+
# byte-equality test, and the copies cannot drift.
|
|
200
|
+
#
|
|
201
|
+
# Guarded on the GENERATOR as well as the source, again for the fixture reason:
|
|
202
|
+
# this script runs against isolated fixtures that vendor `scripts/lib/` without
|
|
203
|
+
# the repository's own `scripts/*.mjs`.
|
|
204
|
+
if [ -f "$ROOT_DIR/scripts/lib/bounded-spawn.mjs" ] &&
|
|
205
|
+
[ -f "$ROOT_DIR/scripts/materialize-copy-overwrite.mjs" ]; then
|
|
206
|
+
for spawn_lane in all typescript expo; do
|
|
207
|
+
spawn_lib_dir="$ROOT_DIR/$spawn_lane/copy-overwrite/scripts/lib"
|
|
208
|
+
mkdir -p "$spawn_lib_dir"
|
|
209
|
+
materialize "$ROOT_DIR/scripts/lib/bounded-spawn.mjs" \
|
|
210
|
+
"$spawn_lib_dir/bounded-spawn.mjs"
|
|
211
|
+
done
|
|
212
|
+
fi
|
|
213
|
+
|
|
186
214
|
# Stack-specific plugins (NO base copy)
|
|
187
215
|
STACKS=(typescript expo nestjs cdk harper-fabric phaser rails)
|
|
188
216
|
for stack in "${STACKS[@]}"; do
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* bounded-spawn — the one implementation of "start a child, and refuse to
|
|
3
|
+
* return a verdict from one that never finished", shared by every guarded
|
|
4
|
+
* `.mjs` entry point Lisa ships that starts a synchronous child.
|
|
5
|
+
*
|
|
6
|
+
* @remarks
|
|
7
|
+
* ## The defect this exists to remove
|
|
8
|
+
*
|
|
9
|
+
* A test with a hung child fails a gate. **A guard with a hung child returns a
|
|
10
|
+
* verdict.** That is the whole reason this module is separate from the test
|
|
11
|
+
* tree's equivalent: out here the consequence of a child that never answered
|
|
12
|
+
* is a check that says "allow".
|
|
13
|
+
*
|
|
14
|
+
* The mechanism is one line, and it is measured rather than reasoned. These are
|
|
15
|
+
* the three outcomes of a synchronous child start, on this platform:
|
|
16
|
+
*
|
|
17
|
+
* | outcome | `.code` | `.status` | `.signal` | streams |
|
|
18
|
+
* | --- | --- | --- | --- | --- |
|
|
19
|
+
* | killed at its deadline | `"ETIMEDOUT"` | `null` | `"SIGKILL"` | **empty** |
|
|
20
|
+
* | ran and exited non-zero | *(absent)* | `3` | `null` | real |
|
|
21
|
+
* | binary not found | `"ENOENT"` | `null` | `null` | empty |
|
|
22
|
+
*
|
|
23
|
+
* Now read the shape that appears all over this repository:
|
|
24
|
+
*
|
|
25
|
+
* ```js
|
|
26
|
+
* return result.status === 0 ? result.stdout : null;
|
|
27
|
+
* ```
|
|
28
|
+
*
|
|
29
|
+
* `status === 0` is false for a killed child **and** for a program that ran and
|
|
30
|
+
* said no, so the two are indistinguishable and both become `null`. The caller
|
|
31
|
+
* treats `null` as "the answer is no" and proceeds. **A busy machine therefore
|
|
32
|
+
* produces the same value a clean answer does**, and nothing anywhere says the
|
|
33
|
+
* word "time".
|
|
34
|
+
*
|
|
35
|
+
* That is why the fail-open spelling was never a careless choice. It is
|
|
36
|
+
* indistinguishable from correct handling unless you already know the kill case
|
|
37
|
+
* exists — which is precisely the argument for putting the discrimination in
|
|
38
|
+
* one shared place instead of asking 80 call sites to remember it.
|
|
39
|
+
*
|
|
40
|
+
* ## Why this THROWS
|
|
41
|
+
*
|
|
42
|
+
* Returning a distinguishable value would leave every call site free to ignore
|
|
43
|
+
* it, and the call sites that most need to check are the ones that already
|
|
44
|
+
* decided a falsy result means "no". Throwing inverts the default: a call site
|
|
45
|
+
* that does nothing gets fail-closed behaviour, and only a call site that
|
|
46
|
+
* *deliberately* catches can proceed.
|
|
47
|
+
*
|
|
48
|
+
* Measured on the tree this was written for: of 80 synchronous child starts in
|
|
49
|
+
* shipped scripts, **51 propagate** — a throw escapes and fails the process, so
|
|
50
|
+
* those are fixed by this module alone — and **29 sit inside a `try` with a
|
|
51
|
+
* `catch`**, where a throw would be swallowed. Those 29 are not a conversion
|
|
52
|
+
* job; each needs a human answer to "what should happen when this child is
|
|
53
|
+
* killed?", and the answer is written at the call site with
|
|
54
|
+
* {@link isChildTimeout}.
|
|
55
|
+
*
|
|
56
|
+
* ## Why the discriminator is `error.code`, not a custom error class
|
|
57
|
+
*
|
|
58
|
+
* A class cannot cross the boundary. Plugin payloads have no `./lib/` to import
|
|
59
|
+
* from — the accommodation `threshold-ratchet.mjs` and `preflight-secrets.mjs`
|
|
60
|
+
* already make — so a shared class would be unavailable in exactly the tree
|
|
61
|
+
* with the most call sites. `ETIMEDOUT` is set by Node itself on all three
|
|
62
|
+
* forms (`spawnSync` puts it on `result.error`; `execFileSync` and `execSync`
|
|
63
|
+
* throw an error carrying it), so a payload that cannot import this module
|
|
64
|
+
* writes the identical check inline and is stating the same **platform fact**
|
|
65
|
+
* rather than duplicating a Lisa convention.
|
|
66
|
+
*
|
|
67
|
+
* `ENOENT` is deliberately NOT treated as a timeout. A missing binary is a real
|
|
68
|
+
* answer about the environment, it is reported the same way on every run, and
|
|
69
|
+
* conflating it with a kill would hide a broken install behind a retryable-
|
|
70
|
+
* looking error.
|
|
71
|
+
* @module scripts/lib/bounded-spawn
|
|
72
|
+
*/
|
|
73
|
+
|
|
74
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Deadline a child gets when the caller states none, in milliseconds.
|
|
78
|
+
*
|
|
79
|
+
* Deliberately generous. This is not a performance budget — it is the point
|
|
80
|
+
* past which a child is presumed hung, and the cost of setting it too low is a
|
|
81
|
+
* guard that fails on a slow machine while the cost of setting it too high is
|
|
82
|
+
* only a slower failure. `git` resolved through `PATH` on macOS goes through
|
|
83
|
+
* Apple's `xcrun` shim, which has been measured at over 20 seconds under load
|
|
84
|
+
* against 11ms for a real binary, so anything tighter than this would make the
|
|
85
|
+
* shim itself the failure.
|
|
86
|
+
*/
|
|
87
|
+
export const DEFAULT_CHILD_BUDGET_MS = 30_000;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Whether this error is a child that was killed at its deadline.
|
|
91
|
+
*
|
|
92
|
+
* The one question a `catch` around a bounded child start has to ask. A `catch`
|
|
93
|
+
* that does not ask it treats "the box was busy" as "the command said no",
|
|
94
|
+
* which is the fail-open this module exists to remove — so a catch block
|
|
95
|
+
* enclosing a bounded start must either re-raise on this or say in a comment
|
|
96
|
+
* why continuing is correct.
|
|
97
|
+
*
|
|
98
|
+
* Accepts `unknown` because that is what a `catch` binding is, and reads the
|
|
99
|
+
* property defensively for the same reason: a thrown non-object is legal
|
|
100
|
+
* JavaScript and must answer `false` rather than crash the guard that was
|
|
101
|
+
* trying to be careful.
|
|
102
|
+
* @param {unknown} error A caught value, or `result.error` from `spawnSync`.
|
|
103
|
+
* @returns {boolean} True when the child was killed at its deadline.
|
|
104
|
+
*/
|
|
105
|
+
export function isChildTimeout(error) {
|
|
106
|
+
return (
|
|
107
|
+
typeof error === "object" &&
|
|
108
|
+
error !== null &&
|
|
109
|
+
/** @type {{ code?: unknown }} */ (error).code === "ETIMEDOUT"
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Re-raise a caught value when it is a killed child, otherwise do nothing.
|
|
115
|
+
*
|
|
116
|
+
* The one-line form for the 29 call sites whose `catch` predates this module.
|
|
117
|
+
* Written as a helper rather than left to each site because the correct spelling
|
|
118
|
+
* is `throw error` — NOT wrapping, NOT `throw new Error(...)` — and a wrapped
|
|
119
|
+
* error loses the `code` the next frame up needs to make the same decision.
|
|
120
|
+
* @param {unknown} error The caught value.
|
|
121
|
+
* @returns {void}
|
|
122
|
+
* @throws {unknown} The original value, when it is a killed child.
|
|
123
|
+
*/
|
|
124
|
+
export function rethrowIfChildTimeout(error) {
|
|
125
|
+
if (isChildTimeout(error)) throw error;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Options a bounded start always applies, whatever the caller asked for.
|
|
130
|
+
*
|
|
131
|
+
* `killSignal` is `SIGKILL` rather than the default `SIGTERM` deliberately: the
|
|
132
|
+
* hang this module exists for is a process that is not servicing signals, and a
|
|
133
|
+
* `SIGTERM` a child ignores turns the deadline into a suggestion.
|
|
134
|
+
* @param {object} options The caller's options.
|
|
135
|
+
* @returns {object} The caller's options with the deadline enforced.
|
|
136
|
+
*/
|
|
137
|
+
function bounded(options) {
|
|
138
|
+
return {
|
|
139
|
+
...options,
|
|
140
|
+
killSignal: "SIGKILL",
|
|
141
|
+
timeout: options.timeout ?? DEFAULT_CHILD_BUDGET_MS,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* `spawnSync` with a deadline, refusing to return a killed child's result.
|
|
147
|
+
*
|
|
148
|
+
* `spawnSync` does not throw on a timeout — it returns a result whose streams
|
|
149
|
+
* are EMPTY and whose `status` is `null`, which is why the fail-open shape in
|
|
150
|
+
* the module docblock cannot see it. This throws instead, so a caller that does
|
|
151
|
+
* nothing inherits fail-closed behaviour.
|
|
152
|
+
* @param {string} command The executable.
|
|
153
|
+
* @param {readonly string[]} args Arguments.
|
|
154
|
+
* @param {object} [options] `spawnSync` options; `timeout` overrides the default.
|
|
155
|
+
* @returns {import("node:child_process").SpawnSyncReturns<string>} The result.
|
|
156
|
+
* @throws {Error} When the child was killed at its deadline.
|
|
157
|
+
*/
|
|
158
|
+
export function boundedSpawnSync(command, args = [], options = {}) {
|
|
159
|
+
const result = spawnSync(command, [...args], bounded(options));
|
|
160
|
+
if (isChildTimeout(result.error)) throw result.error;
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* `execFileSync` with a deadline.
|
|
166
|
+
*
|
|
167
|
+
* `execFileSync` already throws on a timeout, so this changes the FAILURE MODE
|
|
168
|
+
* rather than adding one: without a `timeout` there is no deadline to reach and
|
|
169
|
+
* the call blocks for as long as the child lives. The throw it produces carries
|
|
170
|
+
* `code: "ETIMEDOUT"`, which is what {@link isChildTimeout} reads.
|
|
171
|
+
* @param {string} command The executable.
|
|
172
|
+
* @param {readonly string[]} args Arguments.
|
|
173
|
+
* @param {object} [options] `execFileSync` options; `timeout` overrides the default.
|
|
174
|
+
* @returns {string|Buffer} Whatever the child wrote to stdout.
|
|
175
|
+
* @throws {Error} When the child was killed, or exited non-zero.
|
|
176
|
+
*/
|
|
177
|
+
export function boundedExecFileSync(command, args = [], options = {}) {
|
|
178
|
+
return execFileSync(command, [...args], bounded(options));
|
|
179
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// This file is managed by Lisa and IS replaced on each `lisa` run.
|
|
2
|
+
// Do not edit directly — durable changes belong upstream in Lisa.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* bounded-spawn — the one implementation of "start a child, and refuse to
|
|
6
|
+
* return a verdict from one that never finished", shared by every guarded
|
|
7
|
+
* `.mjs` entry point Lisa ships that starts a synchronous child.
|
|
8
|
+
*
|
|
9
|
+
* @remarks
|
|
10
|
+
* ## The defect this exists to remove
|
|
11
|
+
*
|
|
12
|
+
* A test with a hung child fails a gate. **A guard with a hung child returns a
|
|
13
|
+
* verdict.** That is the whole reason this module is separate from the test
|
|
14
|
+
* tree's equivalent: out here the consequence of a child that never answered
|
|
15
|
+
* is a check that says "allow".
|
|
16
|
+
*
|
|
17
|
+
* The mechanism is one line, and it is measured rather than reasoned. These are
|
|
18
|
+
* the three outcomes of a synchronous child start, on this platform:
|
|
19
|
+
*
|
|
20
|
+
* | outcome | `.code` | `.status` | `.signal` | streams |
|
|
21
|
+
* | --- | --- | --- | --- | --- |
|
|
22
|
+
* | killed at its deadline | `"ETIMEDOUT"` | `null` | `"SIGKILL"` | **empty** |
|
|
23
|
+
* | ran and exited non-zero | *(absent)* | `3` | `null` | real |
|
|
24
|
+
* | binary not found | `"ENOENT"` | `null` | `null` | empty |
|
|
25
|
+
*
|
|
26
|
+
* Now read the shape that appears all over this repository:
|
|
27
|
+
*
|
|
28
|
+
* ```js
|
|
29
|
+
* return result.status === 0 ? result.stdout : null;
|
|
30
|
+
* ```
|
|
31
|
+
*
|
|
32
|
+
* `status === 0` is false for a killed child **and** for a program that ran and
|
|
33
|
+
* said no, so the two are indistinguishable and both become `null`. The caller
|
|
34
|
+
* treats `null` as "the answer is no" and proceeds. **A busy machine therefore
|
|
35
|
+
* produces the same value a clean answer does**, and nothing anywhere says the
|
|
36
|
+
* word "time".
|
|
37
|
+
*
|
|
38
|
+
* That is why the fail-open spelling was never a careless choice. It is
|
|
39
|
+
* indistinguishable from correct handling unless you already know the kill case
|
|
40
|
+
* exists — which is precisely the argument for putting the discrimination in
|
|
41
|
+
* one shared place instead of asking 80 call sites to remember it.
|
|
42
|
+
*
|
|
43
|
+
* ## Why this THROWS
|
|
44
|
+
*
|
|
45
|
+
* Returning a distinguishable value would leave every call site free to ignore
|
|
46
|
+
* it, and the call sites that most need to check are the ones that already
|
|
47
|
+
* decided a falsy result means "no". Throwing inverts the default: a call site
|
|
48
|
+
* that does nothing gets fail-closed behaviour, and only a call site that
|
|
49
|
+
* *deliberately* catches can proceed.
|
|
50
|
+
*
|
|
51
|
+
* Measured on the tree this was written for: of 80 synchronous child starts in
|
|
52
|
+
* shipped scripts, **51 propagate** — a throw escapes and fails the process, so
|
|
53
|
+
* those are fixed by this module alone — and **29 sit inside a `try` with a
|
|
54
|
+
* `catch`**, where a throw would be swallowed. Those 29 are not a conversion
|
|
55
|
+
* job; each needs a human answer to "what should happen when this child is
|
|
56
|
+
* killed?", and the answer is written at the call site with
|
|
57
|
+
* {@link isChildTimeout}.
|
|
58
|
+
*
|
|
59
|
+
* ## Why the discriminator is `error.code`, not a custom error class
|
|
60
|
+
*
|
|
61
|
+
* A class cannot cross the boundary. Plugin payloads have no `./lib/` to import
|
|
62
|
+
* from — the accommodation `threshold-ratchet.mjs` and `preflight-secrets.mjs`
|
|
63
|
+
* already make — so a shared class would be unavailable in exactly the tree
|
|
64
|
+
* with the most call sites. `ETIMEDOUT` is set by Node itself on all three
|
|
65
|
+
* forms (`spawnSync` puts it on `result.error`; `execFileSync` and `execSync`
|
|
66
|
+
* throw an error carrying it), so a payload that cannot import this module
|
|
67
|
+
* writes the identical check inline and is stating the same **platform fact**
|
|
68
|
+
* rather than duplicating a Lisa convention.
|
|
69
|
+
*
|
|
70
|
+
* `ENOENT` is deliberately NOT treated as a timeout. A missing binary is a real
|
|
71
|
+
* answer about the environment, it is reported the same way on every run, and
|
|
72
|
+
* conflating it with a kill would hide a broken install behind a retryable-
|
|
73
|
+
* looking error.
|
|
74
|
+
* @module scripts/lib/bounded-spawn
|
|
75
|
+
*/
|
|
76
|
+
|
|
77
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Deadline a child gets when the caller states none, in milliseconds.
|
|
81
|
+
*
|
|
82
|
+
* Deliberately generous. This is not a performance budget — it is the point
|
|
83
|
+
* past which a child is presumed hung, and the cost of setting it too low is a
|
|
84
|
+
* guard that fails on a slow machine while the cost of setting it too high is
|
|
85
|
+
* only a slower failure. `git` resolved through `PATH` on macOS goes through
|
|
86
|
+
* Apple's `xcrun` shim, which has been measured at over 20 seconds under load
|
|
87
|
+
* against 11ms for a real binary, so anything tighter than this would make the
|
|
88
|
+
* shim itself the failure.
|
|
89
|
+
*/
|
|
90
|
+
export const DEFAULT_CHILD_BUDGET_MS = 30_000;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Whether this error is a child that was killed at its deadline.
|
|
94
|
+
*
|
|
95
|
+
* The one question a `catch` around a bounded child start has to ask. A `catch`
|
|
96
|
+
* that does not ask it treats "the box was busy" as "the command said no",
|
|
97
|
+
* which is the fail-open this module exists to remove — so a catch block
|
|
98
|
+
* enclosing a bounded start must either re-raise on this or say in a comment
|
|
99
|
+
* why continuing is correct.
|
|
100
|
+
*
|
|
101
|
+
* Accepts `unknown` because that is what a `catch` binding is, and reads the
|
|
102
|
+
* property defensively for the same reason: a thrown non-object is legal
|
|
103
|
+
* JavaScript and must answer `false` rather than crash the guard that was
|
|
104
|
+
* trying to be careful.
|
|
105
|
+
* @param {unknown} error A caught value, or `result.error` from `spawnSync`.
|
|
106
|
+
* @returns {boolean} True when the child was killed at its deadline.
|
|
107
|
+
*/
|
|
108
|
+
export function isChildTimeout(error) {
|
|
109
|
+
return (
|
|
110
|
+
typeof error === "object" &&
|
|
111
|
+
error !== null &&
|
|
112
|
+
/** @type {{ code?: unknown }} */ (error).code === "ETIMEDOUT"
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Re-raise a caught value when it is a killed child, otherwise do nothing.
|
|
118
|
+
*
|
|
119
|
+
* The one-line form for the 29 call sites whose `catch` predates this module.
|
|
120
|
+
* Written as a helper rather than left to each site because the correct spelling
|
|
121
|
+
* is `throw error` — NOT wrapping, NOT `throw new Error(...)` — and a wrapped
|
|
122
|
+
* error loses the `code` the next frame up needs to make the same decision.
|
|
123
|
+
* @param {unknown} error The caught value.
|
|
124
|
+
* @returns {void}
|
|
125
|
+
* @throws {unknown} The original value, when it is a killed child.
|
|
126
|
+
*/
|
|
127
|
+
export function rethrowIfChildTimeout(error) {
|
|
128
|
+
if (isChildTimeout(error)) throw error;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Options a bounded start always applies, whatever the caller asked for.
|
|
133
|
+
*
|
|
134
|
+
* `killSignal` is `SIGKILL` rather than the default `SIGTERM` deliberately: the
|
|
135
|
+
* hang this module exists for is a process that is not servicing signals, and a
|
|
136
|
+
* `SIGTERM` a child ignores turns the deadline into a suggestion.
|
|
137
|
+
* @param {object} options The caller's options.
|
|
138
|
+
* @returns {object} The caller's options with the deadline enforced.
|
|
139
|
+
*/
|
|
140
|
+
function bounded(options) {
|
|
141
|
+
return {
|
|
142
|
+
...options,
|
|
143
|
+
killSignal: "SIGKILL",
|
|
144
|
+
timeout: options.timeout ?? DEFAULT_CHILD_BUDGET_MS,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `spawnSync` with a deadline, refusing to return a killed child's result.
|
|
150
|
+
*
|
|
151
|
+
* `spawnSync` does not throw on a timeout — it returns a result whose streams
|
|
152
|
+
* are EMPTY and whose `status` is `null`, which is why the fail-open shape in
|
|
153
|
+
* the module docblock cannot see it. This throws instead, so a caller that does
|
|
154
|
+
* nothing inherits fail-closed behaviour.
|
|
155
|
+
* @param {string} command The executable.
|
|
156
|
+
* @param {readonly string[]} args Arguments.
|
|
157
|
+
* @param {object} [options] `spawnSync` options; `timeout` overrides the default.
|
|
158
|
+
* @returns {import("node:child_process").SpawnSyncReturns<string>} The result.
|
|
159
|
+
* @throws {Error} When the child was killed at its deadline.
|
|
160
|
+
*/
|
|
161
|
+
export function boundedSpawnSync(command, args = [], options = {}) {
|
|
162
|
+
const result = spawnSync(command, [...args], bounded(options));
|
|
163
|
+
if (isChildTimeout(result.error)) throw result.error;
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* `execFileSync` with a deadline.
|
|
169
|
+
*
|
|
170
|
+
* `execFileSync` already throws on a timeout, so this changes the FAILURE MODE
|
|
171
|
+
* rather than adding one: without a `timeout` there is no deadline to reach and
|
|
172
|
+
* the call blocks for as long as the child lives. The throw it produces carries
|
|
173
|
+
* `code: "ETIMEDOUT"`, which is what {@link isChildTimeout} reads.
|
|
174
|
+
* @param {string} command The executable.
|
|
175
|
+
* @param {readonly string[]} args Arguments.
|
|
176
|
+
* @param {object} [options] `execFileSync` options; `timeout` overrides the default.
|
|
177
|
+
* @returns {string|Buffer} Whatever the child wrote to stdout.
|
|
178
|
+
* @throws {Error} When the child was killed, or exited non-zero.
|
|
179
|
+
*/
|
|
180
|
+
export function boundedExecFileSync(command, args = [], options = {}) {
|
|
181
|
+
return execFileSync(command, [...args], bounded(options));
|
|
182
|
+
}
|