@descryy/runtime-controller 0.4.0 → 0.4.2
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/attach-discovery.d.ts +26 -0
- package/dist/attach-discovery.d.ts.map +1 -1
- package/dist/attach-discovery.js +181 -10
- package/dist/attach-discovery.js.map +1 -1
- package/dist/attach-fencing.d.ts +11 -1
- package/dist/attach-fencing.d.ts.map +1 -1
- package/dist/attach-fencing.js +52 -23
- package/dist/attach-fencing.js.map +1 -1
- package/dist/attach-log-file.d.ts +23 -0
- package/dist/attach-log-file.d.ts.map +1 -0
- package/dist/attach-log-file.js +90 -0
- package/dist/attach-log-file.js.map +1 -0
- package/dist/bounded-file.d.ts +11 -0
- package/dist/bounded-file.d.ts.map +1 -0
- package/dist/bounded-file.js +41 -0
- package/dist/bounded-file.js.map +1 -0
- package/dist/bounded-output.d.ts +25 -0
- package/dist/bounded-output.d.ts.map +1 -0
- package/dist/bounded-output.js +95 -0
- package/dist/bounded-output.js.map +1 -0
- package/dist/container-sandbox.d.ts +7 -20
- package/dist/container-sandbox.d.ts.map +1 -1
- package/dist/container-sandbox.js +140 -8
- package/dist/container-sandbox.js.map +1 -1
- package/dist/controller.d.ts +10 -2
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +245 -84
- package/dist/controller.js.map +1 -1
- package/dist/dependency-version-check.d.ts +1 -0
- package/dist/dependency-version-check.d.ts.map +1 -1
- package/dist/dependency-version-check.js +43 -24
- package/dist/dependency-version-check.js.map +1 -1
- package/dist/environment-metadata.d.ts +13 -0
- package/dist/environment-metadata.d.ts.map +1 -1
- package/dist/environment-metadata.js +138 -24
- package/dist/environment-metadata.js.map +1 -1
- package/dist/environment-version-check.d.ts +1 -0
- package/dist/environment-version-check.d.ts.map +1 -1
- package/dist/environment-version-check.js +13 -7
- package/dist/environment-version-check.js.map +1 -1
- package/dist/execution-safety.d.ts +2 -0
- package/dist/execution-safety.d.ts.map +1 -1
- package/dist/execution-safety.js +36 -0
- package/dist/execution-safety.js.map +1 -1
- package/dist/index.d.ts +16 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/node-options.d.ts +3 -0
- package/dist/node-options.d.ts.map +1 -0
- package/dist/node-options.js +34 -0
- package/dist/node-options.js.map +1 -0
- package/dist/orphan-reaper-process.d.ts +2 -0
- package/dist/orphan-reaper-process.d.ts.map +1 -0
- package/dist/orphan-reaper-process.js +98 -0
- package/dist/orphan-reaper-process.js.map +1 -0
- package/dist/orphan-reaper.d.ts +25 -0
- package/dist/orphan-reaper.d.ts.map +1 -0
- package/dist/orphan-reaper.js +161 -0
- package/dist/orphan-reaper.js.map +1 -0
- package/dist/process-identity.d.ts +1 -0
- package/dist/process-identity.d.ts.map +1 -1
- package/dist/process-identity.js +18 -4
- package/dist/process-identity.js.map +1 -1
- package/dist/process-manager.d.ts +13 -9
- package/dist/process-manager.d.ts.map +1 -1
- package/dist/process-manager.js +309 -133
- package/dist/process-manager.js.map +1 -1
- package/dist/readiness.d.ts +1 -0
- package/dist/readiness.d.ts.map +1 -1
- package/dist/readiness.js +77 -11
- package/dist/readiness.js.map +1 -1
- package/dist/sandbox.d.ts +22 -8
- package/dist/sandbox.d.ts.map +1 -1
- package/dist/sandbox.js +366 -51
- package/dist/sandbox.js.map +1 -1
- package/dist/spawn-environment.d.ts +16 -0
- package/dist/spawn-environment.d.ts.map +1 -0
- package/dist/spawn-environment.js +35 -0
- package/dist/spawn-environment.js.map +1 -0
- package/dist/target-pid.d.ts +6 -0
- package/dist/target-pid.d.ts.map +1 -0
- package/dist/target-pid.js +18 -0
- package/dist/target-pid.js.map +1 -0
- package/package.json +2 -2
package/dist/process-manager.js
CHANGED
|
@@ -7,13 +7,21 @@
|
|
|
7
7
|
* fixture-app slice (§34).
|
|
8
8
|
*/
|
|
9
9
|
import { execFile, spawn } from "node:child_process";
|
|
10
|
-
import {
|
|
10
|
+
import { closeSync, readSync, statSync } from "node:fs";
|
|
11
11
|
import { createServer } from "node:net";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
import { StringDecoder } from "node:string_decoder";
|
|
12
14
|
import { promisify } from "node:util";
|
|
13
|
-
import { applyResourceLimits } from "./execution-safety.js";
|
|
14
|
-
import { applySandbox } from "./sandbox.js";
|
|
15
|
-
import { applyContainerSandbox } from "./container-sandbox.js";
|
|
16
|
-
import { computeAttachFence } from "./attach-fencing.js";
|
|
15
|
+
import { applyResourceLimits, resourceLimitNotes } from "./execution-safety.js";
|
|
16
|
+
import { applySandbox, NO_SANDBOX_DISCLOSURES } from "./sandbox.js";
|
|
17
|
+
import { applyContainerSandbox, containerProcessEnv } from "./container-sandbox.js";
|
|
18
|
+
import { computeAttachFence, getProcessStartTimeMs } from "./attach-fencing.js";
|
|
19
|
+
import { openAttachLogFile, resolveAttachLogPath } from "./attach-log-file.js";
|
|
20
|
+
import { buildChildEnvironment } from "./spawn-environment.js";
|
|
21
|
+
import { assertTargetPid } from "./target-pid.js";
|
|
22
|
+
import { liveDescendantPids } from "./process-identity.js";
|
|
23
|
+
import { protectFromOrphaning } from "./orphan-reaper.js";
|
|
24
|
+
import { BoundedOutput } from "./bounded-output.js";
|
|
17
25
|
/**
|
|
18
26
|
* Binds port 0, reads it, releases immediately. A real gap remains between "free
|
|
19
27
|
* when checked" and "still free when the child binds it" (RT-024's gap, one layer
|
|
@@ -44,52 +52,38 @@ export function allocateEphemeralPort() {
|
|
|
44
52
|
});
|
|
45
53
|
}
|
|
46
54
|
const DEFAULT_GRACE_PERIOD_MS = 5000;
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
* own memory, not the app's. Bounded to head + tail per service instead: the
|
|
51
|
-
* first bytes usually carry the startup/config story, the last bytes carry
|
|
52
|
-
* whatever just happened, and the (likely repetitive) middle is the part a
|
|
53
|
-
* reader least needs verbatim. Starting values, not measurements.
|
|
54
|
-
*/
|
|
55
|
-
const OUTPUT_HEAD_BYTES = 64 * 1024;
|
|
56
|
-
const OUTPUT_TAIL_BYTES = 1024 * 1024;
|
|
57
|
-
/** Marks the gap between head and tail; never confused with real process output because it names its own bound. */
|
|
58
|
-
function truncationMarker(totalBytes, keptBytes) {
|
|
59
|
-
return `\n\n...[Descry truncated ${String(totalBytes - keptBytes)} of ${String(totalBytes)} bytes of output here -- head/tail kept, middle dropped]...\n\n`;
|
|
60
|
-
}
|
|
55
|
+
const GROUP_POLL_INTERVAL_MS = 25;
|
|
56
|
+
const DOCKER_STOP_TIMEOUT_SLACK_MS = 10_000;
|
|
57
|
+
const EXIT_DRAIN_TIMEOUT_MS = 2000;
|
|
61
58
|
export function spawnProcess(options) {
|
|
62
59
|
let startedAt = "";
|
|
63
60
|
let exitedAt = null;
|
|
64
61
|
let exitCode = null;
|
|
65
62
|
let signal = null;
|
|
66
|
-
|
|
67
|
-
let
|
|
68
|
-
let
|
|
69
|
-
let totalBytesSeen = 0;
|
|
70
|
-
let killStarted = false;
|
|
63
|
+
const output = new BoundedOutput();
|
|
64
|
+
let killInFlight = null;
|
|
65
|
+
let generation = 0;
|
|
71
66
|
let child;
|
|
72
67
|
let settled = false;
|
|
73
68
|
// Set only for the container backend -- see kill() for why it needs a separate
|
|
74
69
|
// stop mechanism from bwrap's group-kill.
|
|
75
70
|
let containerName = null;
|
|
71
|
+
let sandboxDisclosures = NO_SANDBOX_DISCLOSURES;
|
|
72
|
+
let hostEnvironment = null;
|
|
73
|
+
let workloadInOwnPidNamespace = false;
|
|
74
|
+
let limitNotes = [];
|
|
76
75
|
let resolveExit;
|
|
77
76
|
let exitPromise;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
signal = null;
|
|
85
|
-
headBuffer = "";
|
|
86
|
-
tailBuffer = "";
|
|
87
|
-
totalBytesSeen = 0;
|
|
88
|
-
killStarted = false;
|
|
89
|
-
settled = false;
|
|
90
|
-
exitPromise = new Promise((resolve) => {
|
|
91
|
-
resolveExit = resolve;
|
|
77
|
+
let releaseOrphanProtection = () => undefined;
|
|
78
|
+
function planLaunch() {
|
|
79
|
+
const cwd = options.cwd === undefined ? process.cwd() : resolve(options.cwd);
|
|
80
|
+
const childEnvironment = buildChildEnvironment({
|
|
81
|
+
declared: options.env ?? {},
|
|
82
|
+
...(options.inheritHostEnvironment === undefined ? {} : { inheritHostEnvironment: options.inheritHostEnvironment }),
|
|
92
83
|
});
|
|
84
|
+
if (options.filesystemPolicy !== undefined || options.networkPolicy !== undefined) {
|
|
85
|
+
assertSandboxPathsExist(cwd, options.filesystemPolicy);
|
|
86
|
+
}
|
|
93
87
|
// "container" skips applyResourceLimits entirely (see SpawnProcessOptions.
|
|
94
88
|
// sandboxBackend) -- "bwrap" (default) is unchanged.
|
|
95
89
|
const wrapped = options.sandboxBackend === "container"
|
|
@@ -97,58 +91,118 @@ export function spawnProcess(options) {
|
|
|
97
91
|
interpreterCommand: options.command,
|
|
98
92
|
command: options.command,
|
|
99
93
|
args: options.args ?? [],
|
|
100
|
-
cwd
|
|
101
|
-
processEnv:
|
|
94
|
+
cwd,
|
|
95
|
+
processEnv: containerProcessEnv(options.env),
|
|
96
|
+
...(options.resourceLimits === undefined ? {} : { resourceLimits: options.resourceLimits }),
|
|
102
97
|
...(options.filesystemPolicy === undefined ? {} : { filesystemPolicy: options.filesystemPolicy }),
|
|
103
98
|
...(options.networkPolicy === undefined ? {} : { networkPolicy: options.networkPolicy }),
|
|
104
99
|
})
|
|
105
100
|
: applySandbox({
|
|
106
101
|
interpreterCommand: options.command,
|
|
107
102
|
...applyResourceLimits(options.command, options.args ?? [], options.resourceLimits),
|
|
108
|
-
cwd
|
|
103
|
+
cwd,
|
|
104
|
+
env: childEnvironment.env,
|
|
109
105
|
...(options.filesystemPolicy === undefined ? {} : { filesystemPolicy: options.filesystemPolicy }),
|
|
110
106
|
...(options.networkPolicy === undefined ? {} : { networkPolicy: options.networkPolicy }),
|
|
111
107
|
});
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
108
|
+
return { wrapped, childEnvironment, cwd };
|
|
109
|
+
}
|
|
110
|
+
// Everything a (re)spawn needs to reset, in one place -- construction and
|
|
111
|
+
// restart() must not drift into resetting different subsets of this state.
|
|
112
|
+
function spawnChild() {
|
|
113
|
+
const { wrapped, childEnvironment, cwd } = planLaunch();
|
|
114
|
+
startedAt = new Date().toISOString();
|
|
115
|
+
exitedAt = null;
|
|
116
|
+
exitCode = null;
|
|
117
|
+
signal = null;
|
|
118
|
+
output.startNewEpoch();
|
|
119
|
+
killInFlight = null;
|
|
120
|
+
settled = false;
|
|
121
|
+
exitPromise = new Promise((resolveExitPromise) => {
|
|
122
|
+
resolveExit = resolveExitPromise;
|
|
118
123
|
});
|
|
124
|
+
containerName = wrapped.containerName ?? null;
|
|
125
|
+
sandboxDisclosures = wrapped.sandboxDisclosures ?? NO_SANDBOX_DISCLOSURES;
|
|
126
|
+
hostEnvironment = childEnvironment.hostEnvironment;
|
|
127
|
+
workloadInOwnPidNamespace = wrapped.command === "bwrap";
|
|
128
|
+
limitNotes = options.sandboxBackend === "container" ? [] : resourceLimitNotes(options.resourceLimits);
|
|
129
|
+
try {
|
|
130
|
+
child = spawn(wrapped.command, [...wrapped.args], {
|
|
131
|
+
cwd,
|
|
132
|
+
env: { ...childEnvironment.env, ...wrapped.env },
|
|
133
|
+
detached: true,
|
|
134
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
exitedAt = new Date().toISOString();
|
|
139
|
+
settled = true;
|
|
140
|
+
resolveExit({ exitCode: null, signal: null });
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
releaseOrphanProtection = protectSpawned(child.pid, containerName);
|
|
119
144
|
// Shared by both streams -- stdout/stderr are already interleaved into one
|
|
120
145
|
// combined buffer (this module's existing contract), so head/tail bounding
|
|
121
146
|
// applies to the combined stream, not per-fd.
|
|
122
|
-
function
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
147
|
+
function appendText(text) {
|
|
148
|
+
output.appendText(text);
|
|
149
|
+
}
|
|
150
|
+
const spawnedGeneration = ++generation;
|
|
151
|
+
function decodeInto(stream) {
|
|
152
|
+
if (stream === null)
|
|
153
|
+
return;
|
|
154
|
+
const decoder = new StringDecoder("utf8");
|
|
155
|
+
stream.on("data", (chunk) => {
|
|
156
|
+
if (spawnedGeneration !== generation)
|
|
157
|
+
return;
|
|
158
|
+
output.countBytes(chunk.length);
|
|
159
|
+
appendText(decoder.write(chunk));
|
|
160
|
+
});
|
|
161
|
+
stream.on("end", () => {
|
|
162
|
+
if (spawnedGeneration !== generation)
|
|
163
|
+
return;
|
|
164
|
+
appendText(decoder.end());
|
|
165
|
+
});
|
|
134
166
|
}
|
|
135
|
-
child.stdout
|
|
136
|
-
child.stderr
|
|
167
|
+
decodeInto(child.stdout);
|
|
168
|
+
decodeInto(child.stderr);
|
|
137
169
|
// A bad command (ENOENT) surfaces as an `'error'` event, not a thrown exception,
|
|
138
170
|
// and per Node's docs `'exit'` may never fire after `'error'`. Both events settle
|
|
139
171
|
// the same promise exactly once, so a failed spawn reads as an ordinary
|
|
140
172
|
// never-became-ready process rather than crashing the caller or hanging cleanup.
|
|
141
|
-
|
|
142
|
-
|
|
173
|
+
let exitInfo = null;
|
|
174
|
+
let drainTimer;
|
|
175
|
+
function recordExit(info) {
|
|
176
|
+
if (exitInfo !== null)
|
|
143
177
|
return;
|
|
144
|
-
|
|
178
|
+
exitInfo = info;
|
|
145
179
|
exitedAt = new Date().toISOString();
|
|
146
180
|
exitCode = info.exitCode;
|
|
147
181
|
signal = info.signal;
|
|
148
|
-
resolveExit(info);
|
|
149
182
|
}
|
|
150
|
-
|
|
151
|
-
|
|
183
|
+
function settle() {
|
|
184
|
+
if (settled || exitInfo === null)
|
|
185
|
+
return;
|
|
186
|
+
settled = true;
|
|
187
|
+
clearTimeout(drainTimer);
|
|
188
|
+
resolveExit(exitInfo);
|
|
189
|
+
}
|
|
190
|
+
const releaseThisSpawn = releaseOrphanProtection;
|
|
191
|
+
const spawnedPid = child.pid;
|
|
192
|
+
child.once("exit", (code, exitSignal) => {
|
|
193
|
+
recordExit({ exitCode: code, signal: exitSignal });
|
|
194
|
+
drainTimer = setTimeout(settle, EXIT_DRAIN_TIMEOUT_MS);
|
|
195
|
+
if (spawnedPid !== undefined && containerName === null && !isProcessGroupAlive(spawnedPid))
|
|
196
|
+
releaseThisSpawn();
|
|
197
|
+
});
|
|
198
|
+
child.once("close", (code, exitSignal) => {
|
|
199
|
+
recordExit({ exitCode: code, signal: exitSignal });
|
|
200
|
+
settle();
|
|
201
|
+
});
|
|
202
|
+
child.once("error", () => {
|
|
203
|
+
recordExit({ exitCode: null, signal: null });
|
|
204
|
+
settle();
|
|
205
|
+
});
|
|
152
206
|
}
|
|
153
207
|
spawnChild();
|
|
154
208
|
function handle() {
|
|
@@ -164,16 +218,19 @@ export function spawnProcess(options) {
|
|
|
164
218
|
port: options.port ?? null,
|
|
165
219
|
};
|
|
166
220
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
killStarted = true;
|
|
221
|
+
function kill(gracePeriodMs = DEFAULT_GRACE_PERIOD_MS) {
|
|
222
|
+
killInFlight ??= terminateGroup(gracePeriodMs);
|
|
223
|
+
return killInFlight;
|
|
224
|
+
}
|
|
225
|
+
async function terminateGroup(gracePeriodMs) {
|
|
173
226
|
const pid = child.pid;
|
|
174
227
|
if (pid === undefined) {
|
|
175
228
|
return;
|
|
176
229
|
}
|
|
230
|
+
if (exitedAt !== null && !isProcessGroupAlive(pid) && containerName === null) {
|
|
231
|
+
releaseOrphanProtection();
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
177
234
|
if (containerName !== null) {
|
|
178
235
|
// Found via escape testing: the local `docker` CLI process is not the
|
|
179
236
|
// container -- it runs under `dockerd`, a separate process tree.
|
|
@@ -181,17 +238,45 @@ export function spawnProcess(options) {
|
|
|
181
238
|
// `docker stop` (by name) is the real stop mechanism here; `--rm` then
|
|
182
239
|
// removes it. Still falls through to group-kill below as a CLI backstop.
|
|
183
240
|
try {
|
|
184
|
-
await execFileAsync("docker", ["stop", "--time", String(Math.max(1, Math.round(gracePeriodMs / 1000))), containerName]
|
|
241
|
+
await execFileAsync("docker", ["stop", "--time", String(Math.max(1, Math.round(gracePeriodMs / 1000))), containerName], {
|
|
242
|
+
timeout: gracePeriodMs + DOCKER_STOP_TIMEOUT_SLACK_MS,
|
|
243
|
+
});
|
|
185
244
|
}
|
|
186
245
|
catch {
|
|
187
246
|
// Already stopped/removed -- not worth surfacing.
|
|
188
247
|
}
|
|
189
248
|
}
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
249
|
+
requestGracefulStop(pid);
|
|
250
|
+
const groupGone = await waitForGroupToEmpty(pid, Date.now() + gracePeriodMs);
|
|
251
|
+
if (!groupGone) {
|
|
193
252
|
signalGroup(pid, "SIGKILL");
|
|
194
|
-
|
|
253
|
+
}
|
|
254
|
+
await exitPromise;
|
|
255
|
+
releaseOrphanProtection();
|
|
256
|
+
}
|
|
257
|
+
function requestGracefulStop(pid) {
|
|
258
|
+
if (workloadInOwnPidNamespace) {
|
|
259
|
+
const descendants = liveDescendantPids(pid);
|
|
260
|
+
if (descendants.length > 0) {
|
|
261
|
+
for (const descendant of descendants)
|
|
262
|
+
signalPid(descendant, "SIGTERM");
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
signalGroup(pid, "SIGTERM");
|
|
267
|
+
}
|
|
268
|
+
async function waitForGroupToEmpty(pid, deadline) {
|
|
269
|
+
for (;;) {
|
|
270
|
+
if (exitedAt !== null && !isProcessGroupAlive(pid)) {
|
|
271
|
+
return true;
|
|
272
|
+
}
|
|
273
|
+
const remainingMs = deadline - Date.now();
|
|
274
|
+
if (remainingMs <= 0) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
const tick = cancellableSleep(Math.min(GROUP_POLL_INTERVAL_MS, remainingMs));
|
|
278
|
+
await Promise.race([exitPromise, tick.done]);
|
|
279
|
+
tick.cancel();
|
|
195
280
|
}
|
|
196
281
|
}
|
|
197
282
|
async function restart(gracePeriodMs = DEFAULT_GRACE_PERIOD_MS) {
|
|
@@ -202,27 +287,27 @@ export function spawnProcess(options) {
|
|
|
202
287
|
spawnChild();
|
|
203
288
|
}
|
|
204
289
|
function readOutput() {
|
|
205
|
-
|
|
206
|
-
if (droppedBytes <= 0) {
|
|
207
|
-
// No real gap yet -- tailBuffer alone already covers everything seen so
|
|
208
|
-
// far (headBuffer is a redundant prefix of it in this regime, since it
|
|
209
|
-
// hasn't been outgrown by the tail window sliding past it).
|
|
210
|
-
return tailBuffer;
|
|
211
|
-
}
|
|
212
|
-
return headBuffer + truncationMarker(totalBytesSeen, headBuffer.length + tailBuffer.length) + tailBuffer;
|
|
290
|
+
return output.read();
|
|
213
291
|
}
|
|
214
292
|
function outputInfo() {
|
|
215
|
-
return
|
|
293
|
+
return output.info();
|
|
294
|
+
}
|
|
295
|
+
function readOutputFrom(cursor) {
|
|
296
|
+
return output.readFrom(cursor);
|
|
216
297
|
}
|
|
217
298
|
return {
|
|
218
299
|
handle,
|
|
219
300
|
readOutput,
|
|
220
301
|
outputInfo,
|
|
302
|
+
readOutputFrom,
|
|
221
303
|
waitForExit: () => exitPromise,
|
|
222
304
|
kill,
|
|
223
305
|
restart,
|
|
224
306
|
// A spawned process has no output that predates this runtime observing it.
|
|
225
307
|
attachFence: () => null,
|
|
308
|
+
sandboxDisclosures: () => sandboxDisclosures,
|
|
309
|
+
hostEnvironment: () => hostEnvironment,
|
|
310
|
+
resourceLimitNotes: () => limitNotes,
|
|
226
311
|
};
|
|
227
312
|
}
|
|
228
313
|
/**
|
|
@@ -234,6 +319,8 @@ export function spawnProcess(options) {
|
|
|
234
319
|
* the reverse import would be circular. Tiny and pure enough to duplicate safely.
|
|
235
320
|
*/
|
|
236
321
|
export function isProcessAlive(pid) {
|
|
322
|
+
if (!Number.isSafeInteger(pid) || pid <= 0)
|
|
323
|
+
return false;
|
|
237
324
|
try {
|
|
238
325
|
process.kill(pid, 0);
|
|
239
326
|
return true;
|
|
@@ -243,10 +330,12 @@ export function isProcessAlive(pid) {
|
|
|
243
330
|
}
|
|
244
331
|
}
|
|
245
332
|
const DEFAULT_ATTACH_POLL_INTERVAL_MS = 50;
|
|
333
|
+
const ATTACH_READ_CHUNK_BYTES = 1024 * 1024;
|
|
334
|
+
const ATTACH_START_TIME_TOLERANCE_MS = 2000;
|
|
246
335
|
/**
|
|
247
|
-
* `ManagedProcess` for a service attached to rather than spawned.
|
|
248
|
-
* `attach.logFilePath`
|
|
249
|
-
*
|
|
336
|
+
* `ManagedProcess` for a service attached to rather than spawned. Tails
|
|
337
|
+
* `attach.logFilePath` from the last byte read into the same bounded head/tail
|
|
338
|
+
* buffer a spawned process uses, so a large log is read once, not on every poll.
|
|
250
339
|
*
|
|
251
340
|
* **`kill()` is a deliberate no-op, not a stub.** Descry did not spawn this process,
|
|
252
341
|
* and `ATTACH_MODE_SCOPE_DISCLOSURE` is unconditional: attach mode never executes
|
|
@@ -268,10 +357,17 @@ const DEFAULT_ATTACH_POLL_INTERVAL_MS = 50;
|
|
|
268
357
|
*/
|
|
269
358
|
export function attachManagedProcess(options) {
|
|
270
359
|
const { pid, logFilePath } = options.attach;
|
|
360
|
+
assertTargetPid(pid, { allowOwnProcess: true });
|
|
271
361
|
if (!isProcessAlive(pid)) {
|
|
272
362
|
throw new Error(`attachManagedProcess: pid ${String(pid)} for "${options.processId}" is not running — attach requires ` +
|
|
273
363
|
`a target that already exists.`);
|
|
274
364
|
}
|
|
365
|
+
if (logFilePath !== undefined) {
|
|
366
|
+
const resolution = resolveAttachLogPath(logFilePath);
|
|
367
|
+
if (!resolution.ok) {
|
|
368
|
+
throw new Error(`attachManagedProcess: log file refused for "${options.processId}": ${resolution.reason}`);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
275
371
|
// H-attach-log-discovery: `logFilePath` is now optional -- absent means either
|
|
276
372
|
// a stream source was discovered instead (`options.attach.discoveredLogSource`,
|
|
277
373
|
// consumed by the orchestrator directly, never through this file-tail path) or
|
|
@@ -279,13 +375,25 @@ export function attachManagedProcess(options) {
|
|
|
279
375
|
// reason about, so `fence` is honestly `null` here, same as `spawnProcess`'s own
|
|
280
376
|
// "nothing predates observation" case -- `ManagedProcess.attachFence()` already
|
|
281
377
|
// admits `null` for exactly this.
|
|
282
|
-
const fence = logFilePath === undefined ? null : computeAttachFence(pid, logFilePath);
|
|
283
|
-
let maxObservedByteLength = fence?.attachOffsetBytes ?? 0;
|
|
378
|
+
const fence = logFilePath === undefined ? null : computeAttachFence(pid, logFilePath, process.platform, options.maxBacklogBytes);
|
|
284
379
|
const startedAt = new Date().toISOString();
|
|
380
|
+
const attachedStartTime = getProcessStartTimeMs(pid);
|
|
285
381
|
let exitedAt = null;
|
|
286
382
|
let exitPromise = null;
|
|
383
|
+
let observationEnded = false;
|
|
384
|
+
let pollTimer;
|
|
385
|
+
function targetStillRunning() {
|
|
386
|
+
if (!isProcessAlive(pid))
|
|
387
|
+
return false;
|
|
388
|
+
if (!attachedStartTime.ok)
|
|
389
|
+
return true;
|
|
390
|
+
const current = getProcessStartTimeMs(pid);
|
|
391
|
+
if (!current.ok)
|
|
392
|
+
return true;
|
|
393
|
+
return Math.abs(current.startTimeMs - attachedStartTime.startTimeMs) < ATTACH_START_TIME_TOLERANCE_MS;
|
|
394
|
+
}
|
|
287
395
|
function handle() {
|
|
288
|
-
if (exitedAt === null && !
|
|
396
|
+
if (exitedAt === null && !targetStillRunning()) {
|
|
289
397
|
exitedAt = new Date().toISOString();
|
|
290
398
|
}
|
|
291
399
|
return {
|
|
@@ -303,70 +411,117 @@ export function attachManagedProcess(options) {
|
|
|
303
411
|
attached: true,
|
|
304
412
|
};
|
|
305
413
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
414
|
+
const output = new BoundedOutput();
|
|
415
|
+
let fileOffset = fence?.backlogStartOffset ?? 0;
|
|
416
|
+
if (fence !== null && fence.backlogSkippedBytes > 0)
|
|
417
|
+
output.skipLeadingBytes(fence.backlogSkippedBytes);
|
|
418
|
+
let fileIdentity = null;
|
|
419
|
+
const decoder = new StringDecoder("utf8");
|
|
420
|
+
let chunk = null;
|
|
421
|
+
function pump() {
|
|
311
422
|
if (logFilePath === undefined)
|
|
312
|
-
return
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
423
|
+
return;
|
|
424
|
+
const opened = openAttachLogFile(logFilePath);
|
|
425
|
+
if (!opened.ok) {
|
|
426
|
+
throw new Error(`attachManagedProcess: could not read log file "${logFilePath}" for pid ${String(pid)}: ${opened.reason}`);
|
|
316
427
|
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
428
|
+
const { descriptor } = opened;
|
|
429
|
+
try {
|
|
430
|
+
const replaced = fileIdentity !== null && opened.inode !== fileIdentity;
|
|
431
|
+
if (opened.size < fileOffset || (replaced && fileOffset > 0)) {
|
|
432
|
+
throw new Error(`attachManagedProcess: log file "${logFilePath}" for pid ${String(pid)} shrank from ${String(fileOffset)} ` +
|
|
433
|
+
`to ${String(opened.size)} bytes between reads -- this looks like log rotation or truncation, not new output. ` +
|
|
434
|
+
"The file-tail contract can't reconstruct a rotated/truncated buffer without either duplicating or dropping " +
|
|
435
|
+
`lines. Reported rather than guessed. (This run's own attach offset was ${String(fence?.attachOffsetBytes ?? 0)} bytes.)`);
|
|
436
|
+
}
|
|
437
|
+
fileIdentity = opened.inode;
|
|
438
|
+
chunk ??= Buffer.allocUnsafe(ATTACH_READ_CHUNK_BYTES);
|
|
439
|
+
while (fileOffset < opened.size) {
|
|
440
|
+
const read = readSync(descriptor, chunk, 0, Math.min(chunk.length, opened.size - fileOffset), fileOffset);
|
|
441
|
+
if (read === 0)
|
|
442
|
+
break;
|
|
443
|
+
fileOffset += read;
|
|
444
|
+
output.countBytes(read);
|
|
445
|
+
output.appendText(decoder.write(chunk.subarray(0, read)));
|
|
446
|
+
}
|
|
320
447
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
throw new Error(`attachManagedProcess: log file "${logFilePath}" for pid ${String(pid)} shrank from ${String(maxObservedByteLength)} ` +
|
|
324
|
-
`to ${String(byteLength)} bytes between reads -- this looks like log rotation or truncation, not new output. ` +
|
|
325
|
-
"The file-tail contract can't reconstruct a rotated/truncated buffer without either duplicating or dropping " +
|
|
326
|
-
// Non-null: `fence` is only ever null when `logFilePath` is undefined (set together,
|
|
327
|
-
// once, above), and this line is only reached when it is defined (the early return
|
|
328
|
-
// above covers the other case) -- the two are the same fact, just not one narrowing
|
|
329
|
-
// TS can see across this closure on its own.
|
|
330
|
-
`lines. Reported rather than guessed. (This run's own attach offset was ${String(fence.attachOffsetBytes)} bytes.)`);
|
|
448
|
+
finally {
|
|
449
|
+
closeSync(descriptor);
|
|
331
450
|
}
|
|
332
|
-
|
|
333
|
-
|
|
451
|
+
}
|
|
452
|
+
function readOutput() {
|
|
453
|
+
pump();
|
|
454
|
+
return output.read();
|
|
334
455
|
}
|
|
335
456
|
function waitForExit() {
|
|
336
|
-
exitPromise ??= new Promise((
|
|
457
|
+
exitPromise ??= new Promise((resolveExit) => {
|
|
337
458
|
const poll = () => {
|
|
338
|
-
|
|
459
|
+
pollTimer = undefined;
|
|
460
|
+
if (observationEnded)
|
|
461
|
+
return;
|
|
462
|
+
if (!targetStillRunning()) {
|
|
339
463
|
exitedAt ??= new Date().toISOString();
|
|
340
|
-
|
|
464
|
+
resolveExit({ exitCode: null, signal: null });
|
|
341
465
|
return;
|
|
342
466
|
}
|
|
343
|
-
setTimeout(poll, DEFAULT_ATTACH_POLL_INTERVAL_MS);
|
|
467
|
+
pollTimer = setTimeout(poll, DEFAULT_ATTACH_POLL_INTERVAL_MS);
|
|
468
|
+
pollTimer.unref();
|
|
344
469
|
};
|
|
345
470
|
poll();
|
|
346
471
|
});
|
|
347
472
|
return exitPromise;
|
|
348
473
|
}
|
|
349
474
|
function kill() {
|
|
350
|
-
|
|
475
|
+
observationEnded = true;
|
|
476
|
+
if (pollTimer !== undefined) {
|
|
477
|
+
clearTimeout(pollTimer);
|
|
478
|
+
pollTimer = undefined;
|
|
479
|
+
}
|
|
351
480
|
return Promise.resolve();
|
|
352
481
|
}
|
|
353
482
|
function restart() {
|
|
354
483
|
return Promise.reject(new Error(`attachManagedProcess: cannot restart an attached process (pid ${String(pid)}) — Descry did not spawn ` +
|
|
355
484
|
`it and has no command to relaunch it with.`));
|
|
356
485
|
}
|
|
357
|
-
/**
|
|
358
|
-
* RT-261's head/tail bound is a spawnProcess concern -- this path re-reads
|
|
359
|
-
* `logFilePath` fresh on every call rather than accumulating a growing
|
|
360
|
-
* in-memory buffer, so there is nothing here for that bound to apply to.
|
|
361
|
-
* `truncated` stays honestly false; `totalBytesSeen` reports the real file
|
|
362
|
-
* size observed on the most recent read (0 before the first one).
|
|
363
|
-
*/
|
|
364
486
|
function outputInfo() {
|
|
365
|
-
return
|
|
487
|
+
return output.info();
|
|
366
488
|
}
|
|
367
|
-
|
|
489
|
+
function readOutputFrom(cursor) {
|
|
490
|
+
pump();
|
|
491
|
+
return output.readFrom(cursor);
|
|
492
|
+
}
|
|
493
|
+
return { handle, readOutput, outputInfo, readOutputFrom, waitForExit, kill, restart, attachFence: () => fence, sandboxDisclosures: () => NO_SANDBOX_DISCLOSURES };
|
|
368
494
|
}
|
|
369
495
|
const execFileAsync = promisify(execFile);
|
|
496
|
+
function protectSpawned(pid, containerName) {
|
|
497
|
+
if (pid === undefined)
|
|
498
|
+
return () => undefined;
|
|
499
|
+
const releaseGroup = protectFromOrphaning({ kind: "group", pgid: pid });
|
|
500
|
+
if (containerName === null)
|
|
501
|
+
return releaseGroup;
|
|
502
|
+
const releaseContainer = protectFromOrphaning({ kind: "container", name: containerName });
|
|
503
|
+
return () => {
|
|
504
|
+
releaseGroup();
|
|
505
|
+
releaseContainer();
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
function assertSandboxPathsExist(cwd, filesystemPolicy) {
|
|
509
|
+
const required = [["working directory", cwd, true]];
|
|
510
|
+
for (const root of filesystemPolicy?.allowedRoots ?? [])
|
|
511
|
+
required.push(["allowed root", root, false]);
|
|
512
|
+
for (const [role, path, mustBeDirectory] of required) {
|
|
513
|
+
let isDirectory = false;
|
|
514
|
+
try {
|
|
515
|
+
isDirectory = statSync(path).isDirectory();
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
const detail = error.code === "ENOENT" ? "does not exist" : `could not be inspected (${error instanceof Error ? error.message : String(error)})`;
|
|
519
|
+
throw new Error(`sandbox refused: ${role} ${path} ${detail}`);
|
|
520
|
+
}
|
|
521
|
+
if (mustBeDirectory && !isDirectory)
|
|
522
|
+
throw new Error(`sandbox refused: ${role} ${path} is not a directory`);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
370
525
|
function signalGroup(pid, signal) {
|
|
371
526
|
try {
|
|
372
527
|
// Negative pid targets the whole process group (orphan-prevention mechanism).
|
|
@@ -376,7 +531,28 @@ function signalGroup(pid, signal) {
|
|
|
376
531
|
// Group already gone -- not an error.
|
|
377
532
|
}
|
|
378
533
|
}
|
|
379
|
-
function
|
|
380
|
-
|
|
534
|
+
function signalPid(pid, signal) {
|
|
535
|
+
try {
|
|
536
|
+
process.kill(pid, signal);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
function isProcessGroupAlive(pgid) {
|
|
543
|
+
try {
|
|
544
|
+
process.kill(-pgid, 0);
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
catch {
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
function cancellableSleep(ms) {
|
|
552
|
+
let timer;
|
|
553
|
+
const done = new Promise((resolve) => {
|
|
554
|
+
timer = setTimeout(resolve, ms);
|
|
555
|
+
});
|
|
556
|
+
return { done, cancel: () => clearTimeout(timer) };
|
|
381
557
|
}
|
|
382
558
|
//# sourceMappingURL=process-manager.js.map
|