@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 +1 -1
- package/src/daemon/supervise.ts +63 -18
- package/src/instance/instance.ts +21 -15
- package/src/messaging/delivery.ts +10 -4
- package/src/messaging/notify.ts +12 -3
- package/src/service/service.ts +91 -4
- package/src/topics/egress.ts +162 -0
- package/src/topics/index.ts +1 -0
- package/src/topics/topics.ts +0 -0
- 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
|
@@ -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.
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
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) =>
|
|
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
|
-
|
|
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);
|
package/src/messaging/notify.ts
CHANGED
|
@@ -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) =>
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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
|
+
}
|
package/src/topics/index.ts
CHANGED
package/src/topics/topics.ts
CHANGED
|
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
|
|
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]);
|