@descryy/runtime-controller 0.3.9 → 0.4.1
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 +120 -0
- package/dist/attach-discovery.d.ts.map +1 -0
- package/dist/attach-discovery.js +409 -0
- package/dist/attach-discovery.js.map +1 -0
- package/dist/attach-fencing.d.ts +1 -0
- package/dist/attach-fencing.d.ts.map +1 -1
- package/dist/attach-fencing.js +44 -22
- package/dist/attach-fencing.js.map +1 -1
- package/dist/attach-log-file.d.ts +21 -0
- package/dist/attach-log-file.d.ts.map +1 -0
- package/dist/attach-log-file.js +81 -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 +24 -0
- package/dist/bounded-output.d.ts.map +1 -0
- package/dist/bounded-output.js +79 -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 +139 -8
- package/dist/container-sandbox.js.map +1 -1
- package/dist/controller.d.ts +24 -2
- package/dist/controller.d.ts.map +1 -1
- package/dist/controller.js +260 -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 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- 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 +12 -9
- package/dist/process-manager.d.ts.map +1 -1
- package/dist/process-manager.js +316 -125
- 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 +21 -8
- package/dist/sandbox.d.ts.map +1 -1
- package/dist/sandbox.js +359 -49
- 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,23 +357,49 @@ 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
|
}
|
|
275
|
-
|
|
276
|
-
|
|
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
|
+
}
|
|
371
|
+
// H-attach-log-discovery: `logFilePath` is now optional -- absent means either
|
|
372
|
+
// a stream source was discovered instead (`options.attach.discoveredLogSource`,
|
|
373
|
+
// consumed by the orchestrator directly, never through this file-tail path) or
|
|
374
|
+
// nothing was found at all. Either way there is no file for RT-N3's fence to
|
|
375
|
+
// reason about, so `fence` is honestly `null` here, same as `spawnProcess`'s own
|
|
376
|
+
// "nothing predates observation" case -- `ManagedProcess.attachFence()` already
|
|
377
|
+
// admits `null` for exactly this.
|
|
378
|
+
const fence = logFilePath === undefined ? null : computeAttachFence(pid, logFilePath);
|
|
277
379
|
const startedAt = new Date().toISOString();
|
|
380
|
+
const attachedStartTime = getProcessStartTimeMs(pid);
|
|
278
381
|
let exitedAt = null;
|
|
279
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
|
+
}
|
|
280
395
|
function handle() {
|
|
281
|
-
if (exitedAt === null && !
|
|
396
|
+
if (exitedAt === null && !targetStillRunning()) {
|
|
282
397
|
exitedAt = new Date().toISOString();
|
|
283
398
|
}
|
|
284
399
|
return {
|
|
285
400
|
processId: options.processId,
|
|
286
401
|
// No real command to report -- disclosed rather than guessed.
|
|
287
|
-
command: `<attached: pid ${String(pid)}
|
|
402
|
+
command: `<attached: pid ${String(pid)}${logFilePath === undefined ? "" : `, ${logFilePath}`}>`,
|
|
288
403
|
pid,
|
|
289
404
|
startedAt,
|
|
290
405
|
exitedAt,
|
|
@@ -296,60 +411,115 @@ export function attachManagedProcess(options) {
|
|
|
296
411
|
attached: true,
|
|
297
412
|
};
|
|
298
413
|
}
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
414
|
+
const output = new BoundedOutput();
|
|
415
|
+
let fileOffset = fence?.backlogUnavailableReason === undefined ? 0 : fence.attachOffsetBytes;
|
|
416
|
+
let fileIdentity = null;
|
|
417
|
+
const decoder = new StringDecoder("utf8");
|
|
418
|
+
let chunk = null;
|
|
419
|
+
function pump() {
|
|
420
|
+
if (logFilePath === undefined)
|
|
421
|
+
return;
|
|
422
|
+
const opened = openAttachLogFile(logFilePath);
|
|
423
|
+
if (!opened.ok) {
|
|
424
|
+
throw new Error(`attachManagedProcess: could not read log file "${logFilePath}" for pid ${String(pid)}: ${opened.reason}`);
|
|
303
425
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
426
|
+
const { descriptor } = opened;
|
|
427
|
+
try {
|
|
428
|
+
const replaced = fileIdentity !== null && opened.inode !== fileIdentity;
|
|
429
|
+
if (opened.size < fileOffset || (replaced && fileOffset > 0)) {
|
|
430
|
+
throw new Error(`attachManagedProcess: log file "${logFilePath}" for pid ${String(pid)} shrank from ${String(fileOffset)} ` +
|
|
431
|
+
`to ${String(opened.size)} bytes between reads -- this looks like log rotation or truncation, not new output. ` +
|
|
432
|
+
"The file-tail contract can't reconstruct a rotated/truncated buffer without either duplicating or dropping " +
|
|
433
|
+
`lines. Reported rather than guessed. (This run's own attach offset was ${String(fence?.attachOffsetBytes ?? 0)} bytes.)`);
|
|
434
|
+
}
|
|
435
|
+
fileIdentity = opened.inode;
|
|
436
|
+
chunk ??= Buffer.allocUnsafe(ATTACH_READ_CHUNK_BYTES);
|
|
437
|
+
while (fileOffset < opened.size) {
|
|
438
|
+
const read = readSync(descriptor, chunk, 0, Math.min(chunk.length, opened.size - fileOffset), fileOffset);
|
|
439
|
+
if (read === 0)
|
|
440
|
+
break;
|
|
441
|
+
fileOffset += read;
|
|
442
|
+
output.countBytes(read);
|
|
443
|
+
output.appendText(decoder.write(chunk.subarray(0, read)));
|
|
444
|
+
}
|
|
307
445
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
throw new Error(`attachManagedProcess: log file "${logFilePath}" for pid ${String(pid)} shrank from ${String(maxObservedByteLength)} ` +
|
|
311
|
-
`to ${String(byteLength)} bytes between reads -- this looks like log rotation or truncation, not new output. ` +
|
|
312
|
-
"The file-tail contract can't reconstruct a rotated/truncated buffer without either duplicating or dropping " +
|
|
313
|
-
`lines. Reported rather than guessed. (This run's own attach offset was ${String(fence.attachOffsetBytes)} bytes.)`);
|
|
446
|
+
finally {
|
|
447
|
+
closeSync(descriptor);
|
|
314
448
|
}
|
|
315
|
-
|
|
316
|
-
|
|
449
|
+
}
|
|
450
|
+
function readOutput() {
|
|
451
|
+
pump();
|
|
452
|
+
return output.read();
|
|
317
453
|
}
|
|
318
454
|
function waitForExit() {
|
|
319
|
-
exitPromise ??= new Promise((
|
|
455
|
+
exitPromise ??= new Promise((resolveExit) => {
|
|
320
456
|
const poll = () => {
|
|
321
|
-
|
|
457
|
+
pollTimer = undefined;
|
|
458
|
+
if (observationEnded)
|
|
459
|
+
return;
|
|
460
|
+
if (!targetStillRunning()) {
|
|
322
461
|
exitedAt ??= new Date().toISOString();
|
|
323
|
-
|
|
462
|
+
resolveExit({ exitCode: null, signal: null });
|
|
324
463
|
return;
|
|
325
464
|
}
|
|
326
|
-
setTimeout(poll, DEFAULT_ATTACH_POLL_INTERVAL_MS);
|
|
465
|
+
pollTimer = setTimeout(poll, DEFAULT_ATTACH_POLL_INTERVAL_MS);
|
|
466
|
+
pollTimer.unref();
|
|
327
467
|
};
|
|
328
468
|
poll();
|
|
329
469
|
});
|
|
330
470
|
return exitPromise;
|
|
331
471
|
}
|
|
332
472
|
function kill() {
|
|
333
|
-
|
|
473
|
+
observationEnded = true;
|
|
474
|
+
if (pollTimer !== undefined) {
|
|
475
|
+
clearTimeout(pollTimer);
|
|
476
|
+
pollTimer = undefined;
|
|
477
|
+
}
|
|
334
478
|
return Promise.resolve();
|
|
335
479
|
}
|
|
336
480
|
function restart() {
|
|
337
481
|
return Promise.reject(new Error(`attachManagedProcess: cannot restart an attached process (pid ${String(pid)}) — Descry did not spawn ` +
|
|
338
482
|
`it and has no command to relaunch it with.`));
|
|
339
483
|
}
|
|
340
|
-
/**
|
|
341
|
-
* RT-261's head/tail bound is a spawnProcess concern -- this path re-reads
|
|
342
|
-
* `logFilePath` fresh on every call rather than accumulating a growing
|
|
343
|
-
* in-memory buffer, so there is nothing here for that bound to apply to.
|
|
344
|
-
* `truncated` stays honestly false; `totalBytesSeen` reports the real file
|
|
345
|
-
* size observed on the most recent read (0 before the first one).
|
|
346
|
-
*/
|
|
347
484
|
function outputInfo() {
|
|
348
|
-
return
|
|
485
|
+
return output.info();
|
|
349
486
|
}
|
|
350
|
-
|
|
487
|
+
function readOutputFrom(cursor) {
|
|
488
|
+
pump();
|
|
489
|
+
return output.readFrom(cursor);
|
|
490
|
+
}
|
|
491
|
+
return { handle, readOutput, outputInfo, readOutputFrom, waitForExit, kill, restart, attachFence: () => fence, sandboxDisclosures: () => NO_SANDBOX_DISCLOSURES };
|
|
351
492
|
}
|
|
352
493
|
const execFileAsync = promisify(execFile);
|
|
494
|
+
function protectSpawned(pid, containerName) {
|
|
495
|
+
if (pid === undefined)
|
|
496
|
+
return () => undefined;
|
|
497
|
+
const releaseGroup = protectFromOrphaning({ kind: "group", pgid: pid });
|
|
498
|
+
if (containerName === null)
|
|
499
|
+
return releaseGroup;
|
|
500
|
+
const releaseContainer = protectFromOrphaning({ kind: "container", name: containerName });
|
|
501
|
+
return () => {
|
|
502
|
+
releaseGroup();
|
|
503
|
+
releaseContainer();
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function assertSandboxPathsExist(cwd, filesystemPolicy) {
|
|
507
|
+
const required = [["working directory", cwd, true]];
|
|
508
|
+
for (const root of filesystemPolicy?.allowedRoots ?? [])
|
|
509
|
+
required.push(["allowed root", root, false]);
|
|
510
|
+
for (const [role, path, mustBeDirectory] of required) {
|
|
511
|
+
let isDirectory = false;
|
|
512
|
+
try {
|
|
513
|
+
isDirectory = statSync(path).isDirectory();
|
|
514
|
+
}
|
|
515
|
+
catch (error) {
|
|
516
|
+
const detail = error.code === "ENOENT" ? "does not exist" : `could not be inspected (${error instanceof Error ? error.message : String(error)})`;
|
|
517
|
+
throw new Error(`sandbox refused: ${role} ${path} ${detail}`);
|
|
518
|
+
}
|
|
519
|
+
if (mustBeDirectory && !isDirectory)
|
|
520
|
+
throw new Error(`sandbox refused: ${role} ${path} is not a directory`);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
353
523
|
function signalGroup(pid, signal) {
|
|
354
524
|
try {
|
|
355
525
|
// Negative pid targets the whole process group (orphan-prevention mechanism).
|
|
@@ -359,7 +529,28 @@ function signalGroup(pid, signal) {
|
|
|
359
529
|
// Group already gone -- not an error.
|
|
360
530
|
}
|
|
361
531
|
}
|
|
362
|
-
function
|
|
363
|
-
|
|
532
|
+
function signalPid(pid, signal) {
|
|
533
|
+
try {
|
|
534
|
+
process.kill(pid, signal);
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function isProcessGroupAlive(pgid) {
|
|
541
|
+
try {
|
|
542
|
+
process.kill(-pgid, 0);
|
|
543
|
+
return true;
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
return false;
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
function cancellableSleep(ms) {
|
|
550
|
+
let timer;
|
|
551
|
+
const done = new Promise((resolve) => {
|
|
552
|
+
timer = setTimeout(resolve, ms);
|
|
553
|
+
});
|
|
554
|
+
return { done, cancel: () => clearTimeout(timer) };
|
|
364
555
|
}
|
|
365
556
|
//# sourceMappingURL=process-manager.js.map
|