@launchfile/macos-dev 0.2.0 → 0.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.
- package/dist/bootstrap.js +3 -0
- package/dist/env-writer.d.ts +8 -5
- package/dist/env-writer.js +19 -10
- package/dist/process-manager.d.ts +13 -0
- package/dist/process-manager.js +57 -2
- package/dist/process-stopper.d.ts +99 -0
- package/dist/process-stopper.js +165 -0
- package/dist/provider.d.ts +18 -0
- package/dist/provider.js +148 -24
- package/dist/shell.js +1 -1
- package/dist/state.d.ts +28 -0
- package/dist/storage.d.ts +16 -3
- package/dist/storage.js +23 -6
- package/package.json +3 -3
- package/dist/__tests__/bootstrap.test.d.ts +0 -2
- package/dist/__tests__/bootstrap.test.js +0 -90
- package/dist/__tests__/dry-run.test.d.ts +0 -2
- package/dist/__tests__/dry-run.test.js +0 -141
- package/dist/__tests__/env-writer.test.d.ts +0 -2
- package/dist/__tests__/env-writer.test.js +0 -201
- package/dist/__tests__/lockfile-detect.test.d.ts +0 -2
- package/dist/__tests__/lockfile-detect.test.js +0 -75
- package/dist/__tests__/port-allocator.test.d.ts +0 -2
- package/dist/__tests__/port-allocator.test.js +0 -53
- package/dist/__tests__/secret-generator.test.d.ts +0 -2
- package/dist/__tests__/secret-generator.test.js +0 -26
- package/dist/__tests__/state.test.d.ts +0 -2
- package/dist/__tests__/state.test.js +0 -30
package/dist/bootstrap.js
CHANGED
|
@@ -158,6 +158,9 @@ export async function launchBootstrap(opts = {}) {
|
|
|
158
158
|
for (const [name, component] of Object.entries(launch.components)) {
|
|
159
159
|
if (opts.component && name !== opts.component)
|
|
160
160
|
continue;
|
|
161
|
+
// `bootstrap` is mode-invariant (D-38): the same command runs from
|
|
162
|
+
// source or artifact. A path or binary that differs by mode belongs in
|
|
163
|
+
// storage:/env/PATH, not a separate command.
|
|
161
164
|
const bootstrap = component.commands?.bootstrap;
|
|
162
165
|
if (!bootstrap)
|
|
163
166
|
continue;
|
package/dist/env-writer.d.ts
CHANGED
|
@@ -8,10 +8,13 @@ import { type NormalizedComponent, type NormalizedLaunch, type ResolverContext,
|
|
|
8
8
|
import type { ResourceProperties } from "./resources/types.js";
|
|
9
9
|
export type { ResolverContext };
|
|
10
10
|
/**
|
|
11
|
-
* Compute the $app.* property set for a Launchfile under the
|
|
12
|
-
* provider. The app's "primary" port comes from the first component
|
|
13
|
-
* declaration order) that has at least one `exposed: true` provides entry.
|
|
14
|
-
*
|
|
11
|
+
* Compute the $app.* property set (D-33, D-35) for a Launchfile under the
|
|
12
|
+
* macos-dev provider. The app's "primary" port comes from the first component
|
|
13
|
+
* (in declaration order) that has at least one `exposed: true` provides entry.
|
|
14
|
+
* The `authority`/`scheme`/`tls` trio is derived from the resulting URL via the
|
|
15
|
+
* SDK so split-field tokens (e.g. `CMD_DOMAIN: $app.authority`) resolve.
|
|
16
|
+
* Apps with no exposed component get `port: 0` and `url: ""` (and empty
|
|
17
|
+
* authority/scheme/tls).
|
|
15
18
|
*
|
|
16
19
|
* For multi-exposed-component apps that need a specific component's URL,
|
|
17
20
|
* use `$components.<name>.url` instead — `$app.*` always points at the
|
|
@@ -26,7 +29,7 @@ export declare function buildResolverContext(resourceMap: Record<string, Resourc
|
|
|
26
29
|
/**
|
|
27
30
|
* Resolve all environment variables for a single component.
|
|
28
31
|
*/
|
|
29
|
-
export declare function resolveComponentEnv(component: NormalizedComponent, context: ResolverContext, resourceMap: Record<string, ResourceProperties
|
|
32
|
+
export declare function resolveComponentEnv(component: NormalizedComponent, context: ResolverContext, resourceMap: Record<string, ResourceProperties>, storage?: Record<string, Record<string, string>>): Record<string, string>;
|
|
30
33
|
/**
|
|
31
34
|
* Generate all app-wide secrets, reusing values from state when available.
|
|
32
35
|
*/
|
package/dist/env-writer.js
CHANGED
|
@@ -6,13 +6,16 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { writeFile, mkdir } from "node:fs/promises";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
-
import { resolveExpression, isExpression, } from "@launchfile/sdk";
|
|
9
|
+
import { deriveAppUrlProperties, resolveExpression, isExpression, } from "@launchfile/sdk";
|
|
10
10
|
import { generateValue } from "./secret-generator.js";
|
|
11
11
|
/**
|
|
12
|
-
* Compute the $app.* property set for a Launchfile under the
|
|
13
|
-
* provider. The app's "primary" port comes from the first component
|
|
14
|
-
* declaration order) that has at least one `exposed: true` provides entry.
|
|
15
|
-
*
|
|
12
|
+
* Compute the $app.* property set (D-33, D-35) for a Launchfile under the
|
|
13
|
+
* macos-dev provider. The app's "primary" port comes from the first component
|
|
14
|
+
* (in declaration order) that has at least one `exposed: true` provides entry.
|
|
15
|
+
* The `authority`/`scheme`/`tls` trio is derived from the resulting URL via the
|
|
16
|
+
* SDK so split-field tokens (e.g. `CMD_DOMAIN: $app.authority`) resolve.
|
|
17
|
+
* Apps with no exposed component get `port: 0` and `url: ""` (and empty
|
|
18
|
+
* authority/scheme/tls).
|
|
16
19
|
*
|
|
17
20
|
* For multi-exposed-component apps that need a specific component's URL,
|
|
18
21
|
* use `$components.<name>.url` instead — `$app.*` always points at the
|
|
@@ -27,11 +30,13 @@ export function computeAppProperties(launch, componentPorts) {
|
|
|
27
30
|
break;
|
|
28
31
|
}
|
|
29
32
|
}
|
|
33
|
+
const url = primaryPort > 0 ? `http://localhost:${primaryPort}` : "";
|
|
30
34
|
return {
|
|
31
35
|
name: launch.name,
|
|
32
36
|
host: "localhost",
|
|
33
37
|
port: primaryPort,
|
|
34
|
-
url
|
|
38
|
+
url,
|
|
39
|
+
...deriveAppUrlProperties(url),
|
|
35
40
|
};
|
|
36
41
|
}
|
|
37
42
|
/**
|
|
@@ -64,8 +69,12 @@ export function buildResolverContext(resourceMap, componentPorts, secrets, app)
|
|
|
64
69
|
/**
|
|
65
70
|
* Resolve all environment variables for a single component.
|
|
66
71
|
*/
|
|
67
|
-
export function resolveComponentEnv(component, context, resourceMap) {
|
|
72
|
+
export function resolveComponentEnv(component, context, resourceMap, storage) {
|
|
68
73
|
const env = {};
|
|
74
|
+
// This component's provider-resolved storage paths (D-39). Scoped per
|
|
75
|
+
// component because volume names are component-local — component A's
|
|
76
|
+
// `$storage.cache.path` must not see component B's `cache`.
|
|
77
|
+
const ctx = storage ? { ...context, storage } : context;
|
|
69
78
|
// 1. Resolve set_env from requires
|
|
70
79
|
for (const req of component.requires ?? []) {
|
|
71
80
|
const resourceName = req.name ?? req.type;
|
|
@@ -79,7 +88,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
79
88
|
resourceRecord[k] = v;
|
|
80
89
|
}
|
|
81
90
|
const scopedContext = {
|
|
82
|
-
...
|
|
91
|
+
...ctx,
|
|
83
92
|
resource: resourceRecord,
|
|
84
93
|
};
|
|
85
94
|
for (const [envKey, expr] of Object.entries(req.set_env)) {
|
|
@@ -98,7 +107,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
98
107
|
resourceRecord[k] = v;
|
|
99
108
|
}
|
|
100
109
|
const scopedContext = {
|
|
101
|
-
...
|
|
110
|
+
...ctx,
|
|
102
111
|
resource: resourceRecord,
|
|
103
112
|
};
|
|
104
113
|
for (const [envKey, expr] of Object.entries(sup.set_env)) {
|
|
@@ -113,7 +122,7 @@ export function resolveComponentEnv(component, context, resourceMap) {
|
|
|
113
122
|
if (envVar.default !== undefined) {
|
|
114
123
|
const defaultStr = String(envVar.default);
|
|
115
124
|
if (isExpression(defaultStr)) {
|
|
116
|
-
env[key] = resolveExpression(defaultStr,
|
|
125
|
+
env[key] = resolveExpression(defaultStr, ctx);
|
|
117
126
|
}
|
|
118
127
|
else {
|
|
119
128
|
env[key] = defaultStr;
|
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
* health check waits, and graceful shutdown.
|
|
6
6
|
*/
|
|
7
7
|
import type { NormalizedHealth, NormalizedDependsOnEntry } from "@launchfile/sdk";
|
|
8
|
+
/** A spawned component process recorded for cross-session shutdown. */
|
|
9
|
+
export interface RecordedProcessInfo {
|
|
10
|
+
pid: number;
|
|
11
|
+
pgid: number;
|
|
12
|
+
startedAt: string;
|
|
13
|
+
command: string;
|
|
14
|
+
}
|
|
8
15
|
export declare class ProcessManager {
|
|
9
16
|
private processes;
|
|
10
17
|
private logDir;
|
|
@@ -32,6 +39,12 @@ export declare class ProcessManager {
|
|
|
32
39
|
* Returns batches of component names that can start concurrently.
|
|
33
40
|
*/
|
|
34
41
|
private topologicalSort;
|
|
42
|
+
/**
|
|
43
|
+
* Snapshot the live, spawned processes for persistence to state.json.
|
|
44
|
+
* Only includes components that actually spawned (have a pid). Because we
|
|
45
|
+
* spawn detached, the child is its own group leader, so pgid === pid.
|
|
46
|
+
*/
|
|
47
|
+
getRecordedProcesses(): Record<string, RecordedProcessInfo>;
|
|
35
48
|
/** Get status summary for all processes */
|
|
36
49
|
getStatus(): Array<{
|
|
37
50
|
name: string;
|
package/dist/process-manager.js
CHANGED
|
@@ -18,6 +18,28 @@ const COLORS = [
|
|
|
18
18
|
"\x1b[31m", // red
|
|
19
19
|
];
|
|
20
20
|
const RESET = "\x1b[0m";
|
|
21
|
+
/**
|
|
22
|
+
* Signal a detached child's whole process group (negative pid). Falls back to
|
|
23
|
+
* signaling just the child handle if the group signal fails (e.g. the group is
|
|
24
|
+
* already gone). Best-effort: swallows ESRCH so shutdown stays idempotent.
|
|
25
|
+
*/
|
|
26
|
+
function killGroupOrSelf(proc, pid, signal) {
|
|
27
|
+
if (pid !== undefined) {
|
|
28
|
+
try {
|
|
29
|
+
process.kill(-pid, signal);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Group gone or not a group leader — fall through to handle kill.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
proc?.kill(signal);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
// Already exited.
|
|
41
|
+
}
|
|
42
|
+
}
|
|
21
43
|
export class ProcessManager {
|
|
22
44
|
processes = new Map();
|
|
23
45
|
logDir;
|
|
@@ -73,11 +95,20 @@ export class ProcessManager {
|
|
|
73
95
|
const color = COLORS[colorIdx];
|
|
74
96
|
const maxNameLen = Math.max(...[...this.processes.keys()].map((n) => n.length));
|
|
75
97
|
const paddedName = name.padEnd(maxNameLen);
|
|
98
|
+
// `detached: true` makes the child the leader of a new process group
|
|
99
|
+
// (pgid === pid). That lets `launch down` signal the whole group later via
|
|
100
|
+
// a negative pid, killing the app AND any children it spawned — matching
|
|
101
|
+
// the foreground SIGINT behavior across sessions. We still keep the handle
|
|
102
|
+
// so the foreground session can kill it directly on Ctrl+C.
|
|
76
103
|
proc.process = spawn("sh", ["-c", proc.command], {
|
|
77
104
|
env: { ...process.env, ...proc.env },
|
|
78
105
|
cwd: proc.cwd,
|
|
79
106
|
stdio: ["ignore", "pipe", "pipe"],
|
|
107
|
+
detached: true,
|
|
80
108
|
});
|
|
109
|
+
if (proc.process.pid !== undefined) {
|
|
110
|
+
proc.startedAt = new Date().toISOString();
|
|
111
|
+
}
|
|
81
112
|
// Pipe stdout with prefix
|
|
82
113
|
proc.process.stdout?.on("data", (data) => {
|
|
83
114
|
const lines = data.toString().split("\n");
|
|
@@ -125,15 +156,19 @@ export class ProcessManager {
|
|
|
125
156
|
resolve();
|
|
126
157
|
return;
|
|
127
158
|
}
|
|
159
|
+
const pid = proc.process.pid;
|
|
128
160
|
const timeout = setTimeout(() => {
|
|
129
|
-
|
|
161
|
+
// Escalate to the whole group so stray children die too.
|
|
162
|
+
killGroupOrSelf(proc.process, pid, "SIGKILL");
|
|
130
163
|
}, 10_000);
|
|
131
164
|
proc.process.once("exit", () => {
|
|
132
165
|
clearTimeout(timeout);
|
|
133
166
|
proc.status = "stopped";
|
|
134
167
|
resolve();
|
|
135
168
|
});
|
|
136
|
-
|
|
169
|
+
// Children are spawned detached (own process group), so signal the
|
|
170
|
+
// group (negative pid) to reap any grandchildren too.
|
|
171
|
+
killGroupOrSelf(proc.process, pid, "SIGTERM");
|
|
137
172
|
});
|
|
138
173
|
}
|
|
139
174
|
/**
|
|
@@ -175,6 +210,26 @@ export class ProcessManager {
|
|
|
175
210
|
}
|
|
176
211
|
return batches;
|
|
177
212
|
}
|
|
213
|
+
/**
|
|
214
|
+
* Snapshot the live, spawned processes for persistence to state.json.
|
|
215
|
+
* Only includes components that actually spawned (have a pid). Because we
|
|
216
|
+
* spawn detached, the child is its own group leader, so pgid === pid.
|
|
217
|
+
*/
|
|
218
|
+
getRecordedProcesses() {
|
|
219
|
+
const out = {};
|
|
220
|
+
for (const [name, proc] of this.processes) {
|
|
221
|
+
const pid = proc.process?.pid;
|
|
222
|
+
if (pid === undefined || proc.startedAt === undefined)
|
|
223
|
+
continue;
|
|
224
|
+
out[name] = {
|
|
225
|
+
pid,
|
|
226
|
+
pgid: pid,
|
|
227
|
+
startedAt: proc.startedAt,
|
|
228
|
+
command: proc.command,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
178
233
|
/** Get status summary for all processes */
|
|
179
234
|
getStatus() {
|
|
180
235
|
return [...this.processes.entries()].map(([name, proc]) => ({
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-session app-process termination for `launch down`.
|
|
3
|
+
*
|
|
4
|
+
* The foreground `launch up` session kills its children directly via the
|
|
5
|
+
* ProcessManager on Ctrl+C. But `launch down` may run from a *different* shell
|
|
6
|
+
* (or after the up-session has exited), so it has no live ChildProcess handles —
|
|
7
|
+
* only the pids recorded in `.launchfile/state.json`. This module signals those
|
|
8
|
+
* recorded process groups, with liveness + identity checks to avoid killing an
|
|
9
|
+
* unrelated process that happens to have inherited a recycled pid.
|
|
10
|
+
*
|
|
11
|
+
* ## pid-reuse identity guarantee (read this before trusting the kill)
|
|
12
|
+
*
|
|
13
|
+
* macOS recycles pids. A pid we recorded at `up` time may, by `down` time,
|
|
14
|
+
* belong to a completely unrelated process. Before sending any signal we:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
|
|
17
|
+
* exists. If it's dead, we skip (already stopped).
|
|
18
|
+
* 2. **Identity** — we compare the recorded spawn time against the live
|
|
19
|
+
* process's actual start time via `ps -o lstart= -p <pid>`. If the live
|
|
20
|
+
* process started meaningfully later than we recorded, the pid was
|
|
21
|
+
* recycled and we REFUSE to signal it.
|
|
22
|
+
*
|
|
23
|
+
* This is a best-effort guarantee, not a cryptographic one. Its honest limits:
|
|
24
|
+
* - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
|
|
25
|
+
* recycled process that started within that window of the original spawn
|
|
26
|
+
* could theoretically slip through — vanishingly unlikely in practice.
|
|
27
|
+
* - If `ps` is unavailable/fails, we fall back to liveness-only and signal
|
|
28
|
+
* conservatively ONLY the process group (never a bare pid), accepting the
|
|
29
|
+
* small residual risk rather than orphaning the user's processes forever.
|
|
30
|
+
* - We signal the process GROUP (negative pgid) to also reap children the app
|
|
31
|
+
* spawned, matching the foreground SIGINT behavior. A recycled *group*
|
|
32
|
+
* leader is the residual risk the start-time check exists to close.
|
|
33
|
+
*/
|
|
34
|
+
/** Signal-sending surface, injectable so tests never touch real processes. */
|
|
35
|
+
export interface SignalFns {
|
|
36
|
+
/**
|
|
37
|
+
* Mirror of `process.kill`. Sending signal `0` performs a liveness probe
|
|
38
|
+
* (throws ESRCH if the target does not exist). A negative pid targets a
|
|
39
|
+
* process group.
|
|
40
|
+
*/
|
|
41
|
+
kill: (pid: number, signal: NodeJS.Signals | 0) => void;
|
|
42
|
+
/**
|
|
43
|
+
* Returns the live process's start time (epoch ms) for `pid`, or null if the
|
|
44
|
+
* process is gone or the start time can't be determined.
|
|
45
|
+
*/
|
|
46
|
+
startTime: (pid: number) => Promise<number | null>;
|
|
47
|
+
}
|
|
48
|
+
/** Outcome of attempting to stop one recorded process, for logging/tests. */
|
|
49
|
+
export type StopOutcome = {
|
|
50
|
+
component: string;
|
|
51
|
+
result: "stopped";
|
|
52
|
+
} | {
|
|
53
|
+
component: string;
|
|
54
|
+
result: "already-dead";
|
|
55
|
+
} | {
|
|
56
|
+
component: string;
|
|
57
|
+
result: "identity-mismatch";
|
|
58
|
+
} | {
|
|
59
|
+
component: string;
|
|
60
|
+
result: "error";
|
|
61
|
+
error: string;
|
|
62
|
+
};
|
|
63
|
+
/** A short-lived recorded process, structurally matching ProcessState. */
|
|
64
|
+
export interface RecordedProcess {
|
|
65
|
+
pid: number;
|
|
66
|
+
pgid: number;
|
|
67
|
+
startedAt: string;
|
|
68
|
+
command: string;
|
|
69
|
+
}
|
|
70
|
+
/** Default real implementation backed by `process.kill` and `ps`. */
|
|
71
|
+
export declare const realSignalFns: SignalFns;
|
|
72
|
+
/**
|
|
73
|
+
* Verify a recorded process is still the one we started.
|
|
74
|
+
*
|
|
75
|
+
* Returns:
|
|
76
|
+
* - "alive-verified": process exists AND start time is consistent → safe to signal
|
|
77
|
+
* - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
|
|
78
|
+
* - "dead": no such process (ESRCH) → already stopped, skip
|
|
79
|
+
* - "mismatch": process exists but started too late → recycled pid, DO NOT signal
|
|
80
|
+
*/
|
|
81
|
+
export declare function checkIdentity(rec: RecordedProcess, fns: SignalFns): Promise<"alive-verified" | "alive-unverified" | "dead" | "mismatch">;
|
|
82
|
+
/**
|
|
83
|
+
* Stop one recorded process: graceful SIGTERM to the group, then escalate to
|
|
84
|
+
* SIGKILL after `graceMs` if it's still alive. Always targets the process GROUP
|
|
85
|
+
* (negative pgid) so child processes the app spawned die too.
|
|
86
|
+
*/
|
|
87
|
+
export declare function stopProcess(component: string, rec: RecordedProcess, fns: SignalFns, opts?: {
|
|
88
|
+
graceMs?: number;
|
|
89
|
+
sleep?: (ms: number) => Promise<void>;
|
|
90
|
+
}): Promise<StopOutcome>;
|
|
91
|
+
/**
|
|
92
|
+
* Stop every recorded process. Pure orchestration over `stopProcess`; returns
|
|
93
|
+
* one outcome per component so the caller can report and tests can assert.
|
|
94
|
+
*/
|
|
95
|
+
export declare function stopRecordedProcesses(processes: Record<string, RecordedProcess>, fns?: SignalFns, opts?: {
|
|
96
|
+
graceMs?: number;
|
|
97
|
+
sleep?: (ms: number) => Promise<void>;
|
|
98
|
+
}): Promise<StopOutcome[]>;
|
|
99
|
+
//# sourceMappingURL=process-stopper.d.ts.map
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-session app-process termination for `launch down`.
|
|
3
|
+
*
|
|
4
|
+
* The foreground `launch up` session kills its children directly via the
|
|
5
|
+
* ProcessManager on Ctrl+C. But `launch down` may run from a *different* shell
|
|
6
|
+
* (or after the up-session has exited), so it has no live ChildProcess handles —
|
|
7
|
+
* only the pids recorded in `.launchfile/state.json`. This module signals those
|
|
8
|
+
* recorded process groups, with liveness + identity checks to avoid killing an
|
|
9
|
+
* unrelated process that happens to have inherited a recycled pid.
|
|
10
|
+
*
|
|
11
|
+
* ## pid-reuse identity guarantee (read this before trusting the kill)
|
|
12
|
+
*
|
|
13
|
+
* macOS recycles pids. A pid we recorded at `up` time may, by `down` time,
|
|
14
|
+
* belong to a completely unrelated process. Before sending any signal we:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Liveness** — `process.kill(pid, 0)` throws ESRCH if no such process
|
|
17
|
+
* exists. If it's dead, we skip (already stopped).
|
|
18
|
+
* 2. **Identity** — we compare the recorded spawn time against the live
|
|
19
|
+
* process's actual start time via `ps -o lstart= -p <pid>`. If the live
|
|
20
|
+
* process started meaningfully later than we recorded, the pid was
|
|
21
|
+
* recycled and we REFUSE to signal it.
|
|
22
|
+
*
|
|
23
|
+
* This is a best-effort guarantee, not a cryptographic one. Its honest limits:
|
|
24
|
+
* - `ps lstart` has ~1s resolution, so we allow a small tolerance window. A
|
|
25
|
+
* recycled process that started within that window of the original spawn
|
|
26
|
+
* could theoretically slip through — vanishingly unlikely in practice.
|
|
27
|
+
* - If `ps` is unavailable/fails, we fall back to liveness-only and signal
|
|
28
|
+
* conservatively ONLY the process group (never a bare pid), accepting the
|
|
29
|
+
* small residual risk rather than orphaning the user's processes forever.
|
|
30
|
+
* - We signal the process GROUP (negative pgid) to also reap children the app
|
|
31
|
+
* spawned, matching the foreground SIGINT behavior. A recycled *group*
|
|
32
|
+
* leader is the residual risk the start-time check exists to close.
|
|
33
|
+
*/
|
|
34
|
+
import { execFile } from "node:child_process";
|
|
35
|
+
/**
|
|
36
|
+
* Identity tolerance: how much later than `startedAt` a live process may report
|
|
37
|
+
* having started before we treat it as a recycled pid. `ps lstart` rounds to
|
|
38
|
+
* whole seconds and there's scheduling slop between our `Date.now()` snapshot
|
|
39
|
+
* and the kernel's recorded start, so we allow a few seconds of forward drift.
|
|
40
|
+
*/
|
|
41
|
+
const START_TIME_TOLERANCE_MS = 3000;
|
|
42
|
+
/** Default real implementation backed by `process.kill` and `ps`. */
|
|
43
|
+
export const realSignalFns = {
|
|
44
|
+
kill: (pid, signal) => {
|
|
45
|
+
process.kill(pid, signal);
|
|
46
|
+
},
|
|
47
|
+
startTime: (pid) => queryStartTime(pid),
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Query a process's start time via `ps -o lstart= -p <pid>` using array args
|
|
51
|
+
* (no shell, no interpolation). Returns epoch ms, or null on any failure.
|
|
52
|
+
*/
|
|
53
|
+
function queryStartTime(pid) {
|
|
54
|
+
return new Promise((resolve) => {
|
|
55
|
+
execFile("ps", ["-o", "lstart=", "-p", String(pid)], (error, stdout) => {
|
|
56
|
+
if (error) {
|
|
57
|
+
resolve(null);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const text = stdout.trim();
|
|
61
|
+
if (!text) {
|
|
62
|
+
resolve(null);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const parsed = Date.parse(text);
|
|
66
|
+
resolve(Number.isNaN(parsed) ? null : parsed);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Verify a recorded process is still the one we started.
|
|
72
|
+
*
|
|
73
|
+
* Returns:
|
|
74
|
+
* - "alive-verified": process exists AND start time is consistent → safe to signal
|
|
75
|
+
* - "alive-unverified": process exists but start time couldn't be read → signal group only, cautiously
|
|
76
|
+
* - "dead": no such process (ESRCH) → already stopped, skip
|
|
77
|
+
* - "mismatch": process exists but started too late → recycled pid, DO NOT signal
|
|
78
|
+
*/
|
|
79
|
+
export async function checkIdentity(rec, fns) {
|
|
80
|
+
// Liveness probe.
|
|
81
|
+
try {
|
|
82
|
+
fns.kill(rec.pid, 0);
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
if (isErrno(err) && err.code === "ESRCH")
|
|
86
|
+
return "dead";
|
|
87
|
+
// EPERM means it exists but we can't signal it — treat as alive-unverified.
|
|
88
|
+
if (isErrno(err) && err.code === "EPERM")
|
|
89
|
+
return "alive-unverified";
|
|
90
|
+
return "dead";
|
|
91
|
+
}
|
|
92
|
+
const liveStart = await fns.startTime(rec.pid);
|
|
93
|
+
if (liveStart === null)
|
|
94
|
+
return "alive-unverified";
|
|
95
|
+
const recordedStart = Date.parse(rec.startedAt);
|
|
96
|
+
if (Number.isNaN(recordedStart))
|
|
97
|
+
return "alive-unverified";
|
|
98
|
+
// If the live process started meaningfully *after* we recorded the spawn,
|
|
99
|
+
// the original exited and the pid was recycled. Refuse to signal it.
|
|
100
|
+
if (liveStart > recordedStart + START_TIME_TOLERANCE_MS)
|
|
101
|
+
return "mismatch";
|
|
102
|
+
return "alive-verified";
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Stop one recorded process: graceful SIGTERM to the group, then escalate to
|
|
106
|
+
* SIGKILL after `graceMs` if it's still alive. Always targets the process GROUP
|
|
107
|
+
* (negative pgid) so child processes the app spawned die too.
|
|
108
|
+
*/
|
|
109
|
+
export async function stopProcess(component, rec, fns, opts = {}) {
|
|
110
|
+
const graceMs = opts.graceMs ?? 5000;
|
|
111
|
+
const sleep = opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
|
|
112
|
+
const identity = await checkIdentity(rec, fns);
|
|
113
|
+
if (identity === "dead")
|
|
114
|
+
return { component, result: "already-dead" };
|
|
115
|
+
if (identity === "mismatch")
|
|
116
|
+
return { component, result: "identity-mismatch" };
|
|
117
|
+
// Target the process group. pgid is the leader pid because we spawned detached.
|
|
118
|
+
const groupTarget = -rec.pgid;
|
|
119
|
+
try {
|
|
120
|
+
fns.kill(groupTarget, "SIGTERM");
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
if (isErrno(err) && err.code === "ESRCH")
|
|
124
|
+
return { component, result: "already-dead" };
|
|
125
|
+
return { component, result: "error", error: errMessage(err) };
|
|
126
|
+
}
|
|
127
|
+
// Wait for graceful exit, then escalate if the leader is still alive.
|
|
128
|
+
await sleep(graceMs);
|
|
129
|
+
let stillAlive = true;
|
|
130
|
+
try {
|
|
131
|
+
fns.kill(rec.pid, 0);
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
stillAlive = false;
|
|
135
|
+
}
|
|
136
|
+
if (stillAlive) {
|
|
137
|
+
try {
|
|
138
|
+
fns.kill(groupTarget, "SIGKILL");
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
if (!(isErrno(err) && err.code === "ESRCH")) {
|
|
142
|
+
return { component, result: "error", error: errMessage(err) };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { component, result: "stopped" };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Stop every recorded process. Pure orchestration over `stopProcess`; returns
|
|
150
|
+
* one outcome per component so the caller can report and tests can assert.
|
|
151
|
+
*/
|
|
152
|
+
export async function stopRecordedProcesses(processes, fns = realSignalFns, opts = {}) {
|
|
153
|
+
const outcomes = [];
|
|
154
|
+
for (const [component, rec] of Object.entries(processes)) {
|
|
155
|
+
outcomes.push(await stopProcess(component, rec, fns, opts));
|
|
156
|
+
}
|
|
157
|
+
return outcomes;
|
|
158
|
+
}
|
|
159
|
+
function isErrno(err) {
|
|
160
|
+
return typeof err === "object" && err !== null && "code" in err;
|
|
161
|
+
}
|
|
162
|
+
function errMessage(err) {
|
|
163
|
+
return err instanceof Error ? err.message : String(err);
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=process-stopper.js.map
|
package/dist/provider.d.ts
CHANGED
|
@@ -4,12 +4,30 @@
|
|
|
4
4
|
* Reads a Launchfile, provisions resources, resolves env vars,
|
|
5
5
|
* installs runtimes, and starts all components.
|
|
6
6
|
*/
|
|
7
|
+
import { type NormalizedComponent } from "@launchfile/sdk";
|
|
8
|
+
/**
|
|
9
|
+
* Source-mode run resolution (D-38, precedence `dev` > `image` > `start`).
|
|
10
|
+
* This provider runs apps from source. A component is source-runnable when it
|
|
11
|
+
* declares `dev`, or a `start` with no `image` — an `image` without a `dev`
|
|
12
|
+
* override stays artifact-mode, which this source-only provider can't launch.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isSourceRunnable(component: NormalizedComponent): boolean;
|
|
15
|
+
/** The command run from source, or undefined if the component resolves to its artifact. */
|
|
16
|
+
export declare function sourceRunCommand(component: NormalizedComponent): string | undefined;
|
|
7
17
|
export interface LaunchUpOpts {
|
|
8
18
|
withOptional?: boolean;
|
|
9
19
|
noBuild?: boolean;
|
|
10
20
|
detach?: boolean;
|
|
11
21
|
dryRun?: boolean;
|
|
12
22
|
projectDir?: string;
|
|
23
|
+
/**
|
|
24
|
+
* Component selector (#77): if non-empty, these components plus their
|
|
25
|
+
* transitive downward `depends_on` closure are started (D-41), and the
|
|
26
|
+
* `requires` of every closure member are provisioned. The start-set is the
|
|
27
|
+
* SDK's `selectionClosure` — the same definition the Docker provider uses —
|
|
28
|
+
* so both yield the identical running topology (P-5). Empty = all components.
|
|
29
|
+
*/
|
|
30
|
+
components?: string[];
|
|
13
31
|
}
|
|
14
32
|
export declare function launchUp(opts?: LaunchUpOpts): Promise<void>;
|
|
15
33
|
export declare function launchDown(opts?: {
|