@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.
Files changed (85) hide show
  1. package/dist/attach-discovery.d.ts +26 -0
  2. package/dist/attach-discovery.d.ts.map +1 -1
  3. package/dist/attach-discovery.js +181 -10
  4. package/dist/attach-discovery.js.map +1 -1
  5. package/dist/attach-fencing.d.ts +11 -1
  6. package/dist/attach-fencing.d.ts.map +1 -1
  7. package/dist/attach-fencing.js +52 -23
  8. package/dist/attach-fencing.js.map +1 -1
  9. package/dist/attach-log-file.d.ts +23 -0
  10. package/dist/attach-log-file.d.ts.map +1 -0
  11. package/dist/attach-log-file.js +90 -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 +25 -0
  18. package/dist/bounded-output.d.ts.map +1 -0
  19. package/dist/bounded-output.js +95 -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 +140 -8
  24. package/dist/container-sandbox.js.map +1 -1
  25. package/dist/controller.d.ts +10 -2
  26. package/dist/controller.d.ts.map +1 -1
  27. package/dist/controller.js +245 -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 -7
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -4
  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 +13 -9
  66. package/dist/process-manager.d.ts.map +1 -1
  67. package/dist/process-manager.js +309 -133
  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 +22 -8
  74. package/dist/sandbox.d.ts.map +1 -1
  75. package/dist/sandbox.js +366 -51
  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,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 && !isProcessAlive(pid)) {
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
- function readOutput() {
307
- // No file to tail -- either a stream source was discovered (the
308
- // orchestrator reads that directly, never through here) or nothing was
309
- // found at all. Empty is the honest answer, not a guess standing in for
310
- // one: this function has never had anything to read in that case.
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
- let content;
314
- try {
315
- content = readFileSync(logFilePath, "utf8");
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
- catch (error) {
318
- const detail = error instanceof Error ? error.message : String(error);
319
- throw new Error(`attachManagedProcess: could not read log file "${logFilePath}" for pid ${String(pid)}: ${detail}`);
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
- const byteLength = Buffer.byteLength(content, "utf8");
322
- if (byteLength < maxObservedByteLength) {
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
- maxObservedByteLength = byteLength;
333
- return content;
451
+ }
452
+ function readOutput() {
453
+ pump();
454
+ return output.read();
334
455
  }
335
456
  function waitForExit() {
336
- exitPromise ??= new Promise((resolve) => {
457
+ exitPromise ??= new Promise((resolveExit) => {
337
458
  const poll = () => {
338
- if (!isProcessAlive(pid)) {
459
+ pollTimer = undefined;
460
+ if (observationEnded)
461
+ return;
462
+ if (!targetStillRunning()) {
339
463
  exitedAt ??= new Date().toISOString();
340
- resolve({ exitCode: null, signal: null });
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
- // Deliberate no-op -- see module doc above.
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 { totalBytesSeen: maxObservedByteLength, truncated: false };
487
+ return output.info();
366
488
  }
367
- return { handle, readOutput, outputInfo, waitForExit, kill, restart, attachFence: () => fence };
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 sleep(ms) {
380
- return new Promise((resolve) => setTimeout(resolve, ms));
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