@ccmsg/cli 0.13.0 → 0.14.1
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/auth/admin.ts +5 -2
- package/src/auth/auth.ts +16 -16
- package/src/auth/records.ts +63 -41
- package/src/daemon/supervise.ts +15 -7
- package/src/files/containment.ts +42 -19
- package/src/files/files.ts +123 -104
- package/src/files/sandbox.ts +0 -0
- package/src/instance/instance.ts +40 -11
- package/src/instance/log.ts +29 -13
- package/src/kv/store.ts +104 -56
- package/src/launcher/launcher.ts +8 -5
- package/src/launcher/roots.ts +10 -7
- package/src/launcher/tree.ts +15 -12
- package/src/messaging/delivery.ts +28 -11
- package/src/messaging/direct.ts +105 -39
- package/src/messaging/inbox.ts +83 -21
- package/src/sessions/dump.ts +11 -9
- package/src/sessions/fork.ts +21 -14
- package/src/sessions/handlers.ts +9 -7
- package/src/sessions/items.ts +10 -8
- package/src/sessions/last-live.ts +28 -5
- package/src/sessions/registry.ts +40 -23
- package/src/sessions/search.ts +16 -7
- package/src/sessions/status.ts +3 -3
- package/src/transcript/files.ts +65 -23
- package/src/transcript/read.ts +10 -10
- package/src/transcript/scan.ts +49 -0
package/src/kv/store.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { mkdir, rename, unlink, writeFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import type {
|
|
4
5
|
KvDeleteArgs,
|
|
@@ -28,15 +29,38 @@ export const KV_DIR = "kv";
|
|
|
28
29
|
* One file per namespace, whole-file: a namespace holds a handful of small
|
|
29
30
|
* values, and the whole of it is what both a snapshot and a reload state. The
|
|
30
31
|
* write lands through a temporary and a rename, so a kill leaves either the
|
|
31
|
-
* previous namespace or the new one.
|
|
32
|
+
* previous namespace or the new one.
|
|
33
|
+
*
|
|
34
|
+
* The files are read here, as the store is built — before this instance is
|
|
35
|
+
* accepting anything, so nobody is waiting on the read (DR-0015). Reading them
|
|
36
|
+
* when a namespace was first asked about would put the read inside the turn
|
|
37
|
+
* that answers a `kv.read` or opens a `kv:<ns>` subscription, and a snapshot is
|
|
38
|
+
* answered from what is held rather than from a promise. A namespace with no
|
|
39
|
+
* file starts empty, which is the same thing a namespace written for the first
|
|
40
|
+
* time after this starts from. */
|
|
32
41
|
export class KvStore implements UpstreamResource {
|
|
33
42
|
readonly #namespaces = new Map<string, Map<string, Held>>();
|
|
34
43
|
|
|
44
|
+
/** Per namespace, the writes already asked for, as one chain. */
|
|
45
|
+
readonly #writing = new Map<string, Promise<void>>();
|
|
46
|
+
|
|
35
47
|
constructor(
|
|
36
48
|
private readonly dir: string,
|
|
37
49
|
private readonly self: InstanceId,
|
|
38
50
|
private readonly publish: (topic: string, data: unknown) => void,
|
|
39
|
-
) {
|
|
51
|
+
) {
|
|
52
|
+
let names: string[];
|
|
53
|
+
try {
|
|
54
|
+
names = readdirSync(this.dir);
|
|
55
|
+
} catch {
|
|
56
|
+
// No directory yet, which is an instance nobody has written a value to.
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
for (const name of names) {
|
|
60
|
+
if (!name.endsWith(".json")) continue;
|
|
61
|
+
this.#namespaces.set(name.slice(0, -".json".length), read(join(this.dir, name)));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
40
64
|
|
|
41
65
|
read(args: KvReadArgs): KvReadResult {
|
|
42
66
|
const held = this.#load(args.ns).get(args.key);
|
|
@@ -54,7 +78,7 @@ export class KvStore implements UpstreamResource {
|
|
|
54
78
|
* was unreachable must not displace what was written since. The answer is
|
|
55
79
|
* what the key carries now — equal to what the caller stated when its write
|
|
56
80
|
* stands, and later than it when an existing value did. */
|
|
57
|
-
write(args: KvWriteArgs, now: Timestamp = Date.now()): KvWriteResult {
|
|
81
|
+
async write(args: KvWriteArgs, now: Timestamp = Date.now()): Promise<KvWriteResult> {
|
|
58
82
|
const entries = this.#load(args.ns);
|
|
59
83
|
const updatedAt = args.updated_at ?? now;
|
|
60
84
|
const held = entries.get(args.key);
|
|
@@ -65,7 +89,9 @@ export class KvStore implements UpstreamResource {
|
|
|
65
89
|
return { updated_at: held.updated_at };
|
|
66
90
|
}
|
|
67
91
|
entries.set(args.key, { value: args.value, updated_at: updatedAt });
|
|
68
|
-
|
|
92
|
+
// Told to the subscribers once it is written down, so nobody is holding a
|
|
93
|
+
// value this instance would not have after a restart.
|
|
94
|
+
await this.#persist(args.ns, entries);
|
|
69
95
|
this.publish(`kv:${args.ns}`, {
|
|
70
96
|
entries: [{ key: args.key, value: args.value, updated_at: updatedAt }],
|
|
71
97
|
});
|
|
@@ -75,14 +101,14 @@ export class KvStore implements UpstreamResource {
|
|
|
75
101
|
/** A key that was not there is no error: the caller wanted the namespace
|
|
76
102
|
* without it, and it is. The removal is still announced, because a subscriber
|
|
77
103
|
* that has the entry has to be told it is gone. */
|
|
78
|
-
delete(args: KvDeleteArgs, now: Timestamp = Date.now()): KvDeleteResult {
|
|
104
|
+
async delete(args: KvDeleteArgs, now: Timestamp = Date.now()): Promise<KvDeleteResult> {
|
|
79
105
|
const entries = this.#load(args.ns);
|
|
80
106
|
const before = entries.get(args.key);
|
|
81
107
|
// A removal older than what the key holds undoes nothing, which is the
|
|
82
108
|
// same rule a write is held to.
|
|
83
109
|
if (before !== undefined && before.updated_at > now) return {};
|
|
84
110
|
entries.set(args.key, { updated_at: now, deleted: true });
|
|
85
|
-
this.#persist(args.ns, entries);
|
|
111
|
+
await this.#persist(args.ns, entries);
|
|
86
112
|
// A removal is announced only when something was there to remove: a
|
|
87
113
|
// subscriber holding no entry has nothing to be told is gone.
|
|
88
114
|
if (before !== undefined && before.deleted !== true) {
|
|
@@ -94,8 +120,7 @@ export class KvStore implements UpstreamResource {
|
|
|
94
120
|
}
|
|
95
121
|
|
|
96
122
|
/** Nothing to start or stop: the values are here whether anyone is watching
|
|
97
|
-
* or not, and the
|
|
98
|
-
* touched. */
|
|
123
|
+
* or not, and the files they live in were read as this was built. */
|
|
99
124
|
start(): void {}
|
|
100
125
|
stop(): void {}
|
|
101
126
|
|
|
@@ -110,57 +135,51 @@ export class KvStore implements UpstreamResource {
|
|
|
110
135
|
return [{ instance: this.self, data: { entries } }];
|
|
111
136
|
}
|
|
112
137
|
|
|
138
|
+
/** Settle once every write asked for so far has landed. What a stop waits on
|
|
139
|
+
* before it lets go of the config home (DESIGN §8.5 step 4). */
|
|
140
|
+
async flush(): Promise<void> {
|
|
141
|
+
await Promise.allSettled(this.#writing.values());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** What the namespace holds, dropping the removals nothing can still be
|
|
145
|
+
* carrying an older write for. A name this store read no file for is a
|
|
146
|
+
* namespace with nothing in it. */
|
|
113
147
|
#load(ns: string, now: Timestamp = Date.now()): Map<string, Held> {
|
|
114
|
-
const known = this.#namespaces.get(ns);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
let text: string;
|
|
118
|
-
try {
|
|
119
|
-
text = readFileSync(this.#file(ns), "utf8");
|
|
120
|
-
} catch {
|
|
121
|
-
this.#namespaces.set(ns, entries);
|
|
122
|
-
return entries;
|
|
123
|
-
}
|
|
124
|
-
let parsed: unknown;
|
|
125
|
-
try {
|
|
126
|
-
parsed = JSON.parse(text);
|
|
127
|
-
} catch {
|
|
128
|
-
// A file a kill damaged states nothing this instance can act on, and
|
|
129
|
-
// refusing every read of the namespace would be worse than starting it
|
|
130
|
-
// empty: the next write replaces the file.
|
|
131
|
-
parsed = undefined;
|
|
132
|
-
}
|
|
133
|
-
if (typeof parsed === "object" && parsed !== null) {
|
|
134
|
-
for (const [key, held] of Object.entries(parsed as Record<string, unknown>)) {
|
|
135
|
-
if (typeof held !== "object" || held === null) continue;
|
|
136
|
-
const fields = held as Record<string, unknown>;
|
|
137
|
-
const updatedAt = fields["updated_at"];
|
|
138
|
-
if (typeof updatedAt !== "number") continue;
|
|
139
|
-
entries.set(
|
|
140
|
-
key,
|
|
141
|
-
fields["deleted"] === true
|
|
142
|
-
? { updated_at: updatedAt, deleted: true }
|
|
143
|
-
: { value: fields["value"], updated_at: updatedAt },
|
|
144
|
-
);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
this.#namespaces.set(ns, entries);
|
|
148
|
-
return forget(entries, now);
|
|
148
|
+
const known = this.#namespaces.get(ns) ?? new Map<string, Held>();
|
|
149
|
+
this.#namespaces.set(ns, known);
|
|
150
|
+
return forget(known, now);
|
|
149
151
|
}
|
|
150
152
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
+
/** The namespace as it stands, written whole.
|
|
154
|
+
*
|
|
155
|
+
* The body is taken here, before anything is awaited, so what is written is
|
|
156
|
+
* the namespace as it was when the write was answered. Writes to one
|
|
157
|
+
* namespace are chained rather than started side by side: two of them would
|
|
158
|
+
* otherwise be racing for one file, and the older could land last (DR-0015).
|
|
159
|
+
* Namespaces do not wait on each other, having nothing in common but this
|
|
160
|
+
* directory. */
|
|
161
|
+
#persist(ns: string, entries: Map<string, Held>): Promise<void> {
|
|
153
162
|
const file = this.#file(ns);
|
|
154
163
|
const body: Record<string, Held> = {};
|
|
155
164
|
for (const [key, held] of entries) body[key] = held;
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
165
|
+
const written = (this.#writing.get(ns) ?? Promise.resolve()).then(async () => {
|
|
166
|
+
await mkdir(this.dir, { recursive: true });
|
|
167
|
+
const temporary = `${file}.ccmsg-${process.pid}-${Date.now()}`;
|
|
168
|
+
await writeFile(temporary, JSON.stringify(body));
|
|
169
|
+
try {
|
|
170
|
+
await rename(temporary, file);
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
await unlink(temporary);
|
|
173
|
+
throw cause;
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
// The chain carries the order, not the outcome: a write that failed is
|
|
177
|
+
// answered to its own caller, and the ones behind it still go.
|
|
178
|
+
this.#writing.set(
|
|
179
|
+
ns,
|
|
180
|
+
written.catch(() => {}),
|
|
181
|
+
);
|
|
182
|
+
return written;
|
|
164
183
|
}
|
|
165
184
|
|
|
166
185
|
/** The namespace's file. A namespace is an identifier, so its name is a file
|
|
@@ -170,6 +189,35 @@ export class KvStore implements UpstreamResource {
|
|
|
170
189
|
}
|
|
171
190
|
}
|
|
172
191
|
|
|
192
|
+
/** One namespace's file, as the entries it states.
|
|
193
|
+
*
|
|
194
|
+
* A file a kill damaged states nothing this instance can act on, and refusing
|
|
195
|
+
* every read of the namespace would be worse than starting it empty: the next
|
|
196
|
+
* write replaces the file. */
|
|
197
|
+
function read(file: string): Map<string, Held> {
|
|
198
|
+
const entries = new Map<string, Held>();
|
|
199
|
+
let parsed: unknown;
|
|
200
|
+
try {
|
|
201
|
+
parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
202
|
+
} catch {
|
|
203
|
+
return entries;
|
|
204
|
+
}
|
|
205
|
+
if (typeof parsed !== "object" || parsed === null) return entries;
|
|
206
|
+
for (const [key, held] of Object.entries(parsed as Record<string, unknown>)) {
|
|
207
|
+
if (typeof held !== "object" || held === null) continue;
|
|
208
|
+
const fields = held as Record<string, unknown>;
|
|
209
|
+
const updatedAt = fields["updated_at"];
|
|
210
|
+
if (typeof updatedAt !== "number") continue;
|
|
211
|
+
entries.set(
|
|
212
|
+
key,
|
|
213
|
+
fields["deleted"] === true
|
|
214
|
+
? { updated_at: updatedAt, deleted: true }
|
|
215
|
+
: { value: fields["value"], updated_at: updatedAt },
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return entries;
|
|
219
|
+
}
|
|
220
|
+
|
|
173
221
|
/** Drop the removals nothing can still be carrying an older write for.
|
|
174
222
|
*
|
|
175
223
|
* Done where the namespace is read rather than on a clock of its own: a timer
|
|
@@ -188,9 +236,9 @@ export function kvHandlers(store: KvStore) {
|
|
|
188
236
|
return {
|
|
189
237
|
"kv.read": (input: HandlerInput): KvReadResult =>
|
|
190
238
|
store.read(input.args as unknown as KvReadArgs),
|
|
191
|
-
"kv.write": (input: HandlerInput): KvWriteResult =>
|
|
239
|
+
"kv.write": (input: HandlerInput): Promise<KvWriteResult> =>
|
|
192
240
|
store.write(input.args as unknown as KvWriteArgs),
|
|
193
|
-
"kv.delete": (input: HandlerInput): KvDeleteResult =>
|
|
241
|
+
"kv.delete": (input: HandlerInput): Promise<KvDeleteResult> =>
|
|
194
242
|
store.delete(input.args as unknown as KvDeleteArgs),
|
|
195
243
|
};
|
|
196
244
|
}
|
package/src/launcher/launcher.ts
CHANGED
|
@@ -67,17 +67,20 @@ export class Launcher {
|
|
|
67
67
|
templates: this.config.templates.map((template) => ({
|
|
68
68
|
name: template.name,
|
|
69
69
|
command: template.command,
|
|
70
|
-
params: template.params.map((param) => ({
|
|
70
|
+
params: template.params.map((param) => ({
|
|
71
|
+
name: param.name,
|
|
72
|
+
default: param.default,
|
|
73
|
+
})),
|
|
71
74
|
})),
|
|
72
75
|
};
|
|
73
76
|
}
|
|
74
77
|
|
|
75
|
-
tree(args: DirTreeArgs): DirTreeResult {
|
|
78
|
+
tree(args: DirTreeArgs): Promise<DirTreeResult> {
|
|
76
79
|
return dirTree(this.config, args);
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
run(args: LauncherRunArgs): Promise<LauncherRunResult> {
|
|
80
|
-
const cwd = insideRoots(this.config, args.cwd);
|
|
82
|
+
async run(args: LauncherRunArgs): Promise<LauncherRunResult> {
|
|
83
|
+
const cwd = await insideRoots(this.config, args.cwd);
|
|
81
84
|
if (cwd === undefined) {
|
|
82
85
|
// The op states no refusal for a path, so a directory outside the roots
|
|
83
86
|
// is answered as what it is from here: an argument this launcher cannot
|
|
@@ -129,7 +132,7 @@ export function launcherHandlers(launcher: Launcher) {
|
|
|
129
132
|
"launcher.config.read": (): LauncherConfigReadResult => launcher.configRead(),
|
|
130
133
|
"launcher.run": (input: HandlerInput): Promise<LauncherRunResult> =>
|
|
131
134
|
launcher.run(input.args as unknown as LauncherRunArgs),
|
|
132
|
-
"dir.tree": (input: HandlerInput): DirTreeResult =>
|
|
135
|
+
"dir.tree": (input: HandlerInput): Promise<DirTreeResult> =>
|
|
133
136
|
launcher.tree(input.args as unknown as DirTreeArgs),
|
|
134
137
|
};
|
|
135
138
|
}
|
package/src/launcher/roots.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
2
|
import { isAbsolute } from "node:path";
|
|
3
3
|
import type { LauncherConfig } from "../instance/config.ts";
|
|
4
4
|
import { canonical, within } from "../files/index.ts";
|
|
@@ -13,19 +13,22 @@ import { canonical, within } from "../files/index.ts";
|
|
|
13
13
|
*
|
|
14
14
|
* A root that no longer resolves grants nothing and stops nothing: another
|
|
15
15
|
* configured root may still hold the candidate. */
|
|
16
|
-
export function insideRoots(
|
|
16
|
+
export async function insideRoots(
|
|
17
|
+
config: LauncherConfig,
|
|
18
|
+
path: string,
|
|
19
|
+
): Promise<string | undefined> {
|
|
17
20
|
if (!isAbsolute(path)) return undefined;
|
|
18
|
-
const real = canonical(path);
|
|
19
|
-
if (!isDirectory(real)) return undefined;
|
|
21
|
+
const real = await canonical(path);
|
|
22
|
+
if (!(await isDirectory(real))) return undefined;
|
|
20
23
|
for (const root of config.root_dirs) {
|
|
21
|
-
if (within(real, canonical(root))) return real;
|
|
24
|
+
if (within(real, await canonical(root))) return real;
|
|
22
25
|
}
|
|
23
26
|
return undefined;
|
|
24
27
|
}
|
|
25
28
|
|
|
26
|
-
export function isDirectory(path: string): boolean {
|
|
29
|
+
export async function isDirectory(path: string): Promise<boolean> {
|
|
27
30
|
try {
|
|
28
|
-
return
|
|
31
|
+
return (await stat(path)).isDirectory();
|
|
29
32
|
} catch {
|
|
30
33
|
return false;
|
|
31
34
|
}
|
package/src/launcher/tree.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join, relative } from "node:path";
|
|
3
3
|
import type { DirTreeArgs, DirTreeEntry, DirTreeResult } from "@ccmsg/protocol";
|
|
4
4
|
import type { LauncherConfig } from "../instance/config.ts";
|
|
@@ -18,29 +18,29 @@ const MAX_DEPTH = 5;
|
|
|
18
18
|
* not hold contributes nothing rather than failing the request: the op states no
|
|
19
19
|
* refusal for a path, and a tree assembled from several roots would otherwise be
|
|
20
20
|
* lost whole because one of them went away. */
|
|
21
|
-
export function dirTree(config: LauncherConfig, args: DirTreeArgs): DirTreeResult {
|
|
21
|
+
export async function dirTree(config: LauncherConfig, args: DirTreeArgs): Promise<DirTreeResult> {
|
|
22
22
|
const depth = Math.min(MAX_DEPTH, args.depth ?? config.depth);
|
|
23
23
|
// A filter of nothing is not a filter: an emptied search box shows the tree
|
|
24
24
|
// rather than hiding all of it.
|
|
25
25
|
const filter = args.filter === undefined || args.filter === "" ? undefined : args.filter;
|
|
26
26
|
const entries: DirTreeEntry[] = [];
|
|
27
27
|
for (const root of args.roots) {
|
|
28
|
-
const real = insideRoots(config, root);
|
|
28
|
+
const real = await insideRoots(config, root);
|
|
29
29
|
if (real === undefined) continue;
|
|
30
|
-
entries.push(...walk(config, real, real, depth, filter));
|
|
30
|
+
entries.push(...(await walk(config, real, real, depth, filter)));
|
|
31
31
|
}
|
|
32
32
|
return { entries: sorted(entries) };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
function walk(
|
|
35
|
+
async function walk(
|
|
36
36
|
config: LauncherConfig,
|
|
37
37
|
root: string,
|
|
38
38
|
at: string,
|
|
39
39
|
depth: number,
|
|
40
40
|
filter: string | undefined,
|
|
41
|
-
): DirTreeEntry[] {
|
|
41
|
+
): Promise<DirTreeEntry[]> {
|
|
42
42
|
const entries: DirTreeEntry[] = [];
|
|
43
|
-
for (const dirent of read(at)) {
|
|
43
|
+
for (const dirent of await read(at)) {
|
|
44
44
|
// Design rationale: dot-directories are left out. This answers "where could
|
|
45
45
|
// a session run", and a repository's `.git` is not one of those places —
|
|
46
46
|
// browsing a session's own files is a different op with different rules.
|
|
@@ -49,24 +49,27 @@ function walk(
|
|
|
49
49
|
if (dirent.isSymbolicLink()) {
|
|
50
50
|
// A link is a place to run only if what it points at is one, so it goes
|
|
51
51
|
// through the same containment its target would.
|
|
52
|
-
if (insideRoots(config, path) === undefined) continue;
|
|
52
|
+
if ((await insideRoots(config, path)) === undefined) continue;
|
|
53
53
|
} else if (!dirent.isDirectory()) continue;
|
|
54
54
|
|
|
55
|
-
const children = depth > 1 ? walk(config, root, path, depth - 1, filter) : undefined;
|
|
55
|
+
const children = depth > 1 ? await walk(config, root, path, depth - 1, filter) : undefined;
|
|
56
56
|
if (filter !== undefined) {
|
|
57
57
|
const matches = relative(root, path).includes(filter);
|
|
58
58
|
// An ancestor of a match survives the filter: without it a match several
|
|
59
59
|
// levels down would have nothing to hang from.
|
|
60
60
|
if (!matches && (children === undefined || children.length === 0)) continue;
|
|
61
61
|
}
|
|
62
|
-
entries.push({
|
|
62
|
+
entries.push({
|
|
63
|
+
path,
|
|
64
|
+
...(children === undefined ? {} : { children: sorted(children) }),
|
|
65
|
+
});
|
|
63
66
|
}
|
|
64
67
|
return entries;
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
function read(dir: string) {
|
|
70
|
+
async function read(dir: string) {
|
|
68
71
|
try {
|
|
69
|
-
return
|
|
72
|
+
return await readdir(dir, { withFileTypes: true });
|
|
70
73
|
} catch {
|
|
71
74
|
// A directory that cannot be read is still a place to run; what it holds is
|
|
72
75
|
// simply not known, which is the same answer as holding nothing.
|
|
@@ -143,7 +143,7 @@ export class Delivery implements UpstreamResource {
|
|
|
143
143
|
if (direct === "refused") {
|
|
144
144
|
// Turned away for now, which is neither delivered nor undeliverable: it
|
|
145
145
|
// waits in the inbox and is offered again (DESIGN §6.8).
|
|
146
|
-
this.#hold(to, message);
|
|
146
|
+
await this.#hold(to, message);
|
|
147
147
|
return { delivered: false, reason: "throttled" };
|
|
148
148
|
}
|
|
149
149
|
|
|
@@ -160,11 +160,11 @@ export class Delivery implements UpstreamResource {
|
|
|
160
160
|
// The session is listening but is behind on what it has already been
|
|
161
161
|
// offered, which is the same standing as route (a) turning the message
|
|
162
162
|
// away: it waits in the inbox and is offered again (DESIGN §6.8).
|
|
163
|
-
this.#hold(to, message);
|
|
163
|
+
await this.#hold(to, message);
|
|
164
164
|
return { delivered: false, reason: "throttled" };
|
|
165
165
|
}
|
|
166
166
|
|
|
167
|
-
const { evicted } = this.#hold(to, message);
|
|
167
|
+
const { evicted } = await this.#hold(to, message);
|
|
168
168
|
return this.#undelivered(to, evicted ? "inbox_full" : this.#reason(state));
|
|
169
169
|
};
|
|
170
170
|
|
|
@@ -173,9 +173,9 @@ export class Delivery implements UpstreamResource {
|
|
|
173
173
|
* Stated before it is held, so that a message dropped to make room for it
|
|
174
174
|
* reads in the order the two happened: a removal of something the watcher
|
|
175
175
|
* has, rather than of something it is about to be told about. */
|
|
176
|
-
#hold(to: Sid, message: InboxMessage): { evicted: boolean } {
|
|
176
|
+
async #hold(to: Sid, message: InboxMessage): Promise<{ evicted: boolean }> {
|
|
177
177
|
this.#watchers(to, [message]);
|
|
178
|
-
return this.deps.inbox.hold(to, message);
|
|
178
|
+
return await this.deps.inbox.hold(to, message);
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
/** What is waiting, as somebody looking at it from outside reads it.
|
|
@@ -247,11 +247,18 @@ export class Delivery implements UpstreamResource {
|
|
|
247
247
|
* nothing waiting are not asked about, so the cost of a change nobody is owed
|
|
248
248
|
* anything after is one map read. */
|
|
249
249
|
retry = async (): Promise<void> => {
|
|
250
|
+
const offers: Promise<void>[] = [];
|
|
250
251
|
for (const sid of this.deps.inbox.sids()) {
|
|
251
252
|
const state = this.deps.sessions.classify(sid);
|
|
252
253
|
if (state === undefined || state === "paused" || state === "disappeared") continue;
|
|
253
|
-
|
|
254
|
+
// One session at a time within its own offer, every session at once
|
|
255
|
+
// across them: a session that is slow to answer, or that never does
|
|
256
|
+
// before its deadline, is not a reason the next session waits (DR-0015).
|
|
257
|
+
// The order within a session is what `#offer` holds to, and it guards
|
|
258
|
+
// itself per sid.
|
|
259
|
+
offers.push(this.#offer(sid));
|
|
254
260
|
}
|
|
261
|
+
await Promise.all(offers);
|
|
255
262
|
};
|
|
256
263
|
|
|
257
264
|
/** Hand a session what it is owed, oldest first, over route (a).
|
|
@@ -275,7 +282,7 @@ export class Delivery implements UpstreamResource {
|
|
|
275
282
|
// delivered, and a daemon killed here must not offer those again.
|
|
276
283
|
if (outcome !== "delivered") break;
|
|
277
284
|
this.#claimed.get(to)?.delete(message.mid);
|
|
278
|
-
this.deps.inbox.delivered(to, [message.mid]);
|
|
285
|
+
await this.deps.inbox.delivered(to, [message.mid]);
|
|
279
286
|
}
|
|
280
287
|
} finally {
|
|
281
288
|
this.#claimed.delete(to);
|
|
@@ -324,10 +331,20 @@ export class Delivery implements UpstreamResource {
|
|
|
324
331
|
const held = this.deps.inbox
|
|
325
332
|
.undelivered(sid)
|
|
326
333
|
.filter((message) => claimed?.has(message.mid) !== true);
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
334
|
+
// The messages leave the inbox here, in memory, and the lines saying so go
|
|
335
|
+
// behind whatever the file already owes (DR-0015): the frame carrying them
|
|
336
|
+
// is queued on the connection before this returns, so what the session has
|
|
337
|
+
// been handed is settled whether or not the line has landed yet.
|
|
338
|
+
void this.deps.inbox
|
|
339
|
+
.delivered(
|
|
340
|
+
sid,
|
|
341
|
+
held.map((message) => message.mid),
|
|
342
|
+
)
|
|
343
|
+
.catch(() => {
|
|
344
|
+
// A line that could not be written costs a message being offered again
|
|
345
|
+
// on the next run, which is what the inbox does about anything it is
|
|
346
|
+
// unsure of.
|
|
347
|
+
});
|
|
331
348
|
return [{ instance: this.deps.self, data: held }];
|
|
332
349
|
}
|
|
333
350
|
|