@astrosheep/keiyaku 4.2.0 → 4.4.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.
- package/build/src/akuma/akuma-product.d.ts +2 -1
- package/build/src/akuma/akuma.d.ts +1 -2
- package/build/src/akuma/body.d.ts +2 -2
- package/build/src/akuma/body.js +3 -6
- package/build/src/akuma/index.d.ts +2 -1
- package/build/src/akuma/result-route.d.ts +2 -0
- package/build/src/akuma/result-route.js +1 -0
- package/build/src/akuma/turn-drive.js +1 -1
- package/build/src/cli/commands/akuma-invoke.d.ts +1 -2
- package/build/src/runtime/proc/launch.d.ts +5 -17
- package/build/src/runtime/proc/launch.js +9 -68
- package/build/src/runtime/proc/lifecycle.d.ts +6 -0
- package/build/src/runtime/proc/lifecycle.js +26 -0
- package/build/src/runtime/proc/process-exit.d.ts +4 -0
- package/build/src/runtime/proc/process-exit.js +31 -0
- package/build/src/runtime/proc/run.d.ts +4 -19
- package/build/src/runtime/proc/run.js +14 -116
- package/build/src/runtime/proc/stdio.js +1 -4
- package/build/src/runtime/proc/termination.d.ts +3 -0
- package/build/src/runtime/proc/termination.js +74 -0
- package/build/src/runtime/proc/types.d.ts +16 -0
- package/build/src/runtime/proc/types.js +1 -0
- package/build/src/runtime/proc/windows-launch.exe +0 -0
- package/build/src/runtime/proc/windows-run.d.ts +3 -0
- package/build/src/runtime/proc/windows-run.js +199 -0
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AkumaHandle } from "./akuma-handle.js";
|
|
2
|
-
import type { AkumaCallContext, AkumaCallInput, AkumaConfiguration, AkumaList, AkumaListInput
|
|
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 "./
|
|
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<
|
|
46
|
+
spawnChild?(launch: RequestChildLaunch): Promise<OwnedProcess>;
|
|
47
47
|
spawnBody?(launch: BodyLaunch): Promise<OwnedProcess>;
|
|
48
48
|
express?(route: ResultRoute, options: Readonly<{
|
|
49
49
|
as: string;
|
package/build/src/akuma/body.js
CHANGED
|
@@ -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 {
|
|
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:
|
|
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 ??
|
|
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
|
|
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 @@
|
|
|
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:
|
|
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
|
|
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
|
|
11
|
+
}>, stdio: Stdio): Readonly<{
|
|
23
12
|
cwd: string | undefined;
|
|
24
13
|
env: NodeJS.ProcessEnv | undefined;
|
|
25
|
-
detached:
|
|
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
|
|
32
|
-
export declare function
|
|
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
|
|
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:
|
|
8
|
+
detached: true,
|
|
23
9
|
stdio,
|
|
24
|
-
windowsHide:
|
|
25
|
-
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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(
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
186
|
-
|
|
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(
|
|
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("
|
|
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,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 {};
|
|
Binary file
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
released: false,
|
|
35
|
+
finished: false,
|
|
36
|
+
startedPromise,
|
|
37
|
+
startedResolve,
|
|
38
|
+
startedReject,
|
|
39
|
+
exited,
|
|
40
|
+
exitResolve,
|
|
41
|
+
exitReject,
|
|
42
|
+
};
|
|
43
|
+
child.stderr?.on("data", (chunk) => {
|
|
44
|
+
context.stderr = `${context.stderr}${chunk}`.slice(-4_000);
|
|
45
|
+
});
|
|
46
|
+
return context;
|
|
47
|
+
}
|
|
48
|
+
function failWindows(context, error) {
|
|
49
|
+
if (context.released)
|
|
50
|
+
return;
|
|
51
|
+
context.lifecycle?.markInert();
|
|
52
|
+
if (!context.started)
|
|
53
|
+
context.startedReject(error);
|
|
54
|
+
if (!context.finished) {
|
|
55
|
+
context.finished = true;
|
|
56
|
+
context.exitReject(error);
|
|
57
|
+
}
|
|
58
|
+
context.reader.close();
|
|
59
|
+
context.child.stdout?.resume();
|
|
60
|
+
if (context.targetPid !== undefined) {
|
|
61
|
+
void terminateWindowsTree(context.targetPid)
|
|
62
|
+
.catch(() => undefined)
|
|
63
|
+
.finally(() => context.child.stdin?.end());
|
|
64
|
+
}
|
|
65
|
+
else if (context.child.pid !== undefined && context.child.exitCode === null && context.child.signalCode === null) {
|
|
66
|
+
void terminateWindowsTree(context.child.pid)
|
|
67
|
+
.catch(() => undefined)
|
|
68
|
+
.finally(() => context.child.stdin?.end());
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
context.child.stdin?.end();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
function recordWindowsExit(context, code) {
|
|
75
|
+
if (context.finished)
|
|
76
|
+
return;
|
|
77
|
+
context.finished = true;
|
|
78
|
+
context.lifecycle?.markInert();
|
|
79
|
+
void (async () => {
|
|
80
|
+
let failure;
|
|
81
|
+
let result;
|
|
82
|
+
try {
|
|
83
|
+
result = await retainDetachedExitEvidence(context.log, context.input.log, context.from, code, null);
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
failure = error;
|
|
87
|
+
}
|
|
88
|
+
try {
|
|
89
|
+
await context.closeLog();
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
failure ??= error;
|
|
93
|
+
}
|
|
94
|
+
context.reader.close();
|
|
95
|
+
context.child.stdin?.end();
|
|
96
|
+
context.child.stdout?.resume();
|
|
97
|
+
if (failure !== undefined) {
|
|
98
|
+
const status = detachedExitStatus(code, null);
|
|
99
|
+
throw new Error(`pre-admission ${status}: run-log evidence unavailable: ${failure instanceof Error ? failure.message : String(failure)}`);
|
|
100
|
+
}
|
|
101
|
+
context.exitResolve(result);
|
|
102
|
+
})().catch(context.exitReject);
|
|
103
|
+
}
|
|
104
|
+
function handleWindowsLine(context, line) {
|
|
105
|
+
const startedMatch = /^started ([0-9]+)$/u.exec(line);
|
|
106
|
+
if (startedMatch !== null) {
|
|
107
|
+
const pid = Number.parseInt(startedMatch[1], 10);
|
|
108
|
+
if (!Number.isSafeInteger(pid) || pid <= 0 || context.started) {
|
|
109
|
+
failWindows(context, new Error("Windows launcher returned an invalid started record"));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
context.started = true;
|
|
113
|
+
context.targetPid = pid;
|
|
114
|
+
context.startedResolve(pid);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
const exitedMatch = /^exited ([0-9]+)$/u.exec(line);
|
|
118
|
+
if (exitedMatch !== null) {
|
|
119
|
+
if (!context.started) {
|
|
120
|
+
failWindows(context, new Error("Windows launcher returned exit before started"));
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
recordWindowsExit(context, Number.parseInt(exitedMatch[1], 10));
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
if (line.length > 0)
|
|
127
|
+
failWindows(context, new Error(`Windows launcher returned malformed protocol: ${line}`));
|
|
128
|
+
}
|
|
129
|
+
function installWindowsProtocol(context) {
|
|
130
|
+
context.child.once("error", (error) => failWindows(context, error));
|
|
131
|
+
context.child.once("close", () => {
|
|
132
|
+
if (context.released)
|
|
133
|
+
return;
|
|
134
|
+
if (!context.started) {
|
|
135
|
+
failWindows(context, launchFailure(context.input.argv[0] ?? "windows-launch.exe", context.stderr.trim()));
|
|
136
|
+
}
|
|
137
|
+
else if (!context.finished) {
|
|
138
|
+
failWindows(context, new Error("Windows launcher exited before target exit evidence"));
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
context.reader.on("line", (line) => handleWindowsLine(context, line));
|
|
142
|
+
}
|
|
143
|
+
function ownedWindowsProcess(context, pid) {
|
|
144
|
+
const lifecycle = createProcessLifecycle(async () => {
|
|
145
|
+
await terminateWindowsTree(pid);
|
|
146
|
+
await context.exited;
|
|
147
|
+
}, () => {
|
|
148
|
+
context.released = true;
|
|
149
|
+
context.reader.close();
|
|
150
|
+
context.child.stdin?.write("release\n");
|
|
151
|
+
context.child.stdin?.end();
|
|
152
|
+
context.child.stdout?.resume();
|
|
153
|
+
context.child.unref();
|
|
154
|
+
void context.closeLog();
|
|
155
|
+
});
|
|
156
|
+
context.lifecycle = lifecycle;
|
|
157
|
+
return {
|
|
158
|
+
pid,
|
|
159
|
+
exited: context.exited,
|
|
160
|
+
terminate: lifecycle.terminate,
|
|
161
|
+
release: lifecycle.release,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export async function spawnWindowsRetainedProcess(input) {
|
|
165
|
+
const log = await open(input.log, "a");
|
|
166
|
+
let logClosed = false;
|
|
167
|
+
const closeLog = async () => {
|
|
168
|
+
if (logClosed)
|
|
169
|
+
return;
|
|
170
|
+
logClosed = true;
|
|
171
|
+
await log.close();
|
|
172
|
+
};
|
|
173
|
+
const context = createWindowsContext(input, log, (await log.stat()).size, closeLog);
|
|
174
|
+
installWindowsProtocol(context);
|
|
175
|
+
try {
|
|
176
|
+
const pid = await context.startedPromise;
|
|
177
|
+
return ownedWindowsProcess(context, pid);
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
context.reader.close();
|
|
181
|
+
if (context.child.pid !== undefined) {
|
|
182
|
+
try {
|
|
183
|
+
await terminateWindowsTree(context.child.pid);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
/* preserve the launch error */
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
await new Promise((resolve) => {
|
|
190
|
+
if (context.child.exitCode !== null || context.child.signalCode !== null) {
|
|
191
|
+
resolve();
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
context.child.once("close", () => resolve());
|
|
195
|
+
});
|
|
196
|
+
await closeLog();
|
|
197
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
198
|
+
}
|
|
199
|
+
}
|