@ccmsg/cli 0.3.2 → 0.3.4

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.4",
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
  }
@@ -522,18 +522,14 @@ export class Instance {
522
522
  ...(this.#mesh === undefined ? {} : { cluster: this.#mesh }),
523
523
  inbox,
524
524
  direct: this.#direct,
525
- publish: (topic, data, instance, to) => {
526
- this.#topics.publish(topic, data, instance, to);
527
- },
525
+ publish: (topic, data, instance, to) => this.#topics.publish(topic, data, instance, to),
528
526
  listeners: (topic, to) => this.#topics.subscriberCount(topic, to),
529
527
  });
530
528
 
531
529
  this.#notify = new Notify({
532
530
  self: this.self,
533
531
  label: (sid) => sessionLabel(this.#sessions, sid),
534
- publish: (topic, data, instance) => {
535
- this.#topics.publish(topic, data, instance);
536
- },
532
+ publish: (topic, data, instance) => this.#topics.publish(topic, data, instance),
537
533
  });
538
534
 
539
535
  this.#topics.attach("peers", this.#sessions);
@@ -941,15 +937,25 @@ export class Instance {
941
937
  // they change rather than at exit, so there is nothing held back to flush;
942
938
  // the log's writer is synchronous for the same reason (§3.6).
943
939
  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();
940
+ // 5. let the resources go, the unix socket last. Closing takes the path
941
+ // this process bound, and only that one: the stable address is a symlink
942
+ // nothing here touches, because a successor may have already pointed it at
943
+ // itself (§8.5).
944
+ try {
945
+ await this.#transport.close();
946
+ } catch (cause) {
947
+ // A listener that could not be closed is worth saying, and is not worth
948
+ // holding the pid and the lock over: this process is leaving either way,
949
+ // and keeping them would leave a successor unable to start against a
950
+ // config home nothing is serving.
951
+ this.log.write("listener close failed", { instance: this.self, cause: String(cause) });
952
+ } finally {
953
+ // The pid and lock are the observable proof that this process is still
954
+ // leaving. Released only after every listener has finished closing, so a
955
+ // client cannot mistake an unreachable socket for a completed stop.
956
+ remove(this.paths.pidFile);
957
+ this.lock.release();
958
+ }
953
959
  }
954
960
  }
955
961
 
@@ -21,7 +21,7 @@ import {
21
21
  OpError,
22
22
  type Requester,
23
23
  } from "../dispatch/index.ts";
24
- import type { TopicValue, UpstreamResource } from "../topics/index.ts";
24
+ import type { PublishOutcome, TopicValue, UpstreamResource } from "../topics/index.ts";
25
25
  import type { DirectRoute } from "./direct.ts";
26
26
  import type { Inbox } from "./inbox.ts";
27
27
 
@@ -71,7 +71,7 @@ export interface DeliveryDeps {
71
71
  readonly direct: DirectRoute;
72
72
  /** The one way a value reaches subscribers (§6.1), narrowed to the session a
73
73
  * message is for. */
74
- readonly publish: (topic: string, data: unknown, instance: InstanceId, to: Sid) => void;
74
+ readonly publish: (topic: string, data: unknown, instance: InstanceId, to: Sid) => PublishOutcome;
75
75
  /** How many of that session's connections are listening on `inbox`. */
76
76
  readonly listeners: (topic: string, to: Sid) => number;
77
77
  }
@@ -128,8 +128,14 @@ export class Delivery implements UpstreamResource {
128
128
  }
129
129
 
130
130
  if (this.deps.listeners(INBOX, to) > 0) {
131
- this.deps.publish(INBOX, [message], this.deps.self, to);
132
- return { delivered: true };
131
+ if (this.deps.publish(INBOX, [message], this.deps.self, to) === "ok") {
132
+ return { delivered: true };
133
+ }
134
+ // The session is listening but is behind on what it has already been
135
+ // offered, which is the same standing as route (a) turning the message
136
+ // away: it waits in the inbox and is offered again (§4.4).
137
+ this.deps.inbox.hold(to, message);
138
+ return { delivered: false, reason: "throttled" };
133
139
  }
134
140
 
135
141
  const { evicted } = this.deps.inbox.hold(to, message);
@@ -11,7 +11,7 @@ import type {
11
11
  Timestamp,
12
12
  } from "@ccmsg/protocol";
13
13
  import { type HandlerInput, OpError } from "../dispatch/index.ts";
14
- import type { TopicValue, UpstreamResource } from "../topics/index.ts";
14
+ import type { PublishOutcome, TopicValue, UpstreamResource } from "../topics/index.ts";
15
15
 
16
16
  /** The one topic a notification reaches a watcher on. */
17
17
  const NOTIFY = "notify";
@@ -23,7 +23,7 @@ export interface NotifyDeps {
23
23
  readonly label: (sid: Sid) => string;
24
24
  /** The one way a value reaches subscribers (§6.1). No `to`: a notification is
25
25
  * for whoever is watching, not for one session. */
26
- readonly publish: (topic: string, data: unknown, instance: InstanceId) => void;
26
+ readonly publish: (topic: string, data: unknown, instance: InstanceId) => PublishOutcome;
27
27
  }
28
28
 
29
29
  /** The `notify` topic and the three ops that speak on it.
@@ -99,7 +99,16 @@ export class Notify implements UpstreamResource {
99
99
  text,
100
100
  sent_at: now,
101
101
  };
102
- this.deps.publish(NOTIFY, notification, this.deps.self);
102
+ // A notification is an occurrence, so nothing folds it away and a watcher
103
+ // that cannot keep up is what stops it. The caller hears that rather than
104
+ // the notification going nowhere: it is the one that decides whether to
105
+ // raise another (§6.4).
106
+ if (this.deps.publish(NOTIFY, notification, this.deps.self) === "rate_limited") {
107
+ throw new OpError(
108
+ "internal_error",
109
+ "a watcher is behind on this topic; the notification was not taken",
110
+ );
111
+ }
103
112
  return now;
104
113
  }
105
114
 
@@ -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
 
@@ -0,0 +1,162 @@
1
+ import type { Requester } from "../dispatch/index.ts";
2
+
3
+ /** How long one terminal's frames are gathered before they go out.
4
+ *
5
+ * The value bounds two things at once. Towards the reader: a person watching a
6
+ * list cannot see a change arrive sooner than the display draws it, so frames
7
+ * closer together than a few display frames are spent on nothing, while a wait
8
+ * long enough to be read as lag starts around a quarter of a second. Towards
9
+ * the cluster: a relayed frame waits once per hop, so the delay a subscriber
10
+ * sees is this value times the hops between it and the instance that produced
11
+ * the value — at 100 ms a two-hop cluster still answers inside the window a
12
+ * person reads as immediate, which a longer period would leave.
13
+ *
14
+ * It is not a poll. Nothing is looked at when the period elapses: the timer is
15
+ * armed only by a frame that has to wait, and an idle terminal has none. */
16
+ export const FLUSH_PERIOD_MS = 100;
17
+
18
+ /** How many frames that cannot be folded one terminal may hold at once.
19
+ *
20
+ * Folded frames need no bound — a topic that replaces its value keeps one entry
21
+ * however often it is stated — so this bounds the occurrences and the deltas,
22
+ * the frames that mean something twice if they arrive twice. At one flush every
23
+ * `FLUSH_PERIOD_MS` a terminal that keeps up drains this many every period, so
24
+ * reaching the limit means the producer has been outrunning the reader by more
25
+ * than 2500 frames a second for as long as the queue has stood: past anything a
26
+ * person, a session or a peer produces, and into the storm this layer exists
27
+ * for. What is over the limit is refused rather than dropped quietly, so the op
28
+ * that raised it is the one that hears about it. */
29
+ export const QUEUE_LIMIT = 256;
30
+
31
+ /** The two things the queue asks of time, so a test can hold both still.
32
+ *
33
+ * `schedule` answers with the way to cancel what it armed, because a terminal
34
+ * that goes away while a flush is pending has to leave nothing behind. */
35
+ export interface EgressClock {
36
+ now(): number;
37
+ schedule(afterMs: number, run: () => void): () => void;
38
+ }
39
+
40
+ const REAL_CLOCK: EgressClock = {
41
+ now: () => Date.now(),
42
+ schedule: (afterMs, run) => {
43
+ const timer = setTimeout(run, afterMs);
44
+ return () => {
45
+ clearTimeout(timer);
46
+ };
47
+ },
48
+ };
49
+
50
+ /** What a caller may move: the period, the limit, and the clock both are read
51
+ * against. */
52
+ export interface EgressOptions {
53
+ readonly periodMs?: number;
54
+ readonly limit?: number;
55
+ readonly clock?: EgressClock;
56
+ }
57
+
58
+ interface Pending {
59
+ /** The key this frame folds on, absent for one that does not fold. */
60
+ readonly fold?: string;
61
+ frame: object;
62
+ }
63
+
64
+ /** One terminal's outgoing frames, gathered and let go on a period.
65
+ *
66
+ * A terminal is a person's connection, a mesh peer or a CLI subscriber, and
67
+ * this is the same layer for all three: what differs between them is the socket
68
+ * underneath, not how fast a subscriber can be written to.
69
+ *
70
+ * Three things happen here, and the topic's own granularity decides which. A
71
+ * frame that replaces the value it carries folds onto the one already waiting
72
+ * under the same key, so a value stated a thousand times between two flushes
73
+ * leaves one frame and it is the latest — the reader is never handed a value
74
+ * that has already been superseded, and never misses the last one. A frame that
75
+ * is an occurrence or a delta cannot fold, so it queues in the order it was
76
+ * raised and the queue is bounded: past the bound the frame is refused, which is
77
+ * how the pressure reaches whoever is producing it instead of accumulating
78
+ * here. Both leave together on the flush, in the order they were queued.
79
+ *
80
+ * The first frame after a quiet spell goes out at once: the period bounds how
81
+ * often a flush happens, not how long a lone change waits. */
82
+ export class Egress {
83
+ #queue: Pending[] = [];
84
+ /** The waiting frame per fold key, so a restatement finds its own entry
85
+ * rather than being appended behind it. */
86
+ readonly #folded = new Map<string, Pending>();
87
+ /** How many waiting frames do not fold, which is what the limit counts. */
88
+ #kept = 0;
89
+ #lastFlush = Number.NEGATIVE_INFINITY;
90
+ #cancel: (() => void) | undefined;
91
+
92
+ readonly #periodMs: number;
93
+ readonly #limit: number;
94
+ readonly #clock: EgressClock;
95
+
96
+ constructor(
97
+ private readonly conn: Requester,
98
+ options: EgressOptions = {},
99
+ ) {
100
+ this.#periodMs = options.periodMs ?? FLUSH_PERIOD_MS;
101
+ this.#limit = options.limit ?? QUEUE_LIMIT;
102
+ this.#clock = options.clock ?? REAL_CLOCK;
103
+ }
104
+
105
+ /** Take one frame for this terminal. `fold` is the key it replaces itself
106
+ * under, absent for a frame that has to be sent as often as it is raised.
107
+ *
108
+ * `false` is the queue refusing the frame: it is full of frames that cannot
109
+ * be folded, and the caller is the one that can answer for it. */
110
+ push(frame: object, fold?: string): boolean {
111
+ if (fold === undefined) {
112
+ if (this.#kept >= this.#limit) return false;
113
+ this.#kept += 1;
114
+ this.#queue.push({ frame });
115
+ } else {
116
+ const held = this.#folded.get(fold);
117
+ if (held !== undefined) {
118
+ // In place: the value moves, its position among the occurrences around
119
+ // it does not.
120
+ held.frame = frame;
121
+ this.#arm();
122
+ return true;
123
+ }
124
+ const entry: Pending = { fold, frame };
125
+ this.#queue.push(entry);
126
+ this.#folded.set(fold, entry);
127
+ }
128
+ this.#arm();
129
+ return true;
130
+ }
131
+
132
+ /** Send everything waiting, in the order it was queued. */
133
+ flush(): void {
134
+ this.#cancel?.();
135
+ this.#cancel = undefined;
136
+ this.#lastFlush = this.#clock.now();
137
+ const queue = this.#queue;
138
+ this.#queue = [];
139
+ this.#folded.clear();
140
+ this.#kept = 0;
141
+ for (const entry of queue) this.conn.send(entry.frame);
142
+ }
143
+
144
+ /** The terminal is done with: what was queued goes out, and nothing armed
145
+ * outlives it. */
146
+ release(): void {
147
+ this.flush();
148
+ }
149
+
150
+ #arm(): void {
151
+ if (this.#cancel !== undefined) return;
152
+ const wait = this.#periodMs - (this.#clock.now() - this.#lastFlush);
153
+ if (wait <= 0) {
154
+ this.flush();
155
+ return;
156
+ }
157
+ this.#cancel = this.#clock.schedule(wait, () => {
158
+ this.#cancel = undefined;
159
+ this.flush();
160
+ });
161
+ }
162
+ }
@@ -1,2 +1,3 @@
1
+ export * from "./egress.ts";
1
2
  export * from "./handlers.ts";
2
3
  export * from "./topics.ts";
Binary file
@@ -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]);