@earendil-works/chord 1.2.0 → 1.4.0
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/CHANGELOG.md +71 -0
- package/README.md +69 -14
- package/dist/cell.d.ts +30 -9
- package/dist/cell.d.ts.map +1 -1
- package/dist/cell.js +52 -12
- package/dist/cell.js.map +1 -1
- package/dist/cluster.d.ts +4 -3
- package/dist/cluster.d.ts.map +1 -1
- package/dist/cluster.js +58 -42
- package/dist/cluster.js.map +1 -1
- package/dist/connection.d.ts +171 -58
- package/dist/connection.d.ts.map +1 -1
- package/dist/connection.js +676 -217
- package/dist/connection.js.map +1 -1
- package/dist/context/index.d.ts +8 -0
- package/dist/context/index.d.ts.map +1 -1
- package/dist/context/index.js +15 -0
- package/dist/context/index.js.map +1 -1
- package/dist/contract.d.ts +120 -45
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js +104 -15
- package/dist/contract.js.map +1 -1
- package/dist/declare.d.ts +11 -48
- package/dist/declare.d.ts.map +1 -1
- package/dist/declare.js +1 -21
- package/dist/declare.js.map +1 -1
- package/dist/delta/index.d.ts +2 -1
- package/dist/delta/index.d.ts.map +1 -1
- package/dist/delta/index.js +4 -0
- package/dist/delta/index.js.map +1 -1
- package/dist/errors.d.ts +4 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -1
- package/dist/host.d.ts +214 -120
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +446 -241
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +8 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/dist/link.d.ts +47 -12
- package/dist/link.d.ts.map +1 -1
- package/dist/link.js +200 -14
- package/dist/link.js.map +1 -1
- package/dist/loader.d.ts.map +1 -1
- package/dist/loader.js +7 -2
- package/dist/loader.js.map +1 -1
- package/dist/manager.d.ts.map +1 -1
- package/dist/manager.js +35 -9
- package/dist/manager.js.map +1 -1
- package/dist/object.d.ts +438 -0
- package/dist/object.d.ts.map +1 -0
- package/dist/{record.js → object.js} +614 -335
- package/dist/object.js.map +1 -0
- package/dist/output.d.ts +91 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +360 -0
- package/dist/output.js.map +1 -0
- package/dist/registry.d.ts +3 -3
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +1 -1
- package/dist/registry.js.map +1 -1
- package/dist/reload.d.ts +35 -27
- package/dist/reload.d.ts.map +1 -1
- package/dist/reload.js +256 -192
- package/dist/reload.js.map +1 -1
- package/dist/remote.d.ts +130 -35
- package/dist/remote.d.ts.map +1 -1
- package/dist/remote.js +493 -189
- package/dist/remote.js.map +1 -1
- package/dist/sqlite/cloudflare.d.ts +52 -0
- package/dist/sqlite/cloudflare.d.ts.map +1 -0
- package/dist/sqlite/cloudflare.js +134 -0
- package/dist/sqlite/cloudflare.js.map +1 -0
- package/dist/sqlite/database.d.ts +37 -0
- package/dist/sqlite/database.d.ts.map +1 -0
- package/dist/sqlite/database.js +2 -0
- package/dist/sqlite/database.js.map +1 -0
- package/dist/sqlite/index.d.ts +4 -0
- package/dist/sqlite/index.d.ts.map +1 -0
- package/dist/sqlite/index.js +3 -0
- package/dist/sqlite/index.js.map +1 -0
- package/dist/sqlite/migrations.d.ts +10 -0
- package/dist/sqlite/migrations.d.ts.map +1 -0
- package/dist/sqlite/migrations.js +33 -0
- package/dist/sqlite/migrations.js.map +1 -0
- package/dist/sqlite/node.d.ts +38 -0
- package/dist/sqlite/node.d.ts.map +1 -0
- package/dist/sqlite/node.js +174 -0
- package/dist/sqlite/node.js.map +1 -0
- package/dist/sqlite/schema.d.ts +3 -0
- package/dist/sqlite/schema.d.ts.map +1 -0
- package/dist/sqlite/schema.js +10 -0
- package/dist/sqlite/schema.js.map +1 -0
- package/dist/telemetry-sqlite/index.d.ts +24 -0
- package/dist/telemetry-sqlite/index.d.ts.map +1 -0
- package/dist/telemetry-sqlite/index.js +273 -0
- package/dist/telemetry-sqlite/index.js.map +1 -0
- package/dist/telemetry-sqlite/store.d.ts +9 -0
- package/dist/telemetry-sqlite/store.d.ts.map +1 -0
- package/dist/telemetry-sqlite/store.js +110 -0
- package/dist/telemetry-sqlite/store.js.map +1 -0
- package/dist/types.d.ts +39 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js.map +1 -1
- package/dist/websocket.d.ts +4 -5
- package/dist/websocket.d.ts.map +1 -1
- package/dist/websocket.js +3 -2
- package/dist/websocket.js.map +1 -1
- package/dist/wire.d.ts +210 -129
- package/dist/wire.d.ts.map +1 -1
- package/dist/wire.js +108 -55
- package/dist/wire.js.map +1 -1
- package/docs/guide.md +267 -203
- package/docs/spec.md +141 -114
- package/docs/tutorial.md +257 -235
- package/package.json +27 -1
- package/dist/record.d.ts +0 -320
- package/dist/record.d.ts.map +0 -1
- package/dist/record.js.map +0 -1
package/dist/host.js
CHANGED
|
@@ -1,42 +1,48 @@
|
|
|
1
1
|
import { ChildReloads, reconcileChild, runClusterReload } from "./cluster.js";
|
|
2
2
|
import { Connection } from "./connection.js";
|
|
3
|
-
import { bind, } from "./contract.js";
|
|
3
|
+
import { bind, contractFromDescription, placedOn, placementText, resolveHost, } from "./contract.js";
|
|
4
4
|
import { ChordError, invalid, runAll } from "./errors.js";
|
|
5
|
+
import { awaitReady, CtxImpl, hostLifetime, makeService, ObjectImpl, RefImpl, } from "./object.js";
|
|
5
6
|
import { enqueue } from "./publish.js";
|
|
6
|
-
import { CtxImpl, HandleImpl, makeService, RecordImpl, } from "./record.js";
|
|
7
7
|
import { runReload } from "./reload.js";
|
|
8
|
-
import {
|
|
8
|
+
import { RemoteBinding, RemoteRef } from "./remote.js";
|
|
9
9
|
import { canonicalIdentity, canonicalJson, deepEqual, normalize } from "./schema.js";
|
|
10
|
-
/**
|
|
11
|
-
export const hostPrincipal = Object.freeze({ host: true });
|
|
10
|
+
/** `defineFacet<P>(…)`: an app passes its principal type, so factories and contexts see `P`. */
|
|
12
11
|
export const defineFacet = (setup, options = {}) => ({ setup, uses: options.uses });
|
|
13
12
|
const isFacet = (ref) => typeof ref === "object" && !(ref instanceof URL);
|
|
14
13
|
const EMPTY_TABLE = new Map();
|
|
15
|
-
|
|
14
|
+
/** A host whose principals are `P` (the app's type; `unknown` if not given). */
|
|
15
|
+
export function createHost(options) {
|
|
16
16
|
return new HostImpl(options);
|
|
17
17
|
}
|
|
18
18
|
export class HostImpl {
|
|
19
19
|
name;
|
|
20
20
|
dev;
|
|
21
|
-
principal
|
|
21
|
+
principal;
|
|
22
22
|
inFactory = 0;
|
|
23
23
|
failed;
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
objects = new Map();
|
|
25
|
+
codeVersions = new Map();
|
|
26
26
|
facets = new Map();
|
|
27
|
-
|
|
27
|
+
/** The host's own references to its `lifetime: "host"` objects, by type name. */
|
|
28
|
+
hostObjects = new Map();
|
|
28
29
|
kind;
|
|
29
30
|
connections = new Set();
|
|
30
31
|
upstream;
|
|
31
|
-
/**
|
|
32
|
-
|
|
32
|
+
/** Shared bindings of this host's remote references, by key (re-held after an upstream reconnect). */
|
|
33
|
+
bindings = new Map();
|
|
34
|
+
/** Bindings without references that linger, least recently released first. */
|
|
35
|
+
#lingering = new Map();
|
|
36
|
+
#lingerTypes;
|
|
37
|
+
#lingerCap;
|
|
38
|
+
#originSeq = 0;
|
|
33
39
|
#routes = new Map();
|
|
34
40
|
#childReloads = new ChildReloads(this);
|
|
35
41
|
/** Child facet modules by host-name prefix, from every committed cluster reload (for reconciling children). */
|
|
36
42
|
remoteModules = {};
|
|
37
43
|
/** Removal tombstones also reconcile children that connected during a transaction. */
|
|
38
44
|
remoteRemoved = {};
|
|
39
|
-
/** Highest
|
|
45
|
+
/** Highest code version the parent has retired. */
|
|
40
46
|
#retireAllowed = 0;
|
|
41
47
|
/** Roots that came over a link, by the link they came from. */
|
|
42
48
|
/** Foreign roots (started on another host), by the links they came over. */
|
|
@@ -48,46 +54,75 @@ export class HostImpl {
|
|
|
48
54
|
#rootLinks = new Map();
|
|
49
55
|
current;
|
|
50
56
|
previous;
|
|
51
|
-
/** The
|
|
57
|
+
/** The code version a running reload will commit; new types activated during prepare install their implementation for it. */
|
|
52
58
|
pending;
|
|
53
59
|
/** Activations of these types wait (reload gate). */
|
|
54
|
-
/** Types behind a reload gate: new
|
|
60
|
+
/** Types behind a reload gate: new objects of these types are rejected `upgrade_conflict` until commit/abort. */
|
|
55
61
|
gating;
|
|
56
62
|
reloading = false;
|
|
57
|
-
|
|
63
|
+
taskStopDeadline;
|
|
64
|
+
readyTimeout;
|
|
58
65
|
#principalKey;
|
|
66
|
+
/** `principalKey(self)`: an equal principal is the host's own, whatever object carries it. */
|
|
67
|
+
#selfKey;
|
|
68
|
+
/** Called when a root ends, the parent allows a retirement, or the previous code version retires. */
|
|
69
|
+
#retirementWatchers = new Set();
|
|
70
|
+
/** Child hosts whose hello arrived while a reload ran: synced or reconciled once it ends. */
|
|
71
|
+
#deferredHellos = new Map();
|
|
72
|
+
/** Resolved when the running reload ends. */
|
|
73
|
+
#reloadWaiters = [];
|
|
74
|
+
/** Aborted when the installed run of a facet (by name) is replaced, removed, or the host closes. */
|
|
75
|
+
#facetRuns = new Map();
|
|
59
76
|
#onError;
|
|
60
77
|
#loader;
|
|
61
78
|
#dying = new Map();
|
|
62
79
|
#touches = new Map();
|
|
63
80
|
#order = new Map();
|
|
64
81
|
#subscribers = new Map();
|
|
65
|
-
#
|
|
82
|
+
#codeVersionSeq = 1;
|
|
66
83
|
#rootSeq = 0;
|
|
67
84
|
constructor(options) {
|
|
68
85
|
this.name = options.name ?? "host";
|
|
69
86
|
this.dev = options.dev ?? false;
|
|
87
|
+
if (!("self" in options))
|
|
88
|
+
throw new TypeError("createHost needs `self`: the principal of the host's own code");
|
|
89
|
+
this.principal = options.self;
|
|
70
90
|
this.#principalKey = options.principalKey ?? canonicalJson;
|
|
91
|
+
this.#selfKey = this.#principalKey(options.self);
|
|
71
92
|
this.#onError = options.onError ?? ((error) => console.error("chord:", error));
|
|
72
93
|
this.#loader = options.loader;
|
|
73
|
-
this.
|
|
74
|
-
this.
|
|
94
|
+
this.taskStopDeadline = options.taskStopDeadline ?? 5000;
|
|
95
|
+
this.readyTimeout = options.readyTimeout ?? 60_000;
|
|
96
|
+
this.#lingerTypes = options.linger?.types ?? {};
|
|
97
|
+
this.#lingerCap = options.linger?.cap ?? 4;
|
|
98
|
+
this.current = {
|
|
99
|
+
id: this.#codeVersionSeq,
|
|
100
|
+
services: new Map(),
|
|
101
|
+
tables: new Map(),
|
|
102
|
+
roots: new Map(),
|
|
103
|
+
abort: new AbortController(),
|
|
104
|
+
};
|
|
105
|
+
this.codeVersions.set(this.current.id, this.current);
|
|
75
106
|
this.kind = options.kind ?? "host";
|
|
76
107
|
if (options.upstream !== undefined)
|
|
77
|
-
this.connect(options.upstream, options.
|
|
108
|
+
this.connect(options.upstream.link, options.upstream);
|
|
109
|
+
}
|
|
110
|
+
/** Whether `principal` is the host's own: the same key as `self`. */
|
|
111
|
+
isSelf(principal) {
|
|
112
|
+
return principal === this.principal || this.#principalKey(principal) === this.#selfKey;
|
|
78
113
|
}
|
|
79
|
-
get
|
|
114
|
+
get codeVersion() {
|
|
80
115
|
return this.current.id;
|
|
81
116
|
}
|
|
82
|
-
get
|
|
117
|
+
get previousCodeVersion() {
|
|
83
118
|
return this.previous?.id;
|
|
84
119
|
}
|
|
85
|
-
/** Allocates the next
|
|
86
|
-
|
|
120
|
+
/** Allocates the next code version id, or adopts one issued by the coordinating server. */
|
|
121
|
+
nextCodeVersionId(given) {
|
|
87
122
|
if (given !== undefined && given <= this.current.id)
|
|
88
|
-
throw new Error(`
|
|
89
|
-
this.#
|
|
90
|
-
return this.#
|
|
123
|
+
throw new Error(`code version ${given} is not newer than ${this.current.id}`);
|
|
124
|
+
this.#codeVersionSeq = given ?? this.#codeVersionSeq + 1;
|
|
125
|
+
return this.#codeVersionSeq;
|
|
91
126
|
}
|
|
92
127
|
report(error) {
|
|
93
128
|
this.#onError(error);
|
|
@@ -97,8 +132,8 @@ export class HostImpl {
|
|
|
97
132
|
if (this.reloading)
|
|
98
133
|
throw new Error("host.serve during a reload; serve from a facet passed to reload()");
|
|
99
134
|
this.serveInto(this.current.services, makeService(type, factory, options, ""));
|
|
100
|
-
if (options.
|
|
101
|
-
this.
|
|
135
|
+
if (options.lifetime === "host")
|
|
136
|
+
this.startHostObject(this.current.services.get(type.name));
|
|
102
137
|
});
|
|
103
138
|
install(facet, options = {}) {
|
|
104
139
|
if (this.reloading)
|
|
@@ -106,25 +141,33 @@ export class HostImpl {
|
|
|
106
141
|
if (this.facets.has(facet.name))
|
|
107
142
|
throw new Error(`facet ${facet.name} is already installed; use reload()`);
|
|
108
143
|
const added = [];
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
144
|
+
const run = new AbortController();
|
|
145
|
+
try {
|
|
146
|
+
facet.setup(this.facetApi(((type, factory, options = {}) => {
|
|
147
|
+
const service = makeService(type, factory, options, facet.name);
|
|
148
|
+
this.serveInto(this.current.services, service);
|
|
149
|
+
added.push(service);
|
|
150
|
+
}), run.signal));
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
run.abort(error);
|
|
154
|
+
throw error;
|
|
155
|
+
}
|
|
114
156
|
this.facets.set(facet.name, facet);
|
|
115
|
-
|
|
116
|
-
|
|
157
|
+
this.facetRunChanged(facet.name, run);
|
|
158
|
+
const refs = added.filter(hostLifetime).map((service) => this.startHostObject(service));
|
|
159
|
+
return options.awaitHostObjects === false ? Promise.resolve() : this.#hostObjectsReady(refs);
|
|
117
160
|
}
|
|
118
161
|
async load(plugins, options = {}) {
|
|
119
162
|
const facets = await this.resolve(plugins);
|
|
120
|
-
// No await between installs:
|
|
163
|
+
// No await between installs: a host object's factory may consume a type served by a later facet.
|
|
121
164
|
await Promise.all(facets.map((facet) => this.install(facet, options)));
|
|
122
165
|
}
|
|
123
166
|
ready() {
|
|
124
|
-
return this.#
|
|
167
|
+
return this.#hostObjectsReady(this.hostObjects.values());
|
|
125
168
|
}
|
|
126
|
-
#
|
|
127
|
-
const ready = Promise.all([...
|
|
169
|
+
#hostObjectsReady(refs) {
|
|
170
|
+
const ready = Promise.all([...refs].map((ref) => ref.ready)).then(() => { });
|
|
128
171
|
// Ignoring install's return still reports through onError without an unhandled rejection.
|
|
129
172
|
ready.catch(() => { });
|
|
130
173
|
return ready;
|
|
@@ -138,128 +181,183 @@ export class HostImpl {
|
|
|
138
181
|
const loaded = modules.length > 0 && this.#loader !== undefined ? await this.#loader(modules) : [];
|
|
139
182
|
return plugins.map((ref) => (isFacet(ref) ? ref : loaded.shift()));
|
|
140
183
|
}
|
|
141
|
-
facetApi(serve) {
|
|
184
|
+
facetApi(serve, signal) {
|
|
142
185
|
return {
|
|
143
186
|
serve,
|
|
144
187
|
name: this.name,
|
|
145
188
|
root: (fn, options) => this.root(fn, options),
|
|
146
189
|
registry: (registry) => this.registry(registry),
|
|
190
|
+
signal,
|
|
147
191
|
};
|
|
148
192
|
}
|
|
193
|
+
/** The installed run of facet `name` is now `run` (or none): the run it replaces is aborted. */
|
|
194
|
+
facetRunChanged(name, run) {
|
|
195
|
+
const replaced = this.#facetRuns.get(name);
|
|
196
|
+
if (run === undefined)
|
|
197
|
+
this.#facetRuns.delete(name);
|
|
198
|
+
else
|
|
199
|
+
this.#facetRuns.set(name, run);
|
|
200
|
+
if (replaced !== run)
|
|
201
|
+
replaced?.abort(new ChordError("gone", `facet ${name} was replaced or removed`));
|
|
202
|
+
}
|
|
149
203
|
serveInto(services, service) {
|
|
150
204
|
if (services.has(service.contract.name))
|
|
151
205
|
throw new Error(`${service.contract.name} is already served`);
|
|
206
|
+
const placement = service.contract.description.host;
|
|
207
|
+
if (placement !== undefined && !placedOn(placement, this.name)) {
|
|
208
|
+
throw new TypeError(`${service.contract.name} is declared for ${placementText(placement)}, but ${this.name} serves it`);
|
|
209
|
+
}
|
|
152
210
|
services.set(service.contract.name, service);
|
|
153
211
|
}
|
|
154
|
-
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
handle.ready.catch((error) => this.report(error));
|
|
160
|
-
return handle;
|
|
212
|
+
startHostObject(service) {
|
|
213
|
+
const ref = this.acquire(service.contract, {}, undefined, "host");
|
|
214
|
+
this.hostObjects.set(service.contract.name, ref);
|
|
215
|
+
ref.ready.catch((error) => this.report(error));
|
|
216
|
+
return ref;
|
|
161
217
|
}
|
|
162
218
|
// ─── HostCore ───
|
|
163
|
-
|
|
219
|
+
currentCodeVersion() {
|
|
164
220
|
return this.current.id;
|
|
165
221
|
}
|
|
166
|
-
|
|
167
|
-
return this.
|
|
222
|
+
hasCodeVersion(codeVersion) {
|
|
223
|
+
return this.codeVersions.has(codeVersion);
|
|
168
224
|
}
|
|
169
|
-
*
|
|
170
|
-
for (const [id, state] of this.
|
|
225
|
+
*retainedCodeVersions() {
|
|
226
|
+
for (const [id, state] of this.codeVersions)
|
|
171
227
|
yield [id, state.services];
|
|
172
228
|
if (this.pending !== undefined)
|
|
173
229
|
yield [this.pending.id, this.pending.services];
|
|
174
230
|
}
|
|
175
|
-
|
|
231
|
+
pendingCodeVersion() {
|
|
176
232
|
return this.pending?.id;
|
|
177
233
|
}
|
|
178
|
-
/** The current code for a type; a type a running reload adds is served from the pending
|
|
234
|
+
/** The current code for a type; a type a running reload adds is served from the pending code version. */
|
|
179
235
|
service(typeName) {
|
|
180
236
|
return this.current.services.get(typeName) ?? this.pending?.services.get(typeName);
|
|
181
237
|
}
|
|
182
|
-
addressOf(type, input, principal,
|
|
183
|
-
|
|
184
|
-
|
|
238
|
+
addressOf(type, input, principal, candidate = false) {
|
|
239
|
+
const elsewhere = this.#elsewhere(type, input);
|
|
240
|
+
if (elsewhere !== undefined)
|
|
241
|
+
return this.#remoteKey(type, elsewhere.input, principal);
|
|
185
242
|
const service = this.#bindingService(type.name, candidate);
|
|
186
243
|
if (service === undefined)
|
|
187
244
|
throw unknownType(type.name, this.name);
|
|
188
|
-
|
|
189
|
-
return this.#address(service, normalized, principal);
|
|
245
|
+
return this.#locate(service, input, principal).address;
|
|
190
246
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
247
|
+
/**
|
|
248
|
+
* Where an object of a remote type lives when that is another host: its name and the input normalized with the
|
|
249
|
+
* caller's contract (the placement is read from it). `undefined`: here (and every local type).
|
|
250
|
+
*/
|
|
251
|
+
#elsewhere(type, input) {
|
|
252
|
+
const placement = type.description.host;
|
|
253
|
+
if (type.scope !== "remote" || placement === undefined)
|
|
254
|
+
return undefined;
|
|
255
|
+
const normalized = normalize(type.input, input, `${type.name} input`);
|
|
256
|
+
const at = resolveHost(type.name, placement, normalized);
|
|
257
|
+
return at === this.name ? undefined : { at, input: normalized };
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* The address of `input` for `principal`, normalized. A `lifetime: "host"` type has one object: the empty input, for
|
|
261
|
+
* the host principal, whoever asks; another input is invalid.
|
|
262
|
+
*/
|
|
263
|
+
#locate(service, input, principal) {
|
|
264
|
+
const { name, input: schema } = service.contract;
|
|
265
|
+
const normalized = normalize(schema, input, `${name} input`);
|
|
266
|
+
const identity = canonicalIdentity(schema, normalized);
|
|
267
|
+
if (hostLifetime(service)) {
|
|
268
|
+
if (identity !== canonicalIdentity(schema, normalize(schema, {}, `${name} input`)))
|
|
269
|
+
throw invalid("input", `${name} has lifetime "host": its one object has the empty input`);
|
|
270
|
+
principal = this.principal;
|
|
271
|
+
}
|
|
272
|
+
return { address: `${name}\u0000${identity}\u0000${this.#principalPart(principal)}`, normalized, principal };
|
|
273
|
+
}
|
|
274
|
+
#principalPart(principal) {
|
|
275
|
+
return this.isSelf(principal) ? "host" : `p:${this.#principalKey(principal)}`;
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* The key of a remote binding: type, input identity (fields equal to their defaults omitted, as for local objects),
|
|
279
|
+
* principal key and the consumer's contract fingerprint. No host name: the type and the input decide where it lives.
|
|
280
|
+
*/
|
|
281
|
+
#remoteKey(type, normalized, principal) {
|
|
282
|
+
const identity = canonicalIdentity(type.input, normalized);
|
|
283
|
+
return `@${type.name}\u0000${identity}\u0000${this.#principalPart(principal)}\u0000${type.fingerprint}`;
|
|
194
284
|
}
|
|
195
285
|
acquire(type, input, options, owner, from, candidate = false) {
|
|
196
|
-
|
|
197
|
-
|
|
286
|
+
const elsewhere = this.#elsewhere(type, input);
|
|
287
|
+
if (elsewhere !== undefined)
|
|
288
|
+
return this.#acquireRemote(type, elsewhere.at, elsewhere.input, options, owner);
|
|
198
289
|
const service = this.#bindingService(type.name, candidate);
|
|
199
290
|
if (service === undefined)
|
|
200
291
|
throw unknownType(type.name, this.name);
|
|
201
292
|
bind(service.contract, service.options.accepts, type);
|
|
202
|
-
const principal = options?.principal ?? this.principal;
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
if (
|
|
293
|
+
const { address, normalized, principal } = this.#locate(service, input, options?.principal ?? this.principal);
|
|
294
|
+
// The provider's own placement decides, never where a request says it is going (a client picks that).
|
|
295
|
+
const placement = service.contract.description.host;
|
|
296
|
+
if (placement !== undefined && resolveHost(type.name, placement, normalized) !== this.name) {
|
|
297
|
+
throw invalid("placement", `${type.name} with this input lives on ${resolveHost(type.name, placement, normalized)}, not ${this.name}`);
|
|
298
|
+
}
|
|
299
|
+
let object = this.objects.get(address);
|
|
300
|
+
if (from !== undefined && object !== undefined && (object === from || reaches(object, from))) {
|
|
301
|
+
throw invalid("cycle", `${from.address} -> ${address} would create a ref cycle`);
|
|
302
|
+
}
|
|
303
|
+
if (object === undefined) {
|
|
210
304
|
if (this.gating?.has(type.name)) {
|
|
211
305
|
throw new ChordError("upgrade_conflict", `${type.name} is being upgraded; retry`, { reason: "gate" });
|
|
212
306
|
}
|
|
213
307
|
const dying = this.#dying.get(address);
|
|
214
308
|
if (dying?.status === "disposed")
|
|
215
309
|
this.#dying.delete(address);
|
|
216
|
-
|
|
310
|
+
object = new ObjectImpl(this, {
|
|
217
311
|
address,
|
|
218
312
|
typeName: type.name,
|
|
219
313
|
input: normalized,
|
|
220
314
|
principal,
|
|
221
|
-
|
|
315
|
+
hostLifetime: hostLifetime(service),
|
|
222
316
|
});
|
|
223
|
-
this.
|
|
224
|
-
// A new
|
|
225
|
-
|
|
317
|
+
this.objects.set(address, object);
|
|
318
|
+
// A new object for an address still being disposed waits for that cleanup (exclusive resources).
|
|
319
|
+
object.start(dying?.status === "disposing" ? dying.dispose(false) : undefined);
|
|
226
320
|
}
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
return
|
|
321
|
+
const ref = new RefImpl(object, type, owner);
|
|
322
|
+
object.addRef(ref);
|
|
323
|
+
return ref;
|
|
324
|
+
}
|
|
325
|
+
forget(object) {
|
|
326
|
+
if (this.objects.get(object.address) === object)
|
|
327
|
+
this.objects.delete(object.address);
|
|
328
|
+
if (object.status === "disposing")
|
|
329
|
+
this.#dying.set(object.address, object);
|
|
230
330
|
}
|
|
231
|
-
|
|
232
|
-
if (this.
|
|
233
|
-
this.
|
|
234
|
-
if (record.status === "disposing")
|
|
235
|
-
this.#dying.set(record.address, record);
|
|
331
|
+
disposed(object) {
|
|
332
|
+
if (this.#dying.get(object.address) === object)
|
|
333
|
+
this.#dying.delete(object.address);
|
|
236
334
|
}
|
|
237
|
-
touch(rootId,
|
|
335
|
+
touch(rootId, object) {
|
|
238
336
|
let touched = this.#touches.get(rootId);
|
|
239
337
|
if (touched === undefined) {
|
|
240
338
|
touched = new Set();
|
|
241
339
|
this.#touches.set(rootId, touched);
|
|
242
340
|
}
|
|
243
|
-
touched.add(
|
|
341
|
+
touched.add(object);
|
|
244
342
|
}
|
|
245
343
|
// ─── roots ───
|
|
246
344
|
async root(fn, options = {}) {
|
|
247
345
|
// The incarnation keeps root ids unique across restarts of a host with the same name.
|
|
248
346
|
const rootId = `${this.name}#${this.#incarnation}:${++this.#rootSeq}`;
|
|
249
347
|
this.#local.add(rootId);
|
|
250
|
-
return this.joinRoot(rootId, options.
|
|
348
|
+
return this.joinRoot(rootId, options.codeVersion ?? this.current.id, fn, options);
|
|
251
349
|
}
|
|
252
350
|
/**
|
|
253
|
-
* Runs `fn` inside root `rootId` of `
|
|
254
|
-
* runs here. A root that started here ends when its last call here settles, even if it went out and came back
|
|
255
|
-
* over a link meanwhile. A foreign root (started elsewhere, arriving `from` a link) keeps its leases on
|
|
351
|
+
* Runs `fn` inside root `rootId` of `codeVersion`. A root counts as running here (for retirement) while any of its
|
|
352
|
+
* calls runs here. A root that started here ends when its last call here settles, even if it went out and came back
|
|
353
|
+
* over a link meanwhile. A foreign root (started elsewhere, arriving `from` a link) keeps its leases on objects
|
|
256
354
|
* until every link it came over announced its end (`root_end`) or closed, so its re-entry stays grandfathered.
|
|
257
355
|
*/
|
|
258
|
-
async joinRoot(rootId,
|
|
259
|
-
const
|
|
260
|
-
if (
|
|
356
|
+
async joinRoot(rootId, codeVersionId, fn, options = {}) {
|
|
357
|
+
const codeVersion = this.codeVersions.get(codeVersionId);
|
|
358
|
+
if (codeVersion === undefined) {
|
|
261
359
|
this.#local.delete(rootId);
|
|
262
|
-
throw new ChordError("superseded", `
|
|
360
|
+
throw new ChordError("superseded", `code version ${codeVersionId} is not retained by ${this.name}`);
|
|
263
361
|
}
|
|
264
362
|
if (options.from !== undefined && !this.#local.has(rootId)) {
|
|
265
363
|
let links = this.#foreign.get(rootId);
|
|
@@ -269,11 +367,12 @@ export class HostImpl {
|
|
|
269
367
|
}
|
|
270
368
|
links.add(options.from);
|
|
271
369
|
}
|
|
272
|
-
|
|
370
|
+
codeVersion.roots.set(rootId, (codeVersion.roots.get(rootId) ?? 0) + 1);
|
|
273
371
|
const ctx = new CtxImpl(this, {
|
|
274
|
-
|
|
372
|
+
// Forced retirement aborts what still runs on a code version before closing what only it used.
|
|
373
|
+
signals: options.signal === undefined ? [codeVersion.abort.signal] : [options.signal, codeVersion.abort.signal],
|
|
275
374
|
rootId,
|
|
276
|
-
|
|
375
|
+
codeVersion: codeVersion.id,
|
|
277
376
|
principal: options.principal ?? this.principal,
|
|
278
377
|
});
|
|
279
378
|
try {
|
|
@@ -281,14 +380,15 @@ export class HostImpl {
|
|
|
281
380
|
}
|
|
282
381
|
finally {
|
|
283
382
|
ctx.close();
|
|
284
|
-
const count = (
|
|
383
|
+
const count = (codeVersion.roots.get(rootId) ?? 1) - 1;
|
|
285
384
|
if (count > 0)
|
|
286
|
-
|
|
385
|
+
codeVersion.roots.set(rootId, count);
|
|
287
386
|
else {
|
|
288
|
-
|
|
387
|
+
codeVersion.roots.delete(rootId);
|
|
289
388
|
if (!this.#foreign.has(rootId))
|
|
290
389
|
this.endRoot(rootId);
|
|
291
390
|
this.maybeRetire();
|
|
391
|
+
this.#retirementChanged();
|
|
292
392
|
}
|
|
293
393
|
}
|
|
294
394
|
}
|
|
@@ -305,8 +405,8 @@ export class HostImpl {
|
|
|
305
405
|
endRoot(rootId) {
|
|
306
406
|
this.#local.delete(rootId);
|
|
307
407
|
this.#foreign.delete(rootId);
|
|
308
|
-
for (const
|
|
309
|
-
|
|
408
|
+
for (const object of this.#touches.get(rootId) ?? [])
|
|
409
|
+
object.rootEnded(rootId);
|
|
310
410
|
this.#touches.delete(rootId);
|
|
311
411
|
for (const connection of this.#rootLinks.get(rootId) ?? [])
|
|
312
412
|
connection.send({ t: "root_end", rootId });
|
|
@@ -320,7 +420,7 @@ export class HostImpl {
|
|
|
320
420
|
const links = this.#foreign.get(rootId);
|
|
321
421
|
if (links === undefined || !links.delete(from) || links.size > 0)
|
|
322
422
|
return;
|
|
323
|
-
const running = [...this.
|
|
423
|
+
const running = [...this.codeVersions.values()].some((codeVersion) => codeVersion.roots.has(rootId));
|
|
324
424
|
// Still running here (a call in flight): the lease ends when it settles.
|
|
325
425
|
if (running)
|
|
326
426
|
this.#foreign.delete(rootId);
|
|
@@ -328,48 +428,71 @@ export class HostImpl {
|
|
|
328
428
|
this.endRoot(rootId);
|
|
329
429
|
}
|
|
330
430
|
/**
|
|
331
|
-
* Retires the previous
|
|
332
|
-
* roots span hosts, and an old root on the server may still call in.
|
|
431
|
+
* Retires the previous code version once no local roots of it remain. A child also waits for its parent to retire
|
|
432
|
+
* it: roots span hosts, and an old root on the server may still call in.
|
|
333
433
|
*/
|
|
334
434
|
maybeRetire() {
|
|
435
|
+
if (this.retirable())
|
|
436
|
+
this.retire();
|
|
437
|
+
}
|
|
438
|
+
/** The previous code version has no roots left here and, on a child host, the parent has retired it. */
|
|
439
|
+
retirable() {
|
|
335
440
|
const previous = this.previous;
|
|
336
441
|
if (previous === undefined || previous.roots.size > 0)
|
|
337
|
-
return;
|
|
338
|
-
|
|
339
|
-
return;
|
|
340
|
-
this.retire();
|
|
442
|
+
return false;
|
|
443
|
+
return this.kind === "client" || this.upstream === undefined || previous.id <= this.#retireAllowed;
|
|
341
444
|
}
|
|
342
|
-
allowRetire(
|
|
343
|
-
this.#retireAllowed = Math.max(this.#retireAllowed,
|
|
445
|
+
allowRetire(codeVersion) {
|
|
446
|
+
this.#retireAllowed = Math.max(this.#retireAllowed, codeVersion);
|
|
344
447
|
this.maybeRetire();
|
|
448
|
+
this.#retirementChanged();
|
|
449
|
+
}
|
|
450
|
+
/** Calls `watcher` whenever a root ends, the parent allows a retirement, or a code version retires. */
|
|
451
|
+
watchRetirement(watcher) {
|
|
452
|
+
this.#retirementWatchers.add(watcher);
|
|
453
|
+
return () => this.#retirementWatchers.delete(watcher);
|
|
454
|
+
}
|
|
455
|
+
#retirementChanged() {
|
|
456
|
+
for (const watcher of [...this.#retirementWatchers])
|
|
457
|
+
watcher();
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Tells a child that is now in step which code versions this host retired before: every id below the oldest one it
|
|
461
|
+
* retains (ids only grow). Without it, a child reconciled after those retirements would never hear a `retire` frame for
|
|
462
|
+
* its own previous code version and would wait for the retire deadline at every reload.
|
|
463
|
+
*/
|
|
464
|
+
announceRetired(connection) {
|
|
465
|
+
const retired = (this.previous ?? this.current).id - 1;
|
|
466
|
+
if (retired > 0)
|
|
467
|
+
connection.send({ t: "retire", codeVersion: retired });
|
|
345
468
|
}
|
|
346
|
-
/** Drops the previous
|
|
469
|
+
/** Drops the previous code version: its implementations and the declarations only it used. */
|
|
347
470
|
retire() {
|
|
348
471
|
const previous = this.previous;
|
|
349
472
|
if (previous === undefined)
|
|
350
473
|
return;
|
|
351
474
|
this.previous = undefined;
|
|
352
|
-
this.
|
|
353
|
-
for (const
|
|
354
|
-
this.
|
|
475
|
+
this.codeVersions.delete(previous.id);
|
|
476
|
+
for (const object of [...this.objects.values()])
|
|
477
|
+
this.retireImplementation(object, previous.id);
|
|
355
478
|
for (const connection of this.connections) {
|
|
356
479
|
if (connection.role === "downstream" && connection.peer?.kind === "host")
|
|
357
|
-
connection.send({ t: "retire",
|
|
480
|
+
connection.send({ t: "retire", codeVersion: previous.id });
|
|
358
481
|
}
|
|
359
|
-
this
|
|
482
|
+
this.#retirementChanged();
|
|
360
483
|
}
|
|
361
484
|
/**
|
|
362
|
-
* Drops
|
|
363
|
-
* (reverse open order) before its
|
|
485
|
+
* Drops an object's implementation for `codeVersion` and what only that implementation declared, in disposal order:
|
|
486
|
+
* resources close (reverse open order) before its references are released.
|
|
364
487
|
*/
|
|
365
|
-
|
|
366
|
-
const installed =
|
|
367
|
-
|
|
368
|
-
if (installed === undefined || installed.decls ===
|
|
488
|
+
retireImplementation(object, codeVersion) {
|
|
489
|
+
const installed = object.implementations.get(codeVersion);
|
|
490
|
+
object.implementations.delete(codeVersion);
|
|
491
|
+
if (installed === undefined || installed.decls === object.decls)
|
|
369
492
|
return;
|
|
370
|
-
const kept =
|
|
493
|
+
const kept = object.allDecls();
|
|
371
494
|
const keep = (pick) => new Set([...kept].flatMap((decls) => [...pick(decls)]));
|
|
372
|
-
const
|
|
495
|
+
const refs = keep((decls) => decls.refs.values());
|
|
373
496
|
const resources = keep((decls) => decls.resources.values());
|
|
374
497
|
const derives = keep((decls) => decls.derives.values());
|
|
375
498
|
for (const derived of installed.decls.derives.values())
|
|
@@ -377,21 +500,21 @@ export class HostImpl {
|
|
|
377
500
|
derived.detach();
|
|
378
501
|
const closing = [...installed.decls.resources.values()]
|
|
379
502
|
.filter((resource) => !resources.has(resource))
|
|
380
|
-
.sort((a, b) =>
|
|
381
|
-
const releasing = [...installed.decls.
|
|
382
|
-
|
|
503
|
+
.sort((a, b) => object.openOrder.indexOf(b) - object.openOrder.indexOf(a));
|
|
504
|
+
const releasing = [...installed.decls.refs.values()].filter((ref) => !refs.has(ref));
|
|
505
|
+
object.refreshSchemas();
|
|
383
506
|
runAll([
|
|
384
|
-
...closing.map((resource) => () => resource.close(
|
|
385
|
-
...releasing.map((
|
|
386
|
-
], `${
|
|
507
|
+
...closing.map((resource) => () => resource.close(object.openOrder)),
|
|
508
|
+
...releasing.map((ref) => () => ref.release()),
|
|
509
|
+
], `${object.address}: retiring code version ${codeVersion} failed`).catch((error) => this.report(error));
|
|
387
510
|
}
|
|
388
511
|
// ─── registries ───
|
|
389
512
|
registry(registry) {
|
|
390
513
|
const table = (ctx) => {
|
|
391
|
-
const
|
|
392
|
-
if (
|
|
393
|
-
throw new ChordError("superseded", `
|
|
394
|
-
return (
|
|
514
|
+
const codeVersion = ctx === undefined ? this.current : this.codeVersions.get(ctx.codeVersion);
|
|
515
|
+
if (codeVersion === undefined)
|
|
516
|
+
throw new ChordError("superseded", `code version ${ctx?.codeVersion} is retired`);
|
|
517
|
+
return (codeVersion.tables.get(registry.name) ?? EMPTY_TABLE);
|
|
395
518
|
};
|
|
396
519
|
return {
|
|
397
520
|
table,
|
|
@@ -409,10 +532,10 @@ export class HostImpl {
|
|
|
409
532
|
},
|
|
410
533
|
};
|
|
411
534
|
}
|
|
412
|
-
/** Builds registry tables from (
|
|
535
|
+
/** Builds registry tables from (object, declarations) pairs. Throws registry_conflict. */
|
|
413
536
|
buildTables(entries) {
|
|
414
537
|
const byRegistry = new Map();
|
|
415
|
-
for (const [
|
|
538
|
+
for (const [object, decls] of entries) {
|
|
416
539
|
for (const { registry, key, value } of decls.registrations.values()) {
|
|
417
540
|
let entries = byRegistry.get(registry.name);
|
|
418
541
|
if (entries === undefined) {
|
|
@@ -420,10 +543,10 @@ export class HostImpl {
|
|
|
420
543
|
byRegistry.set(registry.name, entries);
|
|
421
544
|
}
|
|
422
545
|
const existing = entries.get(key);
|
|
423
|
-
if (existing !== undefined && existing.
|
|
424
|
-
throw invalid("registry_conflict", `${registry.name}: ${key} registered by ${existing.
|
|
546
|
+
if (existing !== undefined && existing.object !== object && registry.conflict === "error") {
|
|
547
|
+
throw invalid("registry_conflict", `${registry.name}: ${key} registered by ${existing.object.address} and ${object.address}`);
|
|
425
548
|
}
|
|
426
|
-
entries.set(key, { value,
|
|
549
|
+
entries.set(key, { value, object });
|
|
427
550
|
let order = this.#order.get(registry.name);
|
|
428
551
|
if (order === undefined) {
|
|
429
552
|
order = new Map();
|
|
@@ -442,25 +565,25 @@ export class HostImpl {
|
|
|
442
565
|
return tables;
|
|
443
566
|
}
|
|
444
567
|
/**
|
|
445
|
-
* The service a
|
|
568
|
+
* The service a reference binds against: the current one, or for a prepare rerun (`candidate`) the running reload's
|
|
446
569
|
* pending one, so consumer and provider of a breaking change can reload in one batch.
|
|
447
570
|
*/
|
|
448
571
|
#bindingService(typeName, candidate) {
|
|
449
572
|
return (candidate ? this.pending?.services.get(typeName) : undefined) ?? this.service(typeName);
|
|
450
573
|
}
|
|
451
|
-
/** Live
|
|
574
|
+
/** Live objects with their current declarations; objects waiting for a reload's commit only with `candidates`. */
|
|
452
575
|
liveDecls(candidates = false) {
|
|
453
|
-
return [...this.
|
|
454
|
-
.filter((
|
|
455
|
-
.map((
|
|
576
|
+
return [...this.objects.values()]
|
|
577
|
+
.filter((object) => object.status === "live" && (candidates || !object.deferredRuntime))
|
|
578
|
+
.map((object) => [object, object.decls]);
|
|
456
579
|
}
|
|
457
|
-
checkRegistrations(
|
|
580
|
+
checkRegistrations(object, decls) {
|
|
458
581
|
if (decls.registrations.size === 0)
|
|
459
582
|
return;
|
|
460
|
-
this.buildTables([...this.liveDecls().filter(([other]) => other !==
|
|
583
|
+
this.buildTables([...this.liveDecls().filter(([other]) => other !== object), [object, decls]]);
|
|
461
584
|
}
|
|
462
585
|
registriesChanged(including) {
|
|
463
|
-
const entries = this.liveDecls().filter(([
|
|
586
|
+
const entries = this.liveDecls().filter(([object]) => object !== including);
|
|
464
587
|
if (including !== undefined)
|
|
465
588
|
entries.push([including, including.decls]);
|
|
466
589
|
this.installTables(this.current, this.buildTables(entries));
|
|
@@ -470,11 +593,11 @@ export class HostImpl {
|
|
|
470
593
|
this.failed ??= error;
|
|
471
594
|
this.report(error);
|
|
472
595
|
}
|
|
473
|
-
/** Installs tables for `
|
|
474
|
-
installTables(
|
|
475
|
-
const before =
|
|
476
|
-
|
|
477
|
-
if (
|
|
596
|
+
/** Installs tables for `codeVersion`; subscribers hear about changed tables of the current code version (all tables when `all`). */
|
|
597
|
+
installTables(codeVersion, tables, all = false) {
|
|
598
|
+
const before = codeVersion.tables;
|
|
599
|
+
codeVersion.tables = tables;
|
|
600
|
+
if (codeVersion !== this.current)
|
|
478
601
|
return;
|
|
479
602
|
for (const [name, listeners] of this.#subscribers) {
|
|
480
603
|
const next = tables.get(name) ?? EMPTY_TABLE;
|
|
@@ -484,7 +607,7 @@ export class HostImpl {
|
|
|
484
607
|
enqueue(() => {
|
|
485
608
|
for (const listener of current) {
|
|
486
609
|
try {
|
|
487
|
-
listener(next,
|
|
610
|
+
listener(next, codeVersion.id);
|
|
488
611
|
}
|
|
489
612
|
catch (error) {
|
|
490
613
|
this.report(error);
|
|
@@ -494,7 +617,12 @@ export class HostImpl {
|
|
|
494
617
|
}
|
|
495
618
|
}
|
|
496
619
|
// ─── public API ───
|
|
497
|
-
get(type, input, options) {
|
|
620
|
+
async get(type, input, options) {
|
|
621
|
+
const ref = this.acquire(type, input, options, "external");
|
|
622
|
+
await awaitReady(ref, options?.signal === undefined ? [] : [options.signal], this.readyTimeout);
|
|
623
|
+
return ref.proxy;
|
|
624
|
+
}
|
|
625
|
+
hold(type, input, options) {
|
|
498
626
|
return this.acquire(type, input, options, "external").proxy;
|
|
499
627
|
}
|
|
500
628
|
async invalidatePrincipal(match) {
|
|
@@ -507,7 +635,7 @@ export class HostImpl {
|
|
|
507
635
|
// Relays under the old principal close at once, including those still resolving their route.
|
|
508
636
|
connection.revoke(connection.restamp?.(connection.principal));
|
|
509
637
|
}
|
|
510
|
-
// Patterns propagate to child hosts, which dispose their own matching
|
|
638
|
+
// Patterns propagate to child hosts, which dispose their own matching objects (e.g. relayed guests).
|
|
511
639
|
if (typeof match !== "function") {
|
|
512
640
|
for (const connection of this.connections) {
|
|
513
641
|
if (connection.role === "downstream" && connection.peer.kind === "host") {
|
|
@@ -515,7 +643,7 @@ export class HostImpl {
|
|
|
515
643
|
}
|
|
516
644
|
}
|
|
517
645
|
}
|
|
518
|
-
const matching = [...this.
|
|
646
|
+
const matching = [...this.objects.values()].filter((object) => !this.isSelf(object.principal) && matches(object.principal));
|
|
519
647
|
await disposeHoldersFirst(matching, reason, "invalidatePrincipal: disposal failed");
|
|
520
648
|
}
|
|
521
649
|
// ─── links ───
|
|
@@ -528,54 +656,93 @@ export class HostImpl {
|
|
|
528
656
|
role: "downstream",
|
|
529
657
|
principal: options.principal,
|
|
530
658
|
peer: { kind, name: kind === "host" ? options.name : undefined },
|
|
659
|
+
// Holds over a client link carry its id as their origin (also when relayed), so hosts can tell clients apart.
|
|
660
|
+
...(kind === "client" ? { originId: `${this.#incarnation}:${++this.#originSeq}` } : {}),
|
|
531
661
|
});
|
|
532
662
|
}
|
|
533
|
-
connect(link, options
|
|
663
|
+
connect(link, options) {
|
|
664
|
+
if (!("principal" in options))
|
|
665
|
+
throw new TypeError("connect needs `principal`: what the parent's own requests act as");
|
|
534
666
|
if (this.upstream?.open)
|
|
535
667
|
this.upstream.link.close("replaced");
|
|
536
668
|
const connection = this.#addConnection(link, {
|
|
537
669
|
...options,
|
|
538
670
|
role: "upstream",
|
|
539
|
-
principal:
|
|
671
|
+
principal: options.principal,
|
|
540
672
|
peer: { kind: "host", name: undefined },
|
|
541
673
|
});
|
|
542
674
|
this.upstream = connection;
|
|
543
|
-
//
|
|
544
|
-
for (const
|
|
545
|
-
if (
|
|
546
|
-
connection.hold(
|
|
675
|
+
// Bindings that lost their link re-hold over the new one (`again`: they keep their values meanwhile).
|
|
676
|
+
for (const binding of this.bindings.values())
|
|
677
|
+
if (binding.hold === undefined && !binding.ended)
|
|
678
|
+
connection.hold(binding, binding.applied);
|
|
547
679
|
}
|
|
548
680
|
#addConnection(link, config) {
|
|
549
681
|
const connection = new Connection(this, link, config);
|
|
550
|
-
|
|
682
|
+
// A link that was already closed closed the connection in its constructor: keeping it would route to it forever.
|
|
683
|
+
if (connection.open)
|
|
684
|
+
this.connections.add(connection);
|
|
551
685
|
return connection;
|
|
552
686
|
}
|
|
553
687
|
route(prefix, route) {
|
|
688
|
+
if (typeof route.principal !== "function")
|
|
689
|
+
throw new TypeError(`route ${prefix} needs \`principal\`: what the routed hosts act as when they call back`);
|
|
554
690
|
this.#routes.set(prefix, route);
|
|
555
691
|
}
|
|
556
692
|
/**
|
|
557
|
-
* A child host announced its
|
|
558
|
-
* reload of its modules to this host's
|
|
693
|
+
* A child host announced its code version. In step: usable. Behind: fenced (nothing is routed to it) and reconciled
|
|
694
|
+
* by a reload of its modules to this host's code version, never by renumbering what it runs. Ahead: impossible,
|
|
695
|
+
* refused.
|
|
559
696
|
*/
|
|
560
|
-
peerHello(connection,
|
|
561
|
-
if (
|
|
562
|
-
connection.
|
|
697
|
+
peerHello(connection, codeVersion) {
|
|
698
|
+
if (codeVersion > this.current.id) {
|
|
699
|
+
this.report(new ChordError("invalid", `${connection.name} runs code version ${codeVersion}, ahead of ${this.current.id}`));
|
|
700
|
+
connection.link.close("code version ahead of parent");
|
|
563
701
|
return;
|
|
564
702
|
}
|
|
565
|
-
if (
|
|
566
|
-
|
|
567
|
-
|
|
703
|
+
if (this.reloading) {
|
|
704
|
+
// Not a participant of the running reload (participants are fixed when it starts): in step now, it would miss
|
|
705
|
+
// the commit. Decided when the reload ends. A child already behind is fenced now, so the calls its roots make
|
|
706
|
+
// meanwhile are admitted in this host's code version (never relabeled) instead of being `superseded`.
|
|
707
|
+
connection.peerCodeVersion = codeVersion;
|
|
708
|
+
if (codeVersion < this.current.id)
|
|
709
|
+
connection.fencedCodeVersions.add(codeVersion);
|
|
710
|
+
this.#deferredHellos.set(connection, codeVersion);
|
|
568
711
|
return;
|
|
569
712
|
}
|
|
570
|
-
|
|
571
|
-
|
|
713
|
+
if (codeVersion === this.current.id) {
|
|
714
|
+
connection.markSynced(codeVersion);
|
|
715
|
+
this.announceRetired(connection);
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
connection.peerCodeVersion = codeVersion;
|
|
719
|
+
connection.fencedCodeVersions.add(codeVersion);
|
|
572
720
|
reconcileChild(this, connection).catch((error) => {
|
|
573
721
|
this.report(error);
|
|
574
722
|
connection.link.close("reconciliation failed");
|
|
575
723
|
});
|
|
576
724
|
}
|
|
725
|
+
/** The host's reload slot was freed: children that said hello meanwhile are synced or reconciled now. */
|
|
726
|
+
reloadEnded() {
|
|
727
|
+
this.reloading = false;
|
|
728
|
+
const deferred = [...this.#deferredHellos];
|
|
729
|
+
this.#deferredHellos.clear();
|
|
730
|
+
for (const [connection, codeVersion] of deferred)
|
|
731
|
+
if (connection.open)
|
|
732
|
+
this.peerHello(connection, codeVersion);
|
|
733
|
+
for (const resolve of this.#reloadWaiters.splice(0))
|
|
734
|
+
resolve();
|
|
735
|
+
this.maybeRetire();
|
|
736
|
+
}
|
|
737
|
+
/** Resolves once no reload runs on this host. */
|
|
738
|
+
whenReloadEnded() {
|
|
739
|
+
if (!this.reloading)
|
|
740
|
+
return Promise.resolve();
|
|
741
|
+
return new Promise((resolve) => this.#reloadWaiters.push(resolve));
|
|
742
|
+
}
|
|
577
743
|
connectionClosed(connection) {
|
|
578
744
|
this.connections.delete(connection);
|
|
745
|
+
this.#deferredHellos.delete(connection);
|
|
579
746
|
// Foreign roots that came over this link will never announce their end over it.
|
|
580
747
|
for (const [rootId, links] of [...this.#foreign])
|
|
581
748
|
if (links.has(connection))
|
|
@@ -587,46 +754,78 @@ export class HostImpl {
|
|
|
587
754
|
this.#childReloads.parentLost();
|
|
588
755
|
}
|
|
589
756
|
}
|
|
590
|
-
|
|
591
|
-
|
|
757
|
+
/** The last reference to `binding` was released: it lingers if its type does (`HostOptions.linger`), else closes. */
|
|
758
|
+
bindingIdle(binding) {
|
|
759
|
+
const ms = Object.hasOwn(this.#lingerTypes, binding.contract.name)
|
|
760
|
+
? this.#lingerTypes[binding.contract.name]
|
|
761
|
+
: 0;
|
|
762
|
+
// A binding that never had a shape has nothing worth keeping.
|
|
763
|
+
if (ms <= 0 || binding.ended || !binding.applied || this.#lingerCap <= 0) {
|
|
764
|
+
binding.close();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
binding.lingerTimer = setTimeout(() => binding.close(), ms);
|
|
768
|
+
binding.lingerTimer.unref?.();
|
|
769
|
+
this.#lingering.set(binding.key, binding);
|
|
770
|
+
for (const oldest of this.#lingering.values()) {
|
|
771
|
+
if (this.#lingering.size <= this.#lingerCap)
|
|
772
|
+
break;
|
|
773
|
+
oldest.close();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
forgetBinding(binding) {
|
|
777
|
+
if (this.bindings.get(binding.key) === binding)
|
|
778
|
+
this.bindings.delete(binding.key);
|
|
779
|
+
if (this.#lingering.get(binding.key) === binding)
|
|
780
|
+
this.#lingering.delete(binding.key);
|
|
592
781
|
}
|
|
593
782
|
/** A downstream host connection by its configured name (never by what a peer announced). */
|
|
594
783
|
#childNamed(name) {
|
|
595
|
-
return [...this.connections].find((connection) => connection.
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
784
|
+
return [...this.connections].find((connection) => connection.open &&
|
|
785
|
+
connection.role === "downstream" &&
|
|
786
|
+
connection.peer.kind === "host" &&
|
|
787
|
+
connection.peer.name === name);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* A reference to an object on host `at`: a view onto the binding for its key, created (and held) if there is none.
|
|
791
|
+
* `input` is normalized with `type` (the hold carries it so).
|
|
792
|
+
*/
|
|
793
|
+
#acquireRemote(type, at, input, options, owner) {
|
|
794
|
+
const principal = options?.principal === undefined || this.isSelf(options.principal) ? undefined : options.principal;
|
|
795
|
+
const key = this.#remoteKey(type, input, principal ?? this.principal);
|
|
796
|
+
let binding = this.bindings.get(key);
|
|
797
|
+
const fresh = binding === undefined || binding.ended;
|
|
798
|
+
if (binding === undefined || binding.ended) {
|
|
799
|
+
binding = new RemoteBinding(this, { key, contract: type, at, input, principal });
|
|
800
|
+
this.bindings.set(key, binding);
|
|
801
|
+
}
|
|
802
|
+
const ref = new RemoteRef(binding, owner, options?.state !== false);
|
|
803
|
+
this.#lingering.delete(key);
|
|
804
|
+
binding.addView(ref);
|
|
805
|
+
if (fresh)
|
|
806
|
+
this.#hold(binding);
|
|
807
|
+
return ref;
|
|
808
|
+
}
|
|
809
|
+
/** Holds `binding` over the link to its host: the parent's, a child's, or one a route resolves. */
|
|
810
|
+
#hold(binding) {
|
|
611
811
|
const bindWhenSynced = (connection) => {
|
|
612
812
|
connection.then((target) => {
|
|
613
|
-
if (
|
|
614
|
-
target.hold(
|
|
615
|
-
}, (error) =>
|
|
813
|
+
if (!binding.ended && binding.hold === undefined)
|
|
814
|
+
target.hold(binding, false);
|
|
815
|
+
}, (error) => binding.gone(error));
|
|
616
816
|
};
|
|
617
|
-
const direct = this.#childNamed(at);
|
|
817
|
+
const direct = this.#childNamed(binding.at);
|
|
618
818
|
if (this.upstream !== undefined)
|
|
619
|
-
this.upstream.hold(
|
|
819
|
+
this.upstream.hold(binding, false);
|
|
620
820
|
else if (direct !== undefined)
|
|
621
821
|
bindWhenSynced(direct.whenSynced().then(() => direct));
|
|
622
822
|
else {
|
|
623
|
-
const route = this.#routeFor(at);
|
|
823
|
+
const route = this.#routeFor(binding.at);
|
|
624
824
|
if (route === undefined)
|
|
625
|
-
|
|
825
|
+
binding.gone(new ChordError("unavailable", `no route to ${binding.at}`));
|
|
626
826
|
else
|
|
627
|
-
bindWhenSynced(this.#resolve(route, at, principal ?? this.principal));
|
|
827
|
+
bindWhenSynced(this.#resolve(route, binding.at, binding.principal ?? this.principal));
|
|
628
828
|
}
|
|
629
|
-
return handle;
|
|
630
829
|
}
|
|
631
830
|
#routeFor(hostName) {
|
|
632
831
|
let best;
|
|
@@ -654,7 +853,7 @@ export class HostImpl {
|
|
|
654
853
|
let connection = [...this.connections].find((candidate) => candidate.link === link);
|
|
655
854
|
connection ??= this.#addConnection(link, {
|
|
656
855
|
role: "downstream",
|
|
657
|
-
principal: route.principal
|
|
856
|
+
principal: route.principal(hostName),
|
|
658
857
|
peer: { kind: "host", name: hostName },
|
|
659
858
|
});
|
|
660
859
|
await connection.whenSynced();
|
|
@@ -663,12 +862,16 @@ export class HostImpl {
|
|
|
663
862
|
return connection;
|
|
664
863
|
}
|
|
665
864
|
/**
|
|
666
|
-
* Relays a
|
|
865
|
+
* Relays a reference from `relay.origin` to the host it names. Relaying needs a route: it is where authorization
|
|
667
866
|
* lives. The origin's authorization generation is re-checked after every await and right before forwarding, so a
|
|
668
867
|
* revocation during resolution wins.
|
|
669
868
|
*/
|
|
670
869
|
async relay(relay) {
|
|
671
870
|
const at = relay.frame.at;
|
|
871
|
+
// The hold names its target; its contract token says where the type lives. They must agree.
|
|
872
|
+
const placement = contractFromDescription(relay.frame.token).description.host;
|
|
873
|
+
if (placement === undefined || resolveHost(relay.frame.token.name, placement, relay.frame.input) !== at)
|
|
874
|
+
throw invalid("placement", `${relay.frame.token.name} with this input does not live on ${at}`);
|
|
672
875
|
const route = this.#routeFor(at);
|
|
673
876
|
if (route === undefined) {
|
|
674
877
|
throw this.#childNamed(at) === undefined
|
|
@@ -681,7 +884,7 @@ export class HostImpl {
|
|
|
681
884
|
relay.open(down);
|
|
682
885
|
}
|
|
683
886
|
reload(plugins, options = {}) {
|
|
684
|
-
// Client-local facet
|
|
887
|
+
// Client-local facet code versions never cross the parent link; only child hosts require cluster coordination.
|
|
685
888
|
if (this.kind !== "client" && this.upstream !== undefined) {
|
|
686
889
|
return Promise.reject(new ChordError("unavailable", "reloads are coordinated by the server", { reason: "not_coordinator" }));
|
|
687
890
|
}
|
|
@@ -701,19 +904,23 @@ export class HostImpl {
|
|
|
701
904
|
this.remoteRemoved[prefix] = [...new Set([...(this.remoteRemoved[prefix] ?? []), ...removed])].filter((module) => !modules.includes(module));
|
|
702
905
|
}
|
|
703
906
|
}
|
|
704
|
-
/** The
|
|
705
|
-
|
|
706
|
-
return this.#
|
|
907
|
+
/** The code version id the next reload would commit. */
|
|
908
|
+
peekNextCodeVersion() {
|
|
909
|
+
return this.#codeVersionSeq + 1;
|
|
707
910
|
}
|
|
708
911
|
/**
|
|
709
|
-
* Disposes every
|
|
710
|
-
*
|
|
912
|
+
* Disposes every object, holders before what they reference (a resource's `close` may still use the objects its object
|
|
913
|
+
* references, local or remote), then closes the links.
|
|
711
914
|
*/
|
|
712
915
|
async close() {
|
|
713
|
-
this.
|
|
916
|
+
for (const binding of [...this.#lingering.values()])
|
|
917
|
+
binding.close();
|
|
918
|
+
this.hostObjects.clear();
|
|
919
|
+
for (const name of [...this.#facetRuns.keys()])
|
|
920
|
+
this.facetRunChanged(name, undefined);
|
|
714
921
|
const reason = new ChordError("gone", `host ${this.name} closed`);
|
|
715
922
|
try {
|
|
716
|
-
await disposeHoldersFirst([...this.
|
|
923
|
+
await disposeHoldersFirst([...this.objects.values()], reason, "host close: disposal failed");
|
|
717
924
|
}
|
|
718
925
|
finally {
|
|
719
926
|
for (const connection of [...this.connections])
|
|
@@ -722,35 +929,35 @@ export class HostImpl {
|
|
|
722
929
|
}
|
|
723
930
|
}
|
|
724
931
|
/**
|
|
725
|
-
* Force-disposes `
|
|
726
|
-
* disposed (and their resources closed) while what they
|
|
932
|
+
* Force-disposes `objects` in waves: an object goes once no object of the set that references it is left, so holders are
|
|
933
|
+
* disposed (and their resources closed) while what they reference is still live. In-host references are acyclic. Errors of
|
|
727
934
|
* every disposal are collected.
|
|
728
935
|
*/
|
|
729
|
-
async function disposeHoldersFirst(
|
|
730
|
-
const remaining = new Set(
|
|
731
|
-
const held = (
|
|
936
|
+
async function disposeHoldersFirst(objects, reason, message) {
|
|
937
|
+
const remaining = new Set(objects);
|
|
938
|
+
const held = (object) => {
|
|
732
939
|
const result = new Set();
|
|
733
|
-
for (const decls of
|
|
734
|
-
for (const
|
|
735
|
-
if (
|
|
736
|
-
result.add(
|
|
940
|
+
for (const decls of object.allDecls()) {
|
|
941
|
+
for (const ref of decls.refs.values())
|
|
942
|
+
if (ref.object !== undefined)
|
|
943
|
+
result.add(ref.object);
|
|
737
944
|
}
|
|
738
945
|
return result;
|
|
739
946
|
};
|
|
740
|
-
const
|
|
947
|
+
const refs = new Map(objects.map((object) => [object, held(object)]));
|
|
741
948
|
const errors = [];
|
|
742
949
|
while (remaining.size > 0) {
|
|
743
950
|
const heldByRemaining = new Set();
|
|
744
|
-
for (const
|
|
745
|
-
for (const target of
|
|
951
|
+
for (const object of remaining)
|
|
952
|
+
for (const target of refs.get(object) ?? [])
|
|
746
953
|
heldByRemaining.add(target);
|
|
747
|
-
let wave = [...remaining].filter((
|
|
954
|
+
let wave = [...remaining].filter((object) => !heldByRemaining.has(object));
|
|
748
955
|
// A cycle cannot happen in-host; dispose the rest together rather than loop.
|
|
749
956
|
if (wave.length === 0)
|
|
750
957
|
wave = [...remaining];
|
|
751
|
-
for (const
|
|
752
|
-
remaining.delete(
|
|
753
|
-
const results = await Promise.allSettled(wave.map((
|
|
958
|
+
for (const object of wave)
|
|
959
|
+
remaining.delete(object);
|
|
960
|
+
const results = await Promise.allSettled(wave.map((object) => object.dispose(true, reason)));
|
|
754
961
|
for (const result of results)
|
|
755
962
|
if (result.status === "rejected")
|
|
756
963
|
errors.push(result.reason);
|
|
@@ -769,25 +976,23 @@ function sameTable(a, b) {
|
|
|
769
976
|
const matchesPattern = (pattern, principal) => principal !== null &&
|
|
770
977
|
typeof principal === "object" &&
|
|
771
978
|
Object.entries(pattern).every(([key, value]) => deepEqual(principal[key], value));
|
|
772
|
-
/** Address of a remote hold, as the consumer sees it (the provider normalizes the input). */
|
|
773
|
-
const remoteAddress = (at, type, input, principal) => `@${at}\u0000${type.name}\u0000${canonicalJson(input)}\u0000${principal === undefined ? "host" : canonicalJson(principal)}`;
|
|
774
979
|
const unknownType = (type, host) => new ChordError("unavailable", `${type} is not served by ${host}`, { reason: "unknown_type" });
|
|
775
|
-
/** True if `from`
|
|
980
|
+
/** True if `from` references `to`, directly or transitively, through object-owned references on this host. */
|
|
776
981
|
function reaches(from, to) {
|
|
777
982
|
const seen = new Set();
|
|
778
983
|
const stack = [from];
|
|
779
984
|
while (stack.length > 0) {
|
|
780
|
-
const
|
|
781
|
-
if (
|
|
985
|
+
const object = stack.pop();
|
|
986
|
+
if (object === to)
|
|
782
987
|
return true;
|
|
783
|
-
if (seen.has(
|
|
988
|
+
if (seen.has(object))
|
|
784
989
|
continue;
|
|
785
|
-
seen.add(
|
|
786
|
-
// Remote
|
|
787
|
-
for (const decls of
|
|
788
|
-
for (const
|
|
789
|
-
if (
|
|
790
|
-
stack.push(
|
|
990
|
+
seen.add(object);
|
|
991
|
+
// Remote references have no object here: cycles across hosts are avoided by convention.
|
|
992
|
+
for (const decls of object.allDecls()) {
|
|
993
|
+
for (const ref of decls.refs.values())
|
|
994
|
+
if (ref.object !== undefined)
|
|
995
|
+
stack.push(ref.object);
|
|
791
996
|
}
|
|
792
997
|
}
|
|
793
998
|
return false;
|