@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.
Files changed (85) hide show
  1. package/dist/attach-discovery.d.ts +120 -0
  2. package/dist/attach-discovery.d.ts.map +1 -0
  3. package/dist/attach-discovery.js +409 -0
  4. package/dist/attach-discovery.js.map +1 -0
  5. package/dist/attach-fencing.d.ts +1 -0
  6. package/dist/attach-fencing.d.ts.map +1 -1
  7. package/dist/attach-fencing.js +44 -22
  8. package/dist/attach-fencing.js.map +1 -1
  9. package/dist/attach-log-file.d.ts +21 -0
  10. package/dist/attach-log-file.d.ts.map +1 -0
  11. package/dist/attach-log-file.js +81 -0
  12. package/dist/attach-log-file.js.map +1 -0
  13. package/dist/bounded-file.d.ts +11 -0
  14. package/dist/bounded-file.d.ts.map +1 -0
  15. package/dist/bounded-file.js +41 -0
  16. package/dist/bounded-file.js.map +1 -0
  17. package/dist/bounded-output.d.ts +24 -0
  18. package/dist/bounded-output.d.ts.map +1 -0
  19. package/dist/bounded-output.js +79 -0
  20. package/dist/bounded-output.js.map +1 -0
  21. package/dist/container-sandbox.d.ts +7 -20
  22. package/dist/container-sandbox.d.ts.map +1 -1
  23. package/dist/container-sandbox.js +139 -8
  24. package/dist/container-sandbox.js.map +1 -1
  25. package/dist/controller.d.ts +24 -2
  26. package/dist/controller.d.ts.map +1 -1
  27. package/dist/controller.js +260 -84
  28. package/dist/controller.js.map +1 -1
  29. package/dist/dependency-version-check.d.ts +1 -0
  30. package/dist/dependency-version-check.d.ts.map +1 -1
  31. package/dist/dependency-version-check.js +43 -24
  32. package/dist/dependency-version-check.js.map +1 -1
  33. package/dist/environment-metadata.d.ts +13 -0
  34. package/dist/environment-metadata.d.ts.map +1 -1
  35. package/dist/environment-metadata.js +138 -24
  36. package/dist/environment-metadata.js.map +1 -1
  37. package/dist/environment-version-check.d.ts +1 -0
  38. package/dist/environment-version-check.d.ts.map +1 -1
  39. package/dist/environment-version-check.js +13 -7
  40. package/dist/environment-version-check.js.map +1 -1
  41. package/dist/execution-safety.d.ts +2 -0
  42. package/dist/execution-safety.d.ts.map +1 -1
  43. package/dist/execution-safety.js +36 -0
  44. package/dist/execution-safety.js.map +1 -1
  45. package/dist/index.d.ts +16 -5
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -3
  48. package/dist/index.js.map +1 -1
  49. package/dist/node-options.d.ts +3 -0
  50. package/dist/node-options.d.ts.map +1 -0
  51. package/dist/node-options.js +34 -0
  52. package/dist/node-options.js.map +1 -0
  53. package/dist/orphan-reaper-process.d.ts +2 -0
  54. package/dist/orphan-reaper-process.d.ts.map +1 -0
  55. package/dist/orphan-reaper-process.js +98 -0
  56. package/dist/orphan-reaper-process.js.map +1 -0
  57. package/dist/orphan-reaper.d.ts +25 -0
  58. package/dist/orphan-reaper.d.ts.map +1 -0
  59. package/dist/orphan-reaper.js +161 -0
  60. package/dist/orphan-reaper.js.map +1 -0
  61. package/dist/process-identity.d.ts +1 -0
  62. package/dist/process-identity.d.ts.map +1 -1
  63. package/dist/process-identity.js +18 -4
  64. package/dist/process-identity.js.map +1 -1
  65. package/dist/process-manager.d.ts +12 -9
  66. package/dist/process-manager.d.ts.map +1 -1
  67. package/dist/process-manager.js +316 -125
  68. package/dist/process-manager.js.map +1 -1
  69. package/dist/readiness.d.ts +1 -0
  70. package/dist/readiness.d.ts.map +1 -1
  71. package/dist/readiness.js +77 -11
  72. package/dist/readiness.js.map +1 -1
  73. package/dist/sandbox.d.ts +21 -8
  74. package/dist/sandbox.d.ts.map +1 -1
  75. package/dist/sandbox.js +359 -49
  76. package/dist/sandbox.js.map +1 -1
  77. package/dist/spawn-environment.d.ts +16 -0
  78. package/dist/spawn-environment.d.ts.map +1 -0
  79. package/dist/spawn-environment.js +35 -0
  80. package/dist/spawn-environment.js.map +1 -0
  81. package/dist/target-pid.d.ts +6 -0
  82. package/dist/target-pid.d.ts.map +1 -0
  83. package/dist/target-pid.js +18 -0
  84. package/dist/target-pid.js.map +1 -0
  85. package/package.json +2 -2
@@ -7,13 +7,21 @@
7
7
  * fixture-app slice (§34).
8
8
  */
9
9
  import { execFile, spawn } from "node:child_process";
10
- import { readFileSync } from "node:fs";
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
- * RT-261: `outputBuffer += chunk` grew without bound for the process's whole
49
- * life -- a chatty service (or one stuck in a crash-log loop) grew Descry's
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
- // Bounded head/tail buffer (RT-261) -- see OUTPUT_HEAD_BYTES/OUTPUT_TAIL_BYTES.
67
- let headBuffer = "";
68
- let tailBuffer = "";
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
- // Everything a (re)spawn needs to reset, in one place -- construction and
79
- // restart() must not drift into resetting different subsets of this state.
80
- function spawnChild() {
81
- startedAt = new Date().toISOString();
82
- exitedAt = null;
83
- exitCode = null;
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: options.cwd ?? process.cwd(),
101
- processEnv: { ...process.env, ...options.env },
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: options.cwd ?? process.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
- containerName = wrapped.containerName ?? null;
113
- child = spawn(wrapped.command, wrapped.args, {
114
- cwd: options.cwd,
115
- env: { ...process.env, ...options.env },
116
- detached: true,
117
- stdio: ["ignore", "pipe", "pipe"],
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 appendOutput(chunk) {
123
- totalBytesSeen += chunk.length;
124
- if (headBuffer.length < OUTPUT_HEAD_BYTES) {
125
- headBuffer += chunk.toString("utf8");
126
- if (headBuffer.length > OUTPUT_HEAD_BYTES) {
127
- headBuffer = headBuffer.slice(0, OUTPUT_HEAD_BYTES);
128
- }
129
- }
130
- tailBuffer += chunk.toString("utf8");
131
- if (tailBuffer.length > OUTPUT_TAIL_BYTES) {
132
- tailBuffer = tailBuffer.slice(tailBuffer.length - OUTPUT_TAIL_BYTES);
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?.on("data", appendOutput);
136
- child.stderr?.on("data", appendOutput);
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
- function settle(info) {
142
- if (settled)
173
+ let exitInfo = null;
174
+ let drainTimer;
175
+ function recordExit(info) {
176
+ if (exitInfo !== null)
143
177
  return;
144
- settled = true;
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
- child.once("exit", (code, exitSignal) => settle({ exitCode: code, signal: exitSignal }));
151
- child.once("error", () => settle({ exitCode: null, signal: null }));
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
- async function kill(gracePeriodMs = DEFAULT_GRACE_PERIOD_MS) {
168
- if (killStarted || exitedAt !== null) {
169
- await exitPromise;
170
- return;
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
- signalGroup(pid, "SIGTERM");
191
- const exited = await Promise.race([exitPromise.then(() => true), sleep(gracePeriodMs).then(() => false)]);
192
- if (!exited) {
249
+ requestGracefulStop(pid);
250
+ const groupGone = await waitForGroupToEmpty(pid, Date.now() + gracePeriodMs);
251
+ if (!groupGone) {
193
252
  signalGroup(pid, "SIGKILL");
194
- await exitPromise;
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
- const droppedBytes = totalBytesSeen - headBuffer.length - tailBuffer.length;
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 { totalBytesSeen, truncated: totalBytesSeen > headBuffer.length + tailBuffer.length };
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. Reads
248
- * `attach.logFilePath` fresh on every `readOutput()` -- same growing-whole-buffer
249
- * contract as a spawned process's in-memory pipe buffer.
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
- const fence = computeAttachFence(pid, logFilePath);
276
- let maxObservedByteLength = fence.attachOffsetBytes;
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 && !isProcessAlive(pid)) {
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)}, ${logFilePath}>`,
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
- function readOutput() {
300
- let content;
301
- try {
302
- content = readFileSync(logFilePath, "utf8");
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
- catch (error) {
305
- const detail = error instanceof Error ? error.message : String(error);
306
- throw new Error(`attachManagedProcess: could not read log file "${logFilePath}" for pid ${String(pid)}: ${detail}`);
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
- const byteLength = Buffer.byteLength(content, "utf8");
309
- if (byteLength < maxObservedByteLength) {
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
- maxObservedByteLength = byteLength;
316
- return content;
449
+ }
450
+ function readOutput() {
451
+ pump();
452
+ return output.read();
317
453
  }
318
454
  function waitForExit() {
319
- exitPromise ??= new Promise((resolve) => {
455
+ exitPromise ??= new Promise((resolveExit) => {
320
456
  const poll = () => {
321
- if (!isProcessAlive(pid)) {
457
+ pollTimer = undefined;
458
+ if (observationEnded)
459
+ return;
460
+ if (!targetStillRunning()) {
322
461
  exitedAt ??= new Date().toISOString();
323
- resolve({ exitCode: null, signal: null });
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
- // Deliberate no-op -- see module doc above.
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 { totalBytesSeen: maxObservedByteLength, truncated: false };
485
+ return output.info();
349
486
  }
350
- return { handle, readOutput, outputInfo, waitForExit, kill, restart, attachFence: () => fence };
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 sleep(ms) {
363
- return new Promise((resolve) => setTimeout(resolve, ms));
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