@ccmsg/cli 0.3.2 → 0.3.3
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/package.json +1 -1
- package/src/daemon/supervise.ts +63 -18
- package/src/instance/instance.ts +19 -9
- package/src/service/service.ts +91 -4
- package/src/transport/listener.ts +10 -5
- package/src/transport/uds.ts +4 -1
- package/src/transport/ws.ts +1 -5
package/package.json
CHANGED
package/src/daemon/supervise.ts
CHANGED
|
@@ -45,6 +45,9 @@ export interface Backoff {
|
|
|
45
45
|
|
|
46
46
|
export const BACKOFF: Backoff = { minMs: 500, maxMs: 30_000, steadyMs: 60_000 };
|
|
47
47
|
|
|
48
|
+
/** The time each cooperative stop stage gets before the supervisor escalates. */
|
|
49
|
+
export const STOP_TIMEOUT_MS = 10_000;
|
|
50
|
+
|
|
48
51
|
export interface SuperviseOptions {
|
|
49
52
|
readonly env?: Env;
|
|
50
53
|
readonly spawn?: SpawnInstance;
|
|
@@ -54,6 +57,8 @@ export interface SuperviseOptions {
|
|
|
54
57
|
/** How long a start waits for the child to be serving before it is reported
|
|
55
58
|
* as having failed. */
|
|
56
59
|
readonly startTimeoutMs?: number;
|
|
60
|
+
/** How long each graceful and SIGTERM stop stage may hold shutdown. */
|
|
61
|
+
readonly stopTimeoutMs?: number;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/** One config home the supervisor looks after, and how it is doing.
|
|
@@ -98,6 +103,7 @@ export class Supervisor {
|
|
|
98
103
|
readonly #backoff: Backoff;
|
|
99
104
|
readonly #log: (line: Record<string, unknown>) => void;
|
|
100
105
|
readonly #startTimeoutMs: number;
|
|
106
|
+
readonly #stopTimeoutMs: number;
|
|
101
107
|
readonly #units = new Map<string, Supervised>();
|
|
102
108
|
readonly #waits = new Set<() => void>();
|
|
103
109
|
#listener: ReturnType<typeof Bun.listen> | undefined;
|
|
@@ -110,6 +116,7 @@ export class Supervisor {
|
|
|
110
116
|
this.#spawn = options.spawn ?? spawnInstance;
|
|
111
117
|
this.#backoff = options.backoff ?? BACKOFF;
|
|
112
118
|
this.#startTimeoutMs = options.startTimeoutMs ?? START_TIMEOUT_MS;
|
|
119
|
+
this.#stopTimeoutMs = options.stopTimeoutMs ?? STOP_TIMEOUT_MS;
|
|
113
120
|
this.#log = options.log ?? ((line) => process.stderr.write(`${JSON.stringify(line)}\n`));
|
|
114
121
|
for (const target of registered(this.#env)) this.#units.set(target.dir, new Supervised(target));
|
|
115
122
|
}
|
|
@@ -278,12 +285,7 @@ export class Supervisor {
|
|
|
278
285
|
// asked for rather than one to recover from.
|
|
279
286
|
unit.wanted = false;
|
|
280
287
|
for (const cancel of new Set(this.#waits)) cancel();
|
|
281
|
-
|
|
282
|
-
await askToStop(unit.target);
|
|
283
|
-
} catch {
|
|
284
|
-
child.kill("SIGTERM");
|
|
285
|
-
}
|
|
286
|
-
await child.exited;
|
|
288
|
+
await this.#stopChild(unit, child);
|
|
287
289
|
await unit.loop;
|
|
288
290
|
return { dir, stopped: true };
|
|
289
291
|
}
|
|
@@ -394,27 +396,70 @@ export class Supervisor {
|
|
|
394
396
|
});
|
|
395
397
|
}
|
|
396
398
|
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
399
|
+
async #stopChild(unit: Supervised, child: Child): Promise<void> {
|
|
400
|
+
const within = async (work: Promise<unknown>): Promise<boolean> => {
|
|
401
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
402
|
+
const deadline = new Promise<false>((resolve) => {
|
|
403
|
+
timer = setTimeout(() => resolve(false), this.#stopTimeoutMs);
|
|
404
|
+
});
|
|
405
|
+
try {
|
|
406
|
+
return await Promise.race([
|
|
407
|
+
work.then(
|
|
408
|
+
() => true,
|
|
409
|
+
() => false,
|
|
410
|
+
),
|
|
411
|
+
deadline,
|
|
412
|
+
]);
|
|
413
|
+
} finally {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
// Each stage is said out loud with how long the one before it took. A child
|
|
419
|
+
// that will not leave is a thing that happens on a machine nobody is
|
|
420
|
+
// watching, and what stage it was at is the whole of what can be known
|
|
421
|
+
// about it afterwards.
|
|
422
|
+
const startedAt = Date.now();
|
|
423
|
+
const say = (stage: string) => {
|
|
424
|
+
this.#log({
|
|
425
|
+
event: "stopping",
|
|
426
|
+
dir: unit.target.dir,
|
|
427
|
+
pid: child.pid,
|
|
428
|
+
stage,
|
|
429
|
+
in_ms: Date.now() - startedAt,
|
|
430
|
+
});
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
say("asked");
|
|
434
|
+
if (await within(askToStop(unit.target).then(() => child.exited))) {
|
|
435
|
+
say("exited");
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
say("sigterm");
|
|
439
|
+
child.kill("SIGTERM");
|
|
440
|
+
if (await within(child.exited)) {
|
|
441
|
+
say("exited");
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
say("sigkill");
|
|
445
|
+
child.kill("SIGKILL");
|
|
446
|
+
await child.exited;
|
|
447
|
+
say("exited");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Stop every child, stop restarting them, and give up the socket. */
|
|
401
451
|
async stop(): Promise<void> {
|
|
402
452
|
this.#leaving = true;
|
|
403
|
-
this.#listener?.stop(true);
|
|
404
|
-
this.#listener = undefined;
|
|
405
453
|
for (const cancel of new Set(this.#waits)) cancel();
|
|
406
454
|
await Promise.all(
|
|
407
455
|
[...this.#units.values()].map(async (unit) => {
|
|
408
456
|
const child = unit.child;
|
|
409
457
|
if (child === undefined) return;
|
|
410
|
-
|
|
411
|
-
await askToStop(unit.target);
|
|
412
|
-
} catch {
|
|
413
|
-
child.kill("SIGTERM");
|
|
414
|
-
}
|
|
415
|
-
await child.exited;
|
|
458
|
+
await this.#stopChild(unit, child);
|
|
416
459
|
}),
|
|
417
460
|
);
|
|
461
|
+
this.#listener?.stop(true);
|
|
462
|
+
this.#listener = undefined;
|
|
418
463
|
this.#left?.();
|
|
419
464
|
await this.#ran;
|
|
420
465
|
}
|
package/src/instance/instance.ts
CHANGED
|
@@ -941,15 +941,25 @@ export class Instance {
|
|
|
941
941
|
// they change rather than at exit, so there is nothing held back to flush;
|
|
942
942
|
// the log's writer is synchronous for the same reason (§3.6).
|
|
943
943
|
this.log.write("stopping", { instance: this.self });
|
|
944
|
-
// 5. let the resources go, the unix socket last.
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
944
|
+
// 5. let the resources go, the unix socket last. Closing takes the path
|
|
945
|
+
// this process bound, and only that one: the stable address is a symlink
|
|
946
|
+
// nothing here touches, because a successor may have already pointed it at
|
|
947
|
+
// itself (§8.5).
|
|
948
|
+
try {
|
|
949
|
+
await this.#transport.close();
|
|
950
|
+
} catch (cause) {
|
|
951
|
+
// A listener that could not be closed is worth saying, and is not worth
|
|
952
|
+
// holding the pid and the lock over: this process is leaving either way,
|
|
953
|
+
// and keeping them would leave a successor unable to start against a
|
|
954
|
+
// config home nothing is serving.
|
|
955
|
+
this.log.write("listener close failed", { instance: this.self, cause: String(cause) });
|
|
956
|
+
} finally {
|
|
957
|
+
// The pid and lock are the observable proof that this process is still
|
|
958
|
+
// leaving. Released only after every listener has finished closing, so a
|
|
959
|
+
// client cannot mistake an unreachable socket for a completed stop.
|
|
960
|
+
remove(this.paths.pidFile);
|
|
961
|
+
this.lock.release();
|
|
962
|
+
}
|
|
953
963
|
}
|
|
954
964
|
}
|
|
955
965
|
|
package/src/service/service.ts
CHANGED
|
@@ -171,20 +171,62 @@ function refuse(command: readonly string[], answer: RunResult): never {
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
+
/** How long the supervisor gets to leave after being asked to, before the init
|
|
175
|
+
* system is told to kill it.
|
|
176
|
+
*
|
|
177
|
+
* The same budget the supervisor gives each of its own children, because that
|
|
178
|
+
* is what it spends: a supervisor that is going to leave has finished leaving
|
|
179
|
+
* by the time its last child has. */
|
|
180
|
+
export const STOP_DEADLINE_MS = 10_000;
|
|
181
|
+
|
|
182
|
+
/** How often the init system is asked again while waiting for a departure. */
|
|
183
|
+
const DEPARTURE_POLL_MS = 100;
|
|
184
|
+
|
|
185
|
+
/** Whether `gone` holds, waited for up to the deadline.
|
|
186
|
+
*
|
|
187
|
+
* Asked again rather than awaited: the supervisor belongs to the init system
|
|
188
|
+
* and not to this process, so there is no exit to wait on and the only account
|
|
189
|
+
* of whether it is still there is one that has to be fetched. */
|
|
190
|
+
async function departed(gone: () => Promise<boolean>, deadlineMs: number): Promise<boolean> {
|
|
191
|
+
const end = Date.now() + deadlineMs;
|
|
192
|
+
for (;;) {
|
|
193
|
+
if (await gone()) return true;
|
|
194
|
+
const left = end - Date.now();
|
|
195
|
+
if (left <= 0) return false;
|
|
196
|
+
await Bun.sleep(Math.min(DEPARTURE_POLL_MS, left));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Whether the process that held `pid` is still there, asked of the kernel
|
|
201
|
+
* rather than of the init system.
|
|
202
|
+
*
|
|
203
|
+
* `kill -0` and not a report, because a unit that has been booted out is one
|
|
204
|
+
* launchd no longer says anything about — including whether the process it was
|
|
205
|
+
* running has actually gone. It goes through `Run` for the reason everything
|
|
206
|
+
* else here does: a test drives it without a process on this machine being
|
|
207
|
+
* signalled. */
|
|
208
|
+
async function alive(pid: number, run: Run): Promise<boolean> {
|
|
209
|
+
return (await run(["kill", "-0", String(pid)])).code === 0;
|
|
210
|
+
}
|
|
211
|
+
|
|
174
212
|
export class LaunchdService implements Service {
|
|
175
213
|
readonly kind = "launchd" as const;
|
|
176
214
|
readonly unitFile: string;
|
|
177
215
|
readonly #label: string;
|
|
178
216
|
readonly #env: Env;
|
|
179
217
|
readonly #domain: string;
|
|
218
|
+
readonly #stopDeadlineMs: number;
|
|
180
219
|
|
|
181
220
|
/** The label is a parameter for `Run`'s reason: a test that drives this
|
|
182
221
|
* machine's real launchd has to do it under a name that is not the one the
|
|
183
222
|
* machine's own supervisor is registered under. Nothing in ccmsg passes it —
|
|
184
|
-
* `serviceFor` is where the name is settled.
|
|
185
|
-
|
|
223
|
+
* `serviceFor` is where the name is settled. The deadline is a parameter for
|
|
224
|
+
* the same reason: a test drives the escalation without waiting out ten
|
|
225
|
+
* seconds of it. */
|
|
226
|
+
constructor(env: Env, label: string = LAUNCHD_LABEL, stopDeadlineMs = STOP_DEADLINE_MS) {
|
|
186
227
|
this.#env = env;
|
|
187
228
|
this.#label = label;
|
|
229
|
+
this.#stopDeadlineMs = stopDeadlineMs;
|
|
188
230
|
const home = env["HOME"] ?? homedir();
|
|
189
231
|
this.unitFile = join(home, "Library", "LaunchAgents", `${label}.plist`);
|
|
190
232
|
this.#domain = `gui/${String(process.getuid?.() ?? 0)}`;
|
|
@@ -261,8 +303,33 @@ export class LaunchdService implements Service {
|
|
|
261
303
|
return await this.state(run);
|
|
262
304
|
}
|
|
263
305
|
|
|
306
|
+
/** Take the unit out of launchd, and stay until the supervisor has gone.
|
|
307
|
+
*
|
|
308
|
+
* `bootout` rather than a signal, because `KeepAlive` means a supervisor that
|
|
309
|
+
* is signalled is one launchd starts again: what the person asked for is a
|
|
310
|
+
* supervisor that stays stopped, which is systemd's `stop` and launchd's
|
|
311
|
+
* `bootout`. The file stays where it is — a unit launchd is not holding is
|
|
312
|
+
* what `start` already knows how to bootstrap, and taking the file away as
|
|
313
|
+
* well is `unregister`.
|
|
314
|
+
*
|
|
315
|
+
* The answer waits for the pid rather than being read off the command,
|
|
316
|
+
* because the moment the command returns is the moment the supervisor starts
|
|
317
|
+
* leaving and not the one it finishes at: a supervisor wedged in its own
|
|
318
|
+
* shutdown would otherwise be reported as gone by a `service stop` that had
|
|
319
|
+
* only asked. One that will not go inside the deadline is killed. */
|
|
264
320
|
async stop(run: Run): Promise<ServiceState> {
|
|
265
|
-
|
|
321
|
+
const before = await this.#report(run);
|
|
322
|
+
// A refusal is launchd saying it is not holding this label, which is the
|
|
323
|
+
// state being asked for; `state` below is what reports otherwise.
|
|
324
|
+
await run(["launchctl", "bootout", `${this.#domain}/${this.#label}`]);
|
|
325
|
+
const held = before.service?.pid ?? null;
|
|
326
|
+
if (held !== null) {
|
|
327
|
+
const gone = async () => !(await alive(held, run));
|
|
328
|
+
if (!(await departed(gone, this.#stopDeadlineMs))) {
|
|
329
|
+
await run(["kill", "-KILL", String(held)]);
|
|
330
|
+
await departed(gone, this.#stopDeadlineMs);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
266
333
|
return await this.state(run);
|
|
267
334
|
}
|
|
268
335
|
|
|
@@ -306,9 +373,11 @@ class SystemdService implements Service {
|
|
|
306
373
|
readonly kind = "systemd" as const;
|
|
307
374
|
readonly unitFile: string;
|
|
308
375
|
readonly #env: Env;
|
|
376
|
+
readonly #stopDeadlineMs: number;
|
|
309
377
|
|
|
310
|
-
constructor(env: Env) {
|
|
378
|
+
constructor(env: Env, stopDeadlineMs = STOP_DEADLINE_MS) {
|
|
311
379
|
this.#env = env;
|
|
380
|
+
this.#stopDeadlineMs = stopDeadlineMs;
|
|
312
381
|
const home = env["HOME"] ?? homedir();
|
|
313
382
|
const config = env["XDG_CONFIG_HOME"] ?? join(home, ".config");
|
|
314
383
|
this.unitFile = join(config, "systemd", "user", SYSTEMD_UNIT);
|
|
@@ -374,8 +443,26 @@ class SystemdService implements Service {
|
|
|
374
443
|
return await this.state(run);
|
|
375
444
|
}
|
|
376
445
|
|
|
446
|
+
/** `LaunchdService.stop`'s reasoning, in systemd's vocabulary.
|
|
447
|
+
*
|
|
448
|
+
* `stop` here holds until the unit is inactive on its own, so the wait below
|
|
449
|
+
* usually settles on its first question; it is asked anyway because what the
|
|
450
|
+
* caller is promised is the departure, not the command having returned, and
|
|
451
|
+
* a `stop` that gave up on its own `TimeoutStopSec` returns having left the
|
|
452
|
+
* process behind. */
|
|
377
453
|
async stop(run: Run): Promise<ServiceState> {
|
|
454
|
+
const before = await this.#report(run);
|
|
378
455
|
await run(["systemctl", "--user", "stop", SYSTEMD_UNIT]);
|
|
456
|
+
const held = before.service?.pid ?? null;
|
|
457
|
+
if (held !== null) {
|
|
458
|
+
// The unit is still loaded here, so systemd is still the one that knows
|
|
459
|
+
// what became of the process it started.
|
|
460
|
+
const gone = async () => (await this.#report(run)).service?.pid !== held;
|
|
461
|
+
if (!(await departed(gone, this.#stopDeadlineMs))) {
|
|
462
|
+
await run(["systemctl", "--user", "kill", "--signal=SIGKILL", SYSTEMD_UNIT]);
|
|
463
|
+
await departed(gone, this.#stopDeadlineMs);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
379
466
|
return await this.state(run);
|
|
380
467
|
}
|
|
381
468
|
|
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* everything else, because a client reads "the unix socket refuses" as the
|
|
5
5
|
* instance having finished leaving, and a successor may take the resources it
|
|
6
6
|
* sees freed before that. */
|
|
7
|
+
/** A Bun listener gets this long to settle after releasing its address. */
|
|
8
|
+
export const STOP_DEADLINE_MS = 250;
|
|
9
|
+
|
|
7
10
|
export interface Listener {
|
|
8
11
|
readonly kind: "uds" | "ws";
|
|
9
12
|
/** The socket path, or the bound `host:port` — resolved, so an ephemeral
|
|
@@ -29,11 +32,13 @@ export class Transport {
|
|
|
29
32
|
* come before this one (§8.5 1-4: refuse new work, stop upstream watches,
|
|
30
33
|
* tell the connections, settle what is persisted). */
|
|
31
34
|
async close(): Promise<void> {
|
|
32
|
-
const
|
|
33
|
-
...this.#listeners.filter((l) => l.kind !== "uds"),
|
|
34
|
-
...this.#listeners.filter((l) => l.kind === "uds"),
|
|
35
|
-
];
|
|
35
|
+
const held = [...this.#listeners];
|
|
36
36
|
this.#listeners.length = 0;
|
|
37
|
-
|
|
37
|
+
// The order that matters is the unix socket coming last; among the rest
|
|
38
|
+
// there is none, and closing them one after another would add up their
|
|
39
|
+
// deadlines for no reason — two served listeners is a second of waiting
|
|
40
|
+
// that nothing is waiting for.
|
|
41
|
+
await Promise.all(held.filter((l) => l.kind !== "uds").map((l) => l.close()));
|
|
42
|
+
for (const listener of held.filter((l) => l.kind === "uds")) await listener.close();
|
|
38
43
|
}
|
|
39
44
|
}
|
package/src/transport/uds.ts
CHANGED
|
@@ -81,8 +81,11 @@ export function listenUds(options: UdsOptions): Listener {
|
|
|
81
81
|
return {
|
|
82
82
|
kind: "uds",
|
|
83
83
|
address: options.path,
|
|
84
|
-
|
|
84
|
+
close() {
|
|
85
|
+
// Nothing to wait on: a unix listener gives its address up inside the
|
|
86
|
+
// call, unlike the served WebSocket next door (Bun 1.3.13).
|
|
85
87
|
server.stop(true);
|
|
88
|
+
return Promise.resolve();
|
|
86
89
|
},
|
|
87
90
|
};
|
|
88
91
|
}
|
package/src/transport/ws.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { BaseConn, type Conn, type ConnRegistry } from "./conn.ts";
|
|
|
3
3
|
import { createDriver, type FrameHandler } from "./driver.ts";
|
|
4
4
|
import { LineReader, WriteQueue } from "./framing.ts";
|
|
5
5
|
import { type AuthorizedUpgrade, type EntryPolicy, OPEN } from "./entry.ts";
|
|
6
|
-
import type
|
|
6
|
+
import { type Listener, STOP_DEADLINE_MS } from "./listener.ts";
|
|
7
7
|
|
|
8
8
|
/** What the upgrade hands the socket: whether it was let in as a peer. */
|
|
9
9
|
interface UpgradeData {
|
|
@@ -186,8 +186,4 @@ export function entryPath(pathname: string, path: string): boolean {
|
|
|
186
186
|
return pathname === path || pathname.endsWith(`/${path.replace(/^\//, "")}`);
|
|
187
187
|
}
|
|
188
188
|
|
|
189
|
-
/** How long the stop above waits before trusting the address over the promise.
|
|
190
|
-
* Two orders of magnitude above the millisecond the release was measured at. */
|
|
191
|
-
const STOP_DEADLINE_MS = 250;
|
|
192
|
-
|
|
193
189
|
const NEWLINE = new Uint8Array([0x0a]);
|