@astrosheep/keiyaku 4.2.0 → 4.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { AkumaHandle } from "./akuma-handle.js";
2
- import type { AkumaCallContext, AkumaCallInput, AkumaConfiguration, AkumaList, AkumaListInput, ResultRoute } from "./akuma.js";
2
+ import type { AkumaCallContext, AkumaCallInput, AkumaConfiguration, AkumaList, AkumaListInput } from "./akuma.js";
3
3
  import { CALL_WITH_CONTEXT } from "./akuma-product-symbols.js";
4
+ import type { ResultRoute } from "./result-route.js";
4
5
  import type { WorldRoot } from "../world.js";
5
6
  type AkumaCallLaunchInput = AkumaCallInput & Readonly<{
6
7
  resultRoute?: ResultRoute;
@@ -57,13 +57,12 @@ export type AkumaCallInput = Readonly<{
57
57
  readonly?: true;
58
58
  allowed?: readonly AllowedAction[];
59
59
  }>;
60
- /** Opaque, serializable caller result address carried across the Body process. */
61
- export type ResultRoute = Readonly<unknown>;
62
60
  export type AkumaCallContext = Readonly<{
63
61
  initiatorCwd?: string;
64
62
  cwdCanonical?: true;
65
63
  }>;
66
64
  export type { TellResult, TellWake } from "./body.js";
65
+ export type { ResultRoute } from "./result-route.js";
67
66
  export type InterruptReceipt = Readonly<{
68
67
  kind: "unavailable";
69
68
  evidence: "hung" | "untidy" | "unavailable";
@@ -1,7 +1,7 @@
1
1
  import { type SessionFact, type Soul } from "./heart/index.js";
2
2
  import type { AkumaPaths } from "./identity.js";
3
3
  import type { ProviderAdapter } from "./provider.js";
4
- import type { ResultRoute } from "./akuma.js";
4
+ import type { ResultRoute } from "./result-route.js";
5
5
  import { type UpstreamExecutionPort, type RequestChildLaunch } from "./request-serve.js";
6
6
  import { type OwnedProcess, type RunLogReference } from "../runtime/proc/run.js";
7
7
  export declare const LEASH_HELD_EXIT = 75;
@@ -43,7 +43,7 @@ export type TellWakeRuntime = Readonly<{
43
43
  }>;
44
44
  type BodyRuntime = Readonly<{
45
45
  now(): string;
46
- spawnChild?(launch: RequestChildLaunch): Promise<void>;
46
+ spawnChild?(launch: RequestChildLaunch): Promise<OwnedProcess>;
47
47
  spawnBody?(launch: BodyLaunch): Promise<OwnedProcess>;
48
48
  express?(route: ResultRoute, options: Readonly<{
49
49
  as: string;
@@ -6,7 +6,7 @@ import { driveTurn, turnRecipe } from "./turn-drive.js";
6
6
  import { HeldAkumaLeash, breakBody, endTurn, finishBodyIfIdle, heartExists, isHeartAbsent, readHeart, readNonterminalRequests, watchHeart, } from "./heart/index.js";
7
7
  import { resolveProviderExecution } from "./providers/index.js";
8
8
  import { clearBodyRequestTransport, settleBodyRequests, } from "./request-serve.js";
9
- import { handoffProcess, spawnDetachedProcess, } from "../runtime/proc/run.js";
9
+ import { spawnDetachedProcess, } from "../runtime/proc/run.js";
10
10
  const LEASH_RETRY_MS = 100;
11
11
  export const LEASH_HELD_EXIT = 75;
12
12
  export { CONTROL_RESPONSE_MS } from "./body-supervisor.js";
@@ -102,7 +102,7 @@ async function expressInitialOutcome(launch, soul, outcome, runtime) {
102
102
  function defaultRuntime() {
103
103
  return {
104
104
  now: () => new Date().toISOString(),
105
- spawnChild: handoffAkumaBody,
105
+ spawnChild: spawnAkumaBody,
106
106
  spawnBody: spawnAkumaBody,
107
107
  };
108
108
  }
@@ -122,9 +122,6 @@ export async function bodyProcessInput(launch, bodyModuleUrl = import.meta.url)
122
122
  log: launch.paths.log,
123
123
  };
124
124
  }
125
- async function handoffAkumaBody(launch) {
126
- await handoffProcess(await bodyProcessInput(launch));
127
- }
128
125
  async function prepareBodyStart(paths, leash) {
129
126
  const before = await readHeart(paths);
130
127
  if (before.pause !== null)
@@ -181,7 +178,7 @@ async function runBodyTurns(input) {
181
178
  adapter,
182
179
  bodySequence,
183
180
  supervisor,
184
- runtimeSpawn: runtime.spawnChild ?? handoffAkumaBody,
181
+ runtimeSpawn: runtime.spawnChild ?? spawnAkumaBody,
185
182
  body: initial ?? "",
186
183
  ...(initial === undefined ? {} : { call: initial }),
187
184
  launchTells,
@@ -1,7 +1,8 @@
1
1
  import { type AkuId } from "./identity.js";
2
2
  import type { WorldRoot } from "../world.js";
3
3
  export { Akuma, AkumaHandle, AkumaNotBornError, defaultWaitComplete } from "./akuma.js";
4
- export type { AkumaCallInput, ResultRoute } from "./akuma.js";
4
+ export type { AkumaCallInput } from "./akuma.js";
5
+ export type { ResultRoute } from "./result-route.js";
5
6
  export { ALLOWED_ACTIONS } from "./allowed.js";
6
7
  export type { AllowedAction, AllowedActions } from "./allowed.js";
7
8
  export type { WorldRoot } from "../world.js";
@@ -0,0 +1,2 @@
1
+ /** Opaque, serializable caller result address carried across the Body process. */
2
+ export type ResultRoute = Readonly<unknown>;
@@ -0,0 +1 @@
1
+ export {};
@@ -28,7 +28,7 @@ export async function turnRecipe(paths, soul) {
28
28
  const admitted = latest?.provider === soul.provider.name ? latest : undefined;
29
29
  const session = admitted?.coordinate;
30
30
  return {
31
- cwd: admitted?.cwd ?? soul.cwd,
31
+ cwd: soul.cwd,
32
32
  options: admitted?.options ?? soul.options,
33
33
  ...(session === undefined ? {} : { session }),
34
34
  };
@@ -1,5 +1,4 @@
1
- import { type AkuId, type ActivityHistory } from "../../akuma/index.js";
2
- import type { ResultRoute } from "../../akuma/akuma.js";
1
+ import { type AkuId, type ActivityHistory, type ResultRoute } from "../../akuma/index.js";
3
2
  import { Keiyaku, type AkumaKillResult, type AkumaHistoryResult, type AkumaObservation, type AkumaTellResult, type AkumaWaitResult, type CallResult, type ForkResult, type Keiyaku as KeiyakuContract, type Repo } from "../../index.js";
4
3
  import type { Settings } from "../../settings.js";
5
4
  import type { WorldRoot } from "../../world.js";
@@ -1,33 +1,21 @@
1
1
  import { type ChildProcess } from "node:child_process";
2
- export type LaunchIntent = "handoff" | "retained";
3
2
  export type DetachedProcessInput = Readonly<{
4
3
  argv: readonly string[];
5
4
  cwd: string;
6
5
  env?: NodeJS.ProcessEnv;
7
6
  log: string;
8
7
  }>;
9
- export type SpawnLaunchPolicy = Readonly<{
10
- kind: "spawn";
11
- detached: boolean;
12
- windowsHide: true;
13
- shell: false;
14
- }>;
15
- export type PlatformLaunchPolicy = Readonly<{
16
- kind: "windows-launcher";
17
- }> | SpawnLaunchPolicy;
18
- export declare function platformLaunchPolicy(intent: LaunchIntent, platform?: NodeJS.Platform): PlatformLaunchPolicy;
19
- export declare function spawnOptionsFor<Stdio>(intent: LaunchIntent, input: Readonly<{
8
+ export declare function spawnOptionsFor<Stdio>(input: Readonly<{
20
9
  cwd?: string;
21
10
  env?: NodeJS.ProcessEnv;
22
- }>, stdio: Stdio, platform?: NodeJS.Platform): Readonly<{
11
+ }>, stdio: Stdio): Readonly<{
23
12
  cwd: string | undefined;
24
13
  env: NodeJS.ProcessEnv | undefined;
25
- detached: boolean;
14
+ detached: true;
26
15
  stdio: Stdio;
27
16
  windowsHide: true;
28
17
  shell: false;
29
18
  }>;
30
19
  export declare function windowsLauncherPath(): string;
31
- export declare function spawnLoggedProcess(input: DetachedProcessInput, intent: LaunchIntent): Promise<ChildProcess>;
32
- export declare function handoffWindowsLaunch(input: DetachedProcessInput, launcher?: string): Promise<void>;
33
- export declare function handoffProcess(input: DetachedProcessInput): Promise<void>;
20
+ export declare function launchFailure(command: string, diagnostic: string): NodeJS.ErrnoException;
21
+ export declare function spawnWindowsLauncher(input: DetachedProcessInput, launcher?: string): ChildProcess;
@@ -1,28 +1,14 @@
1
1
  import { spawn } from "node:child_process";
2
- import { open } from "node:fs/promises";
3
2
  import { resolve } from "node:path";
4
3
  import { fileURLToPath } from "node:url";
5
- export function platformLaunchPolicy(intent, platform = process.platform) {
6
- if (platform === "win32" && intent === "handoff")
7
- return { kind: "windows-launcher" };
8
- return {
9
- kind: "spawn",
10
- detached: true,
11
- windowsHide: true,
12
- shell: false,
13
- };
14
- }
15
- export function spawnOptionsFor(intent, input, stdio, platform = process.platform) {
16
- const policy = platformLaunchPolicy(intent, platform);
17
- if (policy.kind !== "spawn")
18
- throw new Error("Windows Body handoff uses the GUI launcher");
4
+ export function spawnOptionsFor(input, stdio) {
19
5
  return {
20
6
  cwd: input.cwd,
21
7
  env: input.env,
22
- detached: policy.detached,
8
+ detached: true,
23
9
  stdio,
24
- windowsHide: policy.windowsHide,
25
- shell: policy.shell,
10
+ windowsHide: true,
11
+ shell: false,
26
12
  };
27
13
  }
28
14
  export function windowsLauncherPath() {
@@ -32,7 +18,7 @@ export function windowsLauncherPath() {
32
18
  }
33
19
  return sourcePath;
34
20
  }
35
- function launchFailure(command, diagnostic) {
21
+ export function launchFailure(command, diagnostic) {
36
22
  const error = new Error(diagnostic.length > 0 ? diagnostic : `spawn ${command} UNKNOWN`);
37
23
  const code = /\b(?:ENOENT|EACCES|EINVAL)\b/.exec(error.message)?.[0];
38
24
  if (code)
@@ -41,58 +27,13 @@ function launchFailure(command, diagnostic) {
41
27
  error.path = command;
42
28
  return error;
43
29
  }
44
- async function waitSpawned(child) {
45
- await new Promise((resolve, reject) => {
46
- child.once("spawn", resolve);
47
- child.once("error", reject);
48
- });
49
- if (child.pid === undefined)
50
- throw new Error("detached process spawned without a pid");
51
- }
52
- export async function spawnLoggedProcess(input, intent) {
53
- const log = await open(input.log, "a");
54
- try {
55
- const child = spawn(input.argv[0], input.argv.slice(1), spawnOptionsFor(intent, input, ["ignore", log.fd, log.fd]));
56
- await waitSpawned(child);
57
- return child;
58
- }
59
- finally {
60
- await log.close();
61
- }
62
- }
63
- export async function handoffWindowsLaunch(input, launcher = windowsLauncherPath()) {
64
- const child = spawn(launcher, [input.log, ...input.argv], {
30
+ export function spawnWindowsLauncher(input, launcher = windowsLauncherPath()) {
31
+ return spawn(launcher, ["--retain", input.log, ...input.argv], {
65
32
  cwd: input.cwd,
66
33
  env: input.env,
67
- stdio: ["ignore", "ignore", "pipe"],
34
+ detached: true,
35
+ stdio: ["pipe", "pipe", "pipe"],
68
36
  windowsHide: true,
69
37
  shell: false,
70
38
  });
71
- let stderr = "";
72
- child.stderr?.setEncoding("utf8");
73
- child.stderr?.on("data", (chunk) => {
74
- stderr = `${stderr}${chunk}`.slice(-4_000);
75
- });
76
- const closed = new Promise((resolve) => {
77
- child.once("close", (exitCode) => resolve(exitCode));
78
- });
79
- try {
80
- await waitSpawned(child);
81
- }
82
- catch (error) {
83
- throw error instanceof Error ? error : new Error(String(error));
84
- }
85
- const code = await closed;
86
- if (code === 0)
87
- return;
88
- throw launchFailure(input.argv[0] ?? launcher, stderr.trim());
89
- }
90
- export async function handoffProcess(input) {
91
- const policy = platformLaunchPolicy("handoff");
92
- if (policy.kind === "windows-launcher") {
93
- await handoffWindowsLaunch(input);
94
- return;
95
- }
96
- const child = await spawnLoggedProcess(input, "handoff");
97
- child.unref();
98
39
  }
@@ -0,0 +1,6 @@
1
+ export type ProcessLifecycle = Readonly<{
2
+ markInert(): void;
3
+ terminate(force?: boolean): Promise<void>;
4
+ release(): void;
5
+ }>;
6
+ export declare function createProcessLifecycle(runTermination: (force: boolean) => Promise<void>, releaseCustody: () => void): ProcessLifecycle;
@@ -0,0 +1,26 @@
1
+ export function createProcessLifecycle(runTermination, releaseCustody) {
2
+ let state = "active";
3
+ let termination;
4
+ return {
5
+ markInert() {
6
+ state = "inert";
7
+ },
8
+ terminate(force = false) {
9
+ if (state === "inert")
10
+ return Promise.resolve();
11
+ if (termination !== undefined)
12
+ return termination;
13
+ state = "terminating";
14
+ termination = runTermination(force).finally(() => {
15
+ state = "inert";
16
+ });
17
+ return termination;
18
+ },
19
+ release() {
20
+ if (state !== "active")
21
+ return;
22
+ state = "inert";
23
+ releaseCustody();
24
+ },
25
+ };
26
+ }
@@ -0,0 +1,4 @@
1
+ import { type FileHandle } from "node:fs/promises";
2
+ import type { DetachedProcessExit } from "./types.js";
3
+ export declare function detachedExitStatus(code: number | null, signal: NodeJS.Signals | null): string;
4
+ export declare function retainDetachedExitEvidence(log: FileHandle, path: string, from: number, code: number | null, signal: NodeJS.Signals | null): Promise<DetachedProcessExit>;
@@ -0,0 +1,31 @@
1
+ import { open } from "node:fs/promises";
2
+ export function detachedExitStatus(code, signal) {
3
+ return code === null ? `signal ${signal ?? "unknown"}` : `exit ${code}`;
4
+ }
5
+ export async function retainDetachedExitEvidence(log, path, from, code, signal) {
6
+ const status = detachedExitStatus(code, signal);
7
+ const marker = Buffer.from(`[child ${status}]\n`);
8
+ let written = 0;
9
+ while (written < marker.byteLength) {
10
+ const write = await log.write(marker, written, marker.byteLength - written);
11
+ if (write.bytesWritten === 0)
12
+ throw new Error("run log exit marker write made no progress");
13
+ written += write.bytesWritten;
14
+ }
15
+ const evidence = await log.stat();
16
+ if (evidence.size < from)
17
+ throw new Error("run log shrank before exit evidence was retained");
18
+ const referenced = await open(path, "r");
19
+ try {
20
+ const current = await referenced.stat();
21
+ if (current.dev !== evidence.dev || current.ino !== evidence.ino) {
22
+ throw new Error("run log path changed before exit evidence was retained");
23
+ }
24
+ if (current.size < evidence.size)
25
+ throw new Error("run log path size changed before exit evidence was retained");
26
+ }
27
+ finally {
28
+ await referenced.close();
29
+ }
30
+ return { code, signal, log: { path, from, to: evidence.size } };
31
+ }
@@ -1,6 +1,7 @@
1
- import { type ChildProcess } from "node:child_process";
2
1
  import { type DetachedProcessInput } from "./launch.js";
3
- export { handoffProcess, type DetachedProcessInput } from "./launch.js";
2
+ import type { OwnedProcess } from "./types.js";
3
+ export type { DetachedProcessInput } from "./launch.js";
4
+ export type { DetachedProcessExit, OwnedProcess, RunLogReference } from "./types.js";
4
5
  type ProcessLaunch = Readonly<{
5
6
  readonly argv: readonly string[];
6
7
  readonly cwd?: string;
@@ -39,23 +40,7 @@ export type ProcessConsumption = Readonly<{
39
40
  readonly outcome: ProcessOutcome | ProcessStreamError;
40
41
  readonly pid: number | null;
41
42
  }>;
42
- export type RunLogReference = Readonly<{
43
- path: string;
44
- from: number;
45
- to: number;
46
- }>;
47
- export type DetachedProcessExit = Readonly<{
48
- code: number | null;
49
- signal: NodeJS.Signals | null;
50
- log: RunLogReference;
51
- }>;
52
- export type OwnedProcess = Readonly<{
53
- pid: number;
54
- exited: Promise<DetachedProcessExit>;
55
- terminate(force?: boolean): Promise<void>;
56
- release(): void;
57
- }>;
58
- export declare function terminateOwnedProcess(child: ChildProcess, force?: boolean): Promise<void>;
43
+ export { terminateOwnedProcess } from "./termination.js";
59
44
  export declare function spawnDetachedProcess(input: DetachedProcessInput): Promise<OwnedProcess>;
60
45
  export declare function runProcess(input: ProcessInput): Promise<ProcessOutcome>;
61
46
  export declare function runCrossPlatformProcess(input: ProcessInput): Promise<ProcessOutcome>;
@@ -1,11 +1,11 @@
1
- import { execFile, spawn } from "node:child_process";
1
+ import { spawn } from "node:child_process";
2
2
  import { open } from "node:fs/promises";
3
- import { setTimeout as delay } from "node:timers/promises";
4
- import { promisify } from "node:util";
5
3
  import crossSpawn from "cross-spawn";
6
4
  import { spawnOptionsFor } from "./launch.js";
7
- export { handoffProcess } from "./launch.js";
8
- const TERMINATION_GRACE_MS = 250;
5
+ import { detachedExitStatus, retainDetachedExitEvidence } from "./process-exit.js";
6
+ import { createProcessLifecycle } from "./lifecycle.js";
7
+ import { terminateOwnedProcess } from "./termination.js";
8
+ import { spawnWindowsRetainedProcess } from "./windows-run.js";
9
9
  const STREAM_TAIL_BYTES = 16 * 1024;
10
10
  function tailCapture(limit) {
11
11
  const bytes = Buffer.allocUnsafe(limit);
@@ -34,98 +34,10 @@ function tailCapture(limit) {
34
34
  },
35
35
  };
36
36
  }
37
- function ignoreMissingProcess(error) {
38
- if (error.code !== "ESRCH")
39
- throw error;
40
- }
41
- function detachedExitStatus(code, signal) {
42
- return code === null ? `signal ${signal ?? "unknown"}` : `exit ${code}`;
43
- }
44
- const WINDOWS_TERMINATION_TIMEOUT_MS = 1_000;
45
- const execFileAsync = promisify(execFile);
46
- async function terminateWindowsTree(pid) {
47
- await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], {
48
- timeout: WINDOWS_TERMINATION_TIMEOUT_MS,
49
- windowsHide: true,
50
- });
51
- }
52
- export async function terminateOwnedProcess(child, force = false) {
53
- const pid = child.pid;
54
- if (pid === undefined || child.exitCode !== null || child.signalCode !== null)
55
- return;
56
- let exited = false;
57
- const exit = new Promise((resolve) => {
58
- child.once("exit", () => {
59
- exited = true;
60
- resolve();
61
- });
62
- child.once("close", () => {
63
- exited = true;
64
- resolve();
65
- });
66
- });
67
- if (process.platform === "win32") {
68
- await terminateWindowsTree(pid);
69
- await exit;
70
- return;
71
- }
72
- if (force) {
73
- try {
74
- process.kill(-pid, "SIGKILL");
75
- }
76
- catch (error) {
77
- ignoreMissingProcess(error);
78
- }
79
- await exit;
80
- return;
81
- }
82
- try {
83
- process.kill(-pid, "SIGTERM");
84
- }
85
- catch (error) {
86
- ignoreMissingProcess(error);
87
- await exit;
88
- return;
89
- }
90
- await Promise.race([exit, delay(TERMINATION_GRACE_MS)]);
91
- if (exited || child.exitCode !== null || child.signalCode !== null)
92
- return;
93
- try {
94
- process.kill(-pid, "SIGKILL");
95
- }
96
- catch (error) {
97
- ignoreMissingProcess(error);
98
- }
99
- await exit;
100
- }
101
- async function retainDetachedExitEvidence(log, path, from, code, signal) {
102
- const status = detachedExitStatus(code, signal);
103
- const marker = Buffer.from(`[child ${status}]\n`);
104
- let written = 0;
105
- while (written < marker.byteLength) {
106
- const write = await log.write(marker, written, marker.byteLength - written);
107
- if (write.bytesWritten === 0)
108
- throw new Error("run log exit marker write made no progress");
109
- written += write.bytesWritten;
110
- }
111
- const evidence = await log.stat();
112
- if (evidence.size < from)
113
- throw new Error("run log shrank before exit evidence was retained");
114
- const referenced = await open(path, "r");
115
- try {
116
- const current = await referenced.stat();
117
- if (current.dev !== evidence.dev || current.ino !== evidence.ino) {
118
- throw new Error("run log path changed before exit evidence was retained");
119
- }
120
- if (current.size < evidence.size)
121
- throw new Error("run log path size changed before exit evidence was retained");
122
- }
123
- finally {
124
- await referenced.close();
125
- }
126
- return { code, signal, log: { path, from, to: evidence.size } };
127
- }
37
+ export { terminateOwnedProcess } from "./termination.js";
128
38
  export async function spawnDetachedProcess(input) {
39
+ if (process.platform === "win32")
40
+ return spawnWindowsRetainedProcess(input);
129
41
  const log = await open(input.log, "a");
130
42
  let launched = false;
131
43
  let logClosed = false;
@@ -137,11 +49,10 @@ export async function spawnDetachedProcess(input) {
137
49
  };
138
50
  try {
139
51
  const from = (await log.stat()).size;
140
- const child = spawn(input.argv[0], input.argv.slice(1), spawnOptionsFor("retained", input, ["ignore", log.fd, log.fd]));
141
- let state = "active";
142
- let termination;
52
+ const child = spawn(input.argv[0], input.argv.slice(1), spawnOptionsFor(input, ["ignore", log.fd, log.fd]));
53
+ const lifecycle = createProcessLifecycle((force) => terminateOwnedProcess(child, force), () => child.unref());
143
54
  const invalidate = () => {
144
- state = "inert";
55
+ lifecycle.markInert();
145
56
  };
146
57
  child.once("exit", invalidate);
147
58
  child.once("close", invalidate);
@@ -182,21 +93,8 @@ export async function spawnDetachedProcess(input) {
182
93
  return {
183
94
  pid,
184
95
  exited,
185
- terminate(force = false) {
186
- if (state === "inert")
187
- return Promise.resolve();
188
- if (termination !== undefined)
189
- return termination;
190
- state = "terminating";
191
- termination = terminateOwnedProcess(child, force).finally(invalidate);
192
- return termination;
193
- },
194
- release: () => {
195
- if (state === "inert")
196
- return;
197
- state = "inert";
198
- child.unref();
199
- },
96
+ terminate: lifecycle.terminate,
97
+ release: lifecycle.release,
200
98
  };
201
99
  }
202
100
  finally {
@@ -216,7 +114,7 @@ function terminalOutcome(code, stdout, stderr, consuming) {
216
114
  async function executeProcess(input, timeoutMs, consumeStdout, spawnProcess = spawn) {
217
115
  if (input.signal?.aborted === true)
218
116
  return { outcome: { kind: "cancelled" }, pid: null };
219
- const child = spawnProcess(input.argv[0], input.argv.slice(1), spawnOptionsFor("retained", input, ["ignore", "pipe", "pipe"]));
117
+ const child = spawnProcess(input.argv[0], input.argv.slice(1), spawnOptionsFor(input, ["ignore", "pipe", "pipe"]));
220
118
  const stdout = tailCapture(STREAM_TAIL_BYTES);
221
119
  const stderr = tailCapture(STREAM_TAIL_BYTES);
222
120
  let stop;
@@ -4,10 +4,7 @@ import { spawnOptionsFor } from "./launch.js";
4
4
  import { terminateOwnedProcess } from "./run.js";
5
5
  const DEFAULT_DRAIN_TIMEOUT_MS = 1_000;
6
6
  export function spawnStdioProcess(input) {
7
- const child = spawn(input.argv[0], input.argv.slice(1), spawnOptionsFor("retained", {
8
- cwd: input.cwd,
9
- env: input.env ?? process.env,
10
- }, ["pipe", "pipe", "pipe"]));
7
+ const child = spawn(input.argv[0], input.argv.slice(1), spawnOptionsFor({ cwd: input.cwd, env: input.env ?? process.env }, ["pipe", "pipe", "pipe"]));
11
8
  let stderr = "";
12
9
  let inputEnded = false;
13
10
  let forced;
@@ -0,0 +1,3 @@
1
+ import { type ChildProcess } from "node:child_process";
2
+ export declare function terminateWindowsTree(pid: number): Promise<void>;
3
+ export declare function terminateOwnedProcess(child: ChildProcess, force?: boolean): Promise<void>;
@@ -0,0 +1,74 @@
1
+ import { execFile } from "node:child_process";
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { promisify } from "node:util";
4
+ const TERMINATION_GRACE_MS = 250;
5
+ const WINDOWS_TERMINATION_TIMEOUT_MS = 1_000;
6
+ const execFileAsync = promisify(execFile);
7
+ function ignoreMissingProcess(error) {
8
+ if (error.code !== "ESRCH")
9
+ throw error;
10
+ }
11
+ export async function terminateWindowsTree(pid) {
12
+ try {
13
+ await execFileAsync("taskkill", ["/PID", String(pid), "/T", "/F"], {
14
+ timeout: WINDOWS_TERMINATION_TIMEOUT_MS,
15
+ windowsHide: true,
16
+ });
17
+ }
18
+ catch (error) {
19
+ const diagnostic = error;
20
+ const text = `${diagnostic.stderr ?? ""} ${diagnostic.message ?? ""}`;
21
+ if (diagnostic.code === "ESRCH" || /not found|no running instance|does not exist/iu.test(text))
22
+ return;
23
+ throw error;
24
+ }
25
+ }
26
+ export async function terminateOwnedProcess(child, force = false) {
27
+ const pid = child.pid;
28
+ if (pid === undefined || child.exitCode !== null || child.signalCode !== null)
29
+ return;
30
+ let exited = false;
31
+ const exit = new Promise((resolve) => {
32
+ child.once("exit", () => {
33
+ exited = true;
34
+ resolve();
35
+ });
36
+ child.once("close", () => {
37
+ exited = true;
38
+ resolve();
39
+ });
40
+ });
41
+ if (process.platform === "win32") {
42
+ await terminateWindowsTree(pid);
43
+ await exit;
44
+ return;
45
+ }
46
+ if (force) {
47
+ try {
48
+ process.kill(-pid, "SIGKILL");
49
+ }
50
+ catch (error) {
51
+ ignoreMissingProcess(error);
52
+ }
53
+ await exit;
54
+ return;
55
+ }
56
+ try {
57
+ process.kill(-pid, "SIGTERM");
58
+ }
59
+ catch (error) {
60
+ ignoreMissingProcess(error);
61
+ await exit;
62
+ return;
63
+ }
64
+ await Promise.race([exit, delay(TERMINATION_GRACE_MS)]);
65
+ if (exited || child.exitCode !== null || child.signalCode !== null)
66
+ return;
67
+ try {
68
+ process.kill(-pid, "SIGKILL");
69
+ }
70
+ catch (error) {
71
+ ignoreMissingProcess(error);
72
+ }
73
+ await exit;
74
+ }
@@ -0,0 +1,16 @@
1
+ export type RunLogReference = Readonly<{
2
+ path: string;
3
+ from: number;
4
+ to: number;
5
+ }>;
6
+ export type DetachedProcessExit = Readonly<{
7
+ code: number | null;
8
+ signal: NodeJS.Signals | null;
9
+ log: RunLogReference;
10
+ }>;
11
+ export type OwnedProcess = Readonly<{
12
+ pid: number;
13
+ exited: Promise<DetachedProcessExit>;
14
+ terminate(force?: boolean): Promise<void>;
15
+ release(): void;
16
+ }>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ import { type DetachedProcessInput } from "./launch.js";
2
+ import type { OwnedProcess } from "./types.js";
3
+ export declare function spawnWindowsRetainedProcess(input: DetachedProcessInput): Promise<OwnedProcess>;
@@ -0,0 +1,193 @@
1
+ import { open } from "node:fs/promises";
2
+ import { createInterface } from "node:readline";
3
+ import { launchFailure, spawnWindowsLauncher } from "./launch.js";
4
+ import { detachedExitStatus, retainDetachedExitEvidence } from "./process-exit.js";
5
+ import { createProcessLifecycle } from "./lifecycle.js";
6
+ import { terminateWindowsTree } from "./termination.js";
7
+ function createWindowsContext(input, log, from, closeLog) {
8
+ const child = spawnWindowsLauncher(input);
9
+ const reader = createInterface({ input: child.stdout });
10
+ let startedResolve;
11
+ let startedReject;
12
+ let exitResolve;
13
+ let exitReject;
14
+ const startedPromise = new Promise((resolve, reject) => {
15
+ startedResolve = resolve;
16
+ startedReject = reject;
17
+ });
18
+ const exited = new Promise((resolve, reject) => {
19
+ exitResolve = resolve;
20
+ exitReject = reject;
21
+ });
22
+ void exited.catch(() => undefined);
23
+ child.stderr?.setEncoding("utf8");
24
+ const context = {
25
+ input,
26
+ log,
27
+ from,
28
+ closeLog,
29
+ child,
30
+ reader,
31
+ stderr: "",
32
+ started: false,
33
+ targetPid: undefined,
34
+ finished: false,
35
+ startedPromise,
36
+ startedResolve,
37
+ startedReject,
38
+ exited,
39
+ exitResolve,
40
+ exitReject,
41
+ };
42
+ child.stderr?.on("data", (chunk) => {
43
+ context.stderr = `${context.stderr}${chunk}`.slice(-4_000);
44
+ });
45
+ return context;
46
+ }
47
+ function failWindows(context, error) {
48
+ context.lifecycle?.markInert();
49
+ if (!context.started)
50
+ context.startedReject(error);
51
+ if (!context.finished) {
52
+ context.finished = true;
53
+ context.exitReject(error);
54
+ }
55
+ context.reader.close();
56
+ context.child.stdout?.resume();
57
+ if (context.targetPid !== undefined) {
58
+ void terminateWindowsTree(context.targetPid)
59
+ .catch(() => undefined)
60
+ .finally(() => context.child.stdin?.end());
61
+ }
62
+ else if (context.child.pid !== undefined && context.child.exitCode === null && context.child.signalCode === null) {
63
+ void terminateWindowsTree(context.child.pid)
64
+ .catch(() => undefined)
65
+ .finally(() => context.child.stdin?.end());
66
+ }
67
+ else {
68
+ context.child.stdin?.end();
69
+ }
70
+ }
71
+ function recordWindowsExit(context, code) {
72
+ if (context.finished)
73
+ return;
74
+ context.finished = true;
75
+ context.lifecycle?.markInert();
76
+ void (async () => {
77
+ let failure;
78
+ let result;
79
+ try {
80
+ result = await retainDetachedExitEvidence(context.log, context.input.log, context.from, code, null);
81
+ }
82
+ catch (error) {
83
+ failure = error;
84
+ }
85
+ try {
86
+ await context.closeLog();
87
+ }
88
+ catch (error) {
89
+ failure ??= error;
90
+ }
91
+ context.reader.close();
92
+ context.child.stdin?.end();
93
+ context.child.stdout?.resume();
94
+ if (failure !== undefined) {
95
+ const status = detachedExitStatus(code, null);
96
+ throw new Error(`pre-admission ${status}: run-log evidence unavailable: ${failure instanceof Error ? failure.message : String(failure)}`);
97
+ }
98
+ context.exitResolve(result);
99
+ })().catch(context.exitReject);
100
+ }
101
+ function handleWindowsLine(context, line) {
102
+ const startedMatch = /^started ([0-9]+)$/u.exec(line);
103
+ if (startedMatch !== null) {
104
+ const pid = Number.parseInt(startedMatch[1], 10);
105
+ if (!Number.isSafeInteger(pid) || pid <= 0 || context.started) {
106
+ failWindows(context, new Error("Windows launcher returned an invalid started record"));
107
+ return;
108
+ }
109
+ context.started = true;
110
+ context.targetPid = pid;
111
+ context.startedResolve(pid);
112
+ return;
113
+ }
114
+ const exitedMatch = /^exited ([0-9]+)$/u.exec(line);
115
+ if (exitedMatch !== null) {
116
+ if (!context.started) {
117
+ failWindows(context, new Error("Windows launcher returned exit before started"));
118
+ return;
119
+ }
120
+ recordWindowsExit(context, Number.parseInt(exitedMatch[1], 10));
121
+ return;
122
+ }
123
+ if (line.length > 0)
124
+ failWindows(context, new Error(`Windows launcher returned malformed protocol: ${line}`));
125
+ }
126
+ function installWindowsProtocol(context) {
127
+ context.child.once("error", (error) => failWindows(context, error));
128
+ context.child.once("close", () => {
129
+ if (!context.started) {
130
+ failWindows(context, launchFailure(context.input.argv[0] ?? "windows-launch.exe", context.stderr.trim()));
131
+ }
132
+ else if (!context.finished) {
133
+ failWindows(context, new Error("Windows launcher exited before target exit evidence"));
134
+ }
135
+ });
136
+ context.reader.on("line", (line) => handleWindowsLine(context, line));
137
+ }
138
+ function ownedWindowsProcess(context, pid) {
139
+ const lifecycle = createProcessLifecycle(async () => {
140
+ await terminateWindowsTree(pid);
141
+ await context.exited;
142
+ }, () => {
143
+ context.reader.close();
144
+ context.child.stdin?.write("release\n");
145
+ context.child.stdin?.end();
146
+ context.child.stdout?.resume();
147
+ context.child.unref();
148
+ void context.closeLog();
149
+ });
150
+ context.lifecycle = lifecycle;
151
+ return {
152
+ pid,
153
+ exited: context.exited,
154
+ terminate: lifecycle.terminate,
155
+ release: lifecycle.release,
156
+ };
157
+ }
158
+ export async function spawnWindowsRetainedProcess(input) {
159
+ const log = await open(input.log, "a");
160
+ let logClosed = false;
161
+ const closeLog = async () => {
162
+ if (logClosed)
163
+ return;
164
+ logClosed = true;
165
+ await log.close();
166
+ };
167
+ const context = createWindowsContext(input, log, (await log.stat()).size, closeLog);
168
+ installWindowsProtocol(context);
169
+ try {
170
+ const pid = await context.startedPromise;
171
+ return ownedWindowsProcess(context, pid);
172
+ }
173
+ catch (error) {
174
+ context.reader.close();
175
+ if (context.child.pid !== undefined) {
176
+ try {
177
+ await terminateWindowsTree(context.child.pid);
178
+ }
179
+ catch {
180
+ /* preserve the launch error */
181
+ }
182
+ }
183
+ await new Promise((resolve) => {
184
+ if (context.child.exitCode !== null || context.child.signalCode !== null) {
185
+ resolve();
186
+ return;
187
+ }
188
+ context.child.once("close", () => resolve());
189
+ });
190
+ await closeLog();
191
+ throw error instanceof Error ? error : new Error(String(error));
192
+ }
193
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astrosheep/keiyaku",
3
- "version": "4.2.0",
3
+ "version": "4.3.0",
4
4
  "files": [
5
5
  "build"
6
6
  ],