@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ccmsg/cli",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "description": "The ccmsg daemon, CLI and agent plugins for one instance (= one config home)",
5
5
  "license": "MIT",
6
6
  "author": "kawaz",
@@ -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
- try {
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
- /** Stop every child, stop restarting them, and give up the socket.
398
- *
399
- * The socket goes first, so a command arriving mid-shutdown is told there is
400
- * no supervisor rather than being answered by one that is leaving. */
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
- try {
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
  }
@@ -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. The pid and the lock go
945
- // before it, because a client reads a refusing socket as this instance
946
- // having finished leaving and a successor may claim what it sees free.
947
- remove(this.paths.pidFile);
948
- this.lock.release();
949
- // Closing takes the path this process bound, and only that one: the stable
950
- // address is a symlink nothing here touches, because a successor may have
951
- // already pointed it at itself (§8.5).
952
- await this.#transport.close();
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
 
@@ -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
- constructor(env: Env, label: string = LAUNCHD_LABEL) {
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
- await run(["launchctl", "kill", "SIGTERM", `${this.#domain}/${this.#label}`]);
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 ordered = [
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
- for (const listener of ordered) await listener.close();
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
  }
@@ -81,8 +81,11 @@ export function listenUds(options: UdsOptions): Listener {
81
81
  return {
82
82
  kind: "uds",
83
83
  address: options.path,
84
- async close() {
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
  }
@@ -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 { Listener } from "./listener.ts";
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]);