@quo-systems/quo 0.2.9 → 0.2.11

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.
Files changed (78) hide show
  1. package/README.md +19 -10
  2. package/SPEC.md +301 -110
  3. package/dist/being/being.d.ts +2 -2
  4. package/dist/being/being.js +34 -12
  5. package/dist/being/digest.js +26 -8
  6. package/dist/being/index.d.ts +2 -2
  7. package/dist/being/index.js +2 -2
  8. package/dist/being/silence.d.ts +2 -0
  9. package/dist/being/silence.js +12 -0
  10. package/dist/being/types.d.ts +4 -2
  11. package/dist/being/types.js +25 -0
  12. package/dist/conformance/assert.js +40 -6
  13. package/dist/conformance/beings.d.ts +48 -5
  14. package/dist/conformance/beings.js +39 -8
  15. package/dist/conformance/estate.js +110 -21
  16. package/dist/conformance/index.d.ts +5 -2
  17. package/dist/conformance/index.js +165 -7
  18. package/dist/harbor/core.d.ts +5 -2
  19. package/dist/harbor/core.js +195 -45
  20. package/dist/harbor/dial.js +32 -15
  21. package/dist/harbor/index.d.ts +1 -0
  22. package/dist/harbor/index.js +3 -0
  23. package/dist/harbor/memory.d.ts +3 -3
  24. package/dist/harbor/memory.js +7 -12
  25. package/dist/harbor/reach.js +42 -17
  26. package/dist/ward/allowance.js +15 -4
  27. package/dist/ward/arithmetic.d.ts +1 -0
  28. package/dist/ward/arithmetic.js +22 -6
  29. package/dist/ward/cells.d.ts +3 -1
  30. package/dist/ward/cells.js +79 -21
  31. package/dist/ward/door.d.ts +3 -2
  32. package/dist/ward/door.js +38 -10
  33. package/dist/ward/ground.d.ts +5 -1
  34. package/dist/ward/ground.js +38 -1
  35. package/dist/ward/heirs.d.ts +2 -3
  36. package/dist/ward/heirs.js +19 -13
  37. package/dist/ward/index.d.ts +1 -0
  38. package/dist/ward/index.js +3 -0
  39. package/dist/ward/owner.d.ts +6 -27
  40. package/dist/ward/owner.js +59 -33
  41. package/dist/ward/partition.d.ts +3 -0
  42. package/dist/ward/partition.js +109 -4
  43. package/dist/ward/seal.d.ts +1 -0
  44. package/dist/ward/seal.js +41 -13
  45. package/dist/ward/stance.d.ts +7 -3
  46. package/dist/ward/stance.js +156 -66
  47. package/dist/ward/ward.d.ts +10 -0
  48. package/dist/ward/ward.js +123 -51
  49. package/package.json +4 -2
  50. package/src/being/being.ts +33 -11
  51. package/src/being/digest.ts +28 -13
  52. package/src/being/index.ts +2 -2
  53. package/src/being/silence.ts +14 -0
  54. package/src/being/types.ts +39 -5
  55. package/src/conformance/assert.ts +37 -4
  56. package/src/conformance/beings.ts +41 -10
  57. package/src/conformance/estate.ts +107 -20
  58. package/src/conformance/index.ts +188 -13
  59. package/src/harbor/core.ts +203 -46
  60. package/src/harbor/dial.ts +46 -17
  61. package/src/harbor/index.ts +3 -0
  62. package/src/harbor/memory.ts +8 -13
  63. package/src/harbor/reach.ts +47 -21
  64. package/src/ward/allowance.ts +15 -4
  65. package/src/ward/arithmetic.ts +25 -8
  66. package/src/ward/cells.ts +76 -25
  67. package/src/ward/door.ts +38 -12
  68. package/src/ward/ground.ts +52 -3
  69. package/src/ward/heirs.ts +19 -13
  70. package/src/ward/index.ts +3 -0
  71. package/src/ward/owner.ts +65 -46
  72. package/src/ward/partition.ts +109 -5
  73. package/src/ward/seal.ts +41 -12
  74. package/src/ward/stance.ts +163 -64
  75. package/src/ward/ward.ts +124 -52
  76. package/vectors/arithmetic.json +7 -0
  77. package/vectors/framing.json +30 -15
  78. package/vectors/wire.json +4 -4
@@ -17,14 +17,25 @@
17
17
  // that arrive from the wire go to an own door or a held socket and never
18
18
  // onward by request or by fallback; that one rule is the rendezvous.
19
19
  import { Ward } from '../ward/ward.js';
20
+ import { maker, entropy as random, learnPk } from '../ward/ground.js';
21
+ import { silence } from '../being/silence.js';
20
22
  import { request } from './reach.js';
21
- import { openReply, wardSignPk } from '../ward/seal.js';
23
+ import { isWardPk, openReply, wardSignPk } from '../ward/seal.js';
22
24
  import { concat, sealingPair, KEY } from '../ward/arithmetic.js';
23
25
  import { within, LATE } from '../ward/allowance.js';
24
26
  export const DEFAULT_CODE = 'classes/index.ts';
27
+ // The device's entropy, spelled once. A harbor draws it for a ward's seed at
28
+ // birth, for the lid of every probe it sends, and it hands the same function
29
+ // to every ward it boots as the ground's `random`.
25
30
  // How long a probe waits for the door behind a claim. A claim that answers
26
31
  // nothing in this time is not bound; the next announce is another chance.
27
32
  const PROBE = 10_000;
33
+ // How many pks one announce may claim, and how many are proven at once.
34
+ const ANNOUNCE = 64;
35
+ const PROVING = 8;
36
+ // How many times each reach has announced: a proof from an older announce
37
+ // binds nothing when it lands.
38
+ const announces = new WeakMap();
28
39
  export class Harbor {
29
40
  store;
30
41
  loader;
@@ -33,7 +44,10 @@ export class Harbor {
33
44
  reaches = new Map(); // the directory: foreign ward pk -> reach
34
45
  down = new Set(); // pks this harbor will not reach right now: weather, for a test
35
46
  refused = new Map(); // own ward pk -> arrivals its door would not admit. what a terrain does with it is its own
47
+ faults = new Map(); // ward name -> saves the store refused. what a terrain does with it is its own
48
+ unbooted = []; // the wards the store keeps that would not host this run, by name
36
49
  classes = {}; // classes handed in-process, beside the loader's
50
+ #saving = new Map();
37
51
  // The dialers' sockets, in the order they were dialed: where a pk nobody
38
52
  // here knows is sent, because the listener a harbor dialed is a rendezvous
39
53
  // and may hold that pk for someone else. It is a list because a harbor may
@@ -58,16 +72,73 @@ export class Harbor {
58
72
  this.store = store;
59
73
  this.loader = loader;
60
74
  }
61
- // Boot every ward the store keeps, and learn the hints.
75
+ // Boot every ward the store keeps, and learn the hints. One ward that will
76
+ // not host, a partition this kit cannot read or a loader that throws, is
77
+ // named in `unbooted` and takes nobody else down with it.
62
78
  async boot() {
63
79
  for (const name of await this.store.list()) {
64
- const kept = await this.store.load(name);
65
- if (kept)
66
- await this.host(name, kept);
80
+ try {
81
+ const kept = await this.store.load(name);
82
+ if (kept)
83
+ await this.host(name, kept);
84
+ }
85
+ catch {
86
+ this.unbooted.push(name);
87
+ }
67
88
  }
68
89
  for (const [pk, url] of Object.entries(await this.store.hints()))
69
90
  this.hint(pk, url);
70
91
  }
92
+ // The object a ward writes into, by the name it is kept under: the
93
+ // partition the harbor was handed at boot and keeps so it can save it.
94
+ // Nothing in the harbor reads it, and it is not on `Hosted`, because a
95
+ // side that holds a ward would then hold every seed in it and reach any
96
+ // being past every door. It is here for one reader, the conformance
97
+ // suite, whose census is ward state read straight and whose forgeries are
98
+ // ward state written straight, the way `MemoryHarbor.partitions` is. A
99
+ // side asks the ward.
100
+ partitionOf(name) {
101
+ return this.#saving.get(name)?.memory;
102
+ }
103
+ // ---- saving
104
+ // The ward wrote. Its partition is saved once the line is free, and once
105
+ // for every burst of writes, in the order the writes came.
106
+ #wrote(name) {
107
+ const s = this.#saving.get(name);
108
+ if (!s || s.gone)
109
+ return;
110
+ s.dirty = true;
111
+ if (s.timer === undefined)
112
+ s.timer = setTimeout(() => void this.#flush(name), 0);
113
+ }
114
+ async #flush(name) {
115
+ const s = this.#saving.get(name);
116
+ if (!s)
117
+ return;
118
+ if (s.timer !== undefined) {
119
+ clearTimeout(s.timer);
120
+ s.timer = undefined;
121
+ }
122
+ if (s.running)
123
+ return s.running;
124
+ s.running = (async () => {
125
+ while (s.dirty && !s.gone) {
126
+ s.dirty = false;
127
+ try {
128
+ await this.store.save(name, s.memory);
129
+ }
130
+ catch {
131
+ this.faults.set(name, (this.faults.get(name) ?? 0) + 1);
132
+ }
133
+ }
134
+ })();
135
+ try {
136
+ await s.running;
137
+ }
138
+ finally {
139
+ s.running = null;
140
+ }
141
+ }
71
142
  // ---- the directory
72
143
  // Bytes to one of this harbor's own doors, and the one bit the door says
73
144
  // beside its reply, counted.
@@ -103,8 +174,8 @@ export class Harbor {
103
174
  return bound?.held ? bound.reach.carry(pk, bytes) : undefined;
104
175
  }
105
176
  hint(pk, url) {
106
- if (this.reaches.get(pk)?.held)
107
- return; // a socket in hand beats a hint
177
+ if (this.reaches.has(pk))
178
+ return; // a reach proven at a door beats a hint, held or reached through a dialed line
108
179
  this.reaches.set(pk, { reach: request(url), held: false });
109
180
  }
110
181
  async remember(pk, url) {
@@ -119,13 +190,12 @@ export class Harbor {
119
190
  // signed by the ward key. Only the holder of that ward's seed can write
120
191
  // that reply, the lid is fresh so nothing replays, and a box that does not
121
192
  // open writes nothing at the ward. This is the one box a harbor ever opens,
122
- // the one it sealed itself, and it reads nothing from it but that the
123
- // signature is the claimed key's.
193
+ // the one it sealed itself, and it reads nothing from it but that it is
194
+ // the silence a door owes such a box, signed by the claimed key.
124
195
  async prove(pk, reach) {
125
- if (!/^[0-9a-f]{128}$/.test(pk))
196
+ if (!isWardPk(pk))
126
197
  return false;
127
198
  try {
128
- const random = (n) => globalThis.crypto.getRandomValues(new Uint8Array(n));
129
199
  const lid = await sealingPair(random(KEY));
130
200
  const back = await within(PROBE, reach.carry(pk, concat([lid.pk, random(KEY)])));
131
201
  if (back === LATE || back === undefined)
@@ -139,19 +209,45 @@ export class Harbor {
139
209
  }
140
210
  // Bind what the far side claims, each pk proven at its door first. Both
141
211
  // sides bind this way: the listener the dialer's claims, the dialer the
142
- // listener's, since a claim is a claim whichever end made it.
212
+ // listener's, since a claim is a claim whichever end made it. An announce
213
+ // names at most this many pks, and they are proven a few at a time: a
214
+ // side that announced a thousand would otherwise cost a thousand keys, a
215
+ // thousand frames and a thousand waits at once, on its word alone.
143
216
  async bind(pks, reach, held) {
144
217
  // An announce is the whole of what that side holds now, so a pk this
145
- // reach was bound for and no longer claims is unbound at once: a ward
218
+ // reach was bound for and does not claim now is unbound at once: a ward
146
219
  // that left a dialer is not reachable through it, proof or no proof.
220
+ // And it is the newest word: a proof still in flight from an earlier
221
+ // announce on this reach binds nothing when it lands, since the side
222
+ // has spoken again since.
223
+ const said = (announces.get(reach) ?? 0) + 1;
224
+ announces.set(reach, said);
147
225
  for (const [pk, b] of this.reaches)
148
226
  if (b.reach === reach && !pks.includes(pk))
149
227
  this.reaches.delete(pk);
150
- await Promise.all(pks.map(async (pk) => {
151
- if (this.doors.has(pk) || !(await this.prove(pk, reach)))
228
+ const claimed = [...new Set(pks)].slice(0, ANNOUNCE);
229
+ const prove = async (pk) => {
230
+ if (this.doors.has(pk))
231
+ return;
232
+ // A line already held for this pk is not displaced by another line that
233
+ // proves the same pk. A proof says only that the ward is reachable that
234
+ // way, and a relay that forwards to the real ward proves as well as the
235
+ // ward's own line does: taking the binding would add a hop the relay
236
+ // chooses, and can drop. The first held line keeps it until it closes,
237
+ // which unbind says.
238
+ const standing = this.reaches.get(pk);
239
+ if (standing?.held && standing.reach !== reach)
240
+ return;
241
+ if (!(await this.prove(pk, reach)))
152
242
  return;
243
+ if (announces.get(reach) !== said)
244
+ return; // a newer announce has spoken since
245
+ if (this.reaches.get(pk)?.held && this.reaches.get(pk).reach !== reach)
246
+ return; // one took it while this was proving
153
247
  this.reaches.set(pk, { reach, held });
154
- }));
248
+ };
249
+ for (let i = 0; i < claimed.length; i += PROVING)
250
+ await Promise.all(claimed.slice(i, i + PROVING).map(prove));
155
251
  }
156
252
  unbind(reach) {
157
253
  for (const [pk, b] of this.reaches)
@@ -161,68 +257,122 @@ export class Harbor {
161
257
  // ---- wards
162
258
  // Mint a seed and boot an empty ward under a name: a world born here.
163
259
  async create(name, user = '', code = DEFAULT_CODE) {
164
- const kept = { seed: globalThis.crypto.getRandomValues(new Uint8Array(32)), partition: {}, record: { pk: '', code, user } };
260
+ const kept = { seed: random(KEY), partition: {}, record: { pk: '', code, user } };
165
261
  await this.store.put(name, kept);
166
262
  const hosted = await this.host(name, kept);
167
263
  await this.store.record(name, hosted.record);
168
264
  return hosted;
169
265
  }
170
266
  // Stop serving a ward and take it out of the store: what was kept comes
171
- // back, for another harbor to put. The partition is written first, because
172
- // a being driven in-process changes it without passing a door.
267
+ // back, for another harbor to put. The partition is written first, and
268
+ // never again under this name: whoever still holds the old pointers holds
269
+ // a ward that saves nothing and answers its owner silence.
173
270
  async drop(name) {
174
271
  const h = this.wards.get(name);
175
272
  if (h) {
176
273
  await h.save();
274
+ // Gone for whoever still holds the old pointers, whose save closure has
275
+ // this row in hand, and out of the map, which now says what this harbor
276
+ // serves and nothing else.
277
+ const s = this.#saving.get(name);
278
+ if (s)
279
+ s.gone = true;
280
+ this.#saving.delete(name);
177
281
  this.wards.delete(name);
178
282
  this.doors.delete(h.pk);
179
283
  this.#announce(); // this harbor no longer claims it: a listener unbinds a pk an announce stops naming
180
284
  }
181
285
  return this.store.take(name);
182
286
  }
183
- // Put what another harbor dropped, and boot it: same seed, same pk.
287
+ // Put what another harbor dropped, and boot it: same seed, same pk. A ward
288
+ // that will not boot is not adopted. This is the one path where a partition
289
+ // written somewhere else arrives, so it is the one place the ward's own
290
+ // shape check is met, and the name is refused for as long as it is kept:
291
+ // a partition put and left would hold the name against every later try,
292
+ // with a copy of it in a store that can never serve it. It goes back out,
293
+ // and the only copy is the one the caller is still holding.
184
294
  async adopt(name, kept) {
185
295
  await this.store.put(name, kept);
186
- return this.host(name, kept);
296
+ try {
297
+ return await this.host(name, kept);
298
+ }
299
+ catch (e) {
300
+ await this.store.take(name);
301
+ throw e;
302
+ }
187
303
  }
188
304
  // A ward from what the store keeps: the ground, once, and two pointers.
189
305
  async host(name, kept) {
190
306
  const { seed, partition: memory } = kept;
191
307
  const classes = await this.loader(kept.record);
192
- const objects = new Map(); // cells -> the object instantiate made. a side's hand, never the ward's
308
+ const objects = new WeakMap(); // cells -> the object instantiate made. a side's hand, never the ward's; it follows the cells out when she is unbooted
193
309
  const ground = {
194
310
  seed,
195
311
  memory,
196
- instantiate: (className, stance) => {
197
- const C = classes[className] ?? this.classes[className];
198
- if (!C)
199
- return null;
200
- const obj = new C(stance);
201
- objects.set(stance.cells, obj);
202
- return obj;
203
- },
312
+ // This ward's own classes first, then the harbor's: a ward that names a
313
+ // class of its own is answered with hers.
314
+ instantiate: maker(objects, classes, this.classes),
204
315
  carry: (pk, bytes) => this.carry(pk, new Uint8Array(bytes)),
205
- random: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
316
+ random,
317
+ wrote: () => this.#wrote(name),
206
318
  };
207
- const w = await Ward(ground);
208
- const save = () => this.store.save(name, memory);
209
- const after = (f) => async (...a) => {
210
- try {
211
- return await f(...a);
212
- }
213
- finally {
214
- await save();
215
- }
319
+ const saving = { memory, dirty: false, running: null, timer: undefined, gone: false };
320
+ this.#saving.set(name, saving);
321
+ // A ward that will not be born leaves nothing behind: a partition of a
322
+ // shape this kit cannot read throws here, and a name the harbor does not
323
+ // serve must not have a saving row saying it does. Learning the pk is
324
+ // inside this, because a ward that will not say its pk is a ward this
325
+ // harbor cannot route to and is no more born than one that threw.
326
+ let w, pk;
327
+ try {
328
+ w = await Ward(ground);
329
+ pk = await learnPk(w);
330
+ }
331
+ catch (e) {
332
+ saving.gone = true;
333
+ this.#saving.delete(name);
334
+ throw e;
335
+ }
336
+ // A save the caller may wait for: what the ward wrote so far is in the
337
+ // store when this resolves, or the fault is counted. After a call through
338
+ // a pointer the store is current, so a restart right after it finds
339
+ // everything; a refusal at the door wrote nothing and saves nothing, and
340
+ // neither does a read. A ward that wrote nothing has nothing to save, so
341
+ // asking it what it holds is free, which is what makes the ask pointer
342
+ // the way to find out rather than an expensive way.
343
+ const save = async () => {
344
+ if (saving.gone)
345
+ return;
346
+ await this.#flush(name);
347
+ };
348
+ const door = async (bytes) => {
349
+ const r = await w.door(bytes);
350
+ // The reply waits for the store, so a restart right after it finds
351
+ // what the arrival wrote. Whether a key this door holds spoke is not
352
+ // the test: a stranger at the public being is not heard and her being
353
+ // may still have written, and a refusal wrote nothing whoever made it.
354
+ await save();
355
+ return r;
356
+ };
357
+ const ask = async (method, args) => {
358
+ if (saving.gone)
359
+ return silence;
360
+ const out = await w.ask(method, args);
361
+ await save();
362
+ return out;
216
363
  };
217
- const door = after(w.door);
218
- const ask = after(w.ask);
219
- const bp = (await w.ask()); // learned by asking, as anyone learns anything
364
+ // The objects map is keyed by the cells the ward handed instantiate, so
365
+ // the way back to a being is her cells; the partition is read for that
366
+ // one lookup and for nothing else, and a key with no object is a being
367
+ // who is not here this run. `keys` is what the map holds, so a caller
368
+ // never needs the beings table to find out what to ask for.
220
369
  const beings = memory.beings;
221
370
  const being = (key) => {
222
- const cells = beings?.[key];
371
+ const cells = beings && Object.hasOwn(beings, key) ? beings[key] : undefined;
223
372
  return cells ? objects.get(cells) : undefined;
224
373
  };
225
- const hosted = { door, ask, pk: bp.notes.pk, name, record: { ...kept.record, pk: bp.notes.pk }, partition: memory, being, save };
374
+ const keys = () => Object.keys(beings ?? {}).filter((k) => being(k) !== undefined);
375
+ const hosted = { door, ask, pk, name, record: { ...kept.record, pk }, being, keys, save };
226
376
  this.wards.set(name, hosted);
227
377
  this.doors.set(hosted.pk, door);
228
378
  await save();
@@ -1,12 +1,13 @@
1
1
  import { REFUSED, Socket } from './reach.js';
2
2
  export function dial(harbor, url) {
3
3
  const d = { url, socket: null, wake: () => { }, close: () => { } };
4
- let stopped = false, wait = 1000, timer;
4
+ let stopped = false, wait = 1000, timer, line, // the line in hand, open or still opening: close() closes it either way
5
+ held; // the one this dialer owns now, and how it gives back what the line took
5
6
  const connect = () => {
6
7
  timer = undefined; // no wait pending: this is the dial it was waiting for
7
8
  if (stopped)
8
9
  return;
9
- const line = new WebSocket(url.replace(/\/$/, '').replace(/^http/, 'ws'));
10
+ line = new WebSocket(url.replace(/\/$/, '').replace(/^http/, 'ws'));
10
11
  const s = new Socket(line, (pk, bytes) => harbor.deliver(pk, bytes), (far) => {
11
12
  // A line that opened is not a line that works. The wait goes back to
12
13
  // a second here, where the far side has answered and been taken, and
@@ -15,13 +16,15 @@ export function dial(harbor, url) {
15
16
  wait = 1000;
16
17
  void harbor.bind(far, s, false); // the listener's claims, each proven at its door: reached through this socket, not held for others
17
18
  }, (why) => {
18
- harbor.announcers.delete(say);
19
- harbor.unbind(s);
20
- if (d.socket === s)
21
- d.socket = null;
22
- const at = harbor.fallbacks.indexOf(s);
23
- if (at !== -1)
24
- harbor.fallbacks.splice(at, 1); // this line only: another dialer's stays
19
+ shed();
20
+ // A line this dialer already put down. Its close arrives whenever the
21
+ // system gets round to noticing, which on a socket that died under a
22
+ // sleeping device is minutes; by then the line dialed in its place is
23
+ // the one in hand, so this one gives back what it took and dials
24
+ // nothing. Only the line the dialer still holds dials again.
25
+ if (held?.socket !== s)
26
+ return;
27
+ held = undefined;
25
28
  // A suite this listener will not speak is not a line that dropped. It
26
29
  // will not become speakable by asking again sooner, so the wait goes
27
30
  // straight to the ceiling and stays there until it changes its mind.
@@ -31,12 +34,26 @@ export function dial(harbor, url) {
31
34
  timer = setTimeout(connect, wait);
32
35
  wait = Math.min(wait * 2, 30000);
33
36
  });
37
+ // Everything this line took from the harbor, given back. Said when it
38
+ // closes, and when the dialer puts it down without waiting for that.
39
+ const shed = () => {
40
+ harbor.announcers.delete(say);
41
+ harbor.unbind(s);
42
+ if (d.socket === s)
43
+ d.socket = null;
44
+ const at = harbor.fallbacks.indexOf(s);
45
+ if (at !== -1)
46
+ harbor.fallbacks.splice(at, 1); // this line only: another dialer's stays
47
+ };
48
+ held = { socket: s, shed };
34
49
  harbor.fallbacks.push(s); // from the moment it is dialed: an ask made before the handshake waits on the socket, and is not unreached
35
50
  // What this harbor holds, said when the line opens and again whenever it
36
51
  // changes. A ward booted or adopted after the handshake is announced on
37
52
  // the line already in hand, and a dropped one stops being claimed at once.
38
53
  const say = () => s.announce([...harbor.doors.keys()]);
39
54
  line.addEventListener('open', () => {
55
+ if (stopped)
56
+ return s.close(); // closed while it was opening: it opens into nothing
40
57
  d.socket = s;
41
58
  harbor.announcers.add(say);
42
59
  say();
@@ -45,19 +62,19 @@ export function dial(harbor, url) {
45
62
  d.wake = () => {
46
63
  if (stopped)
47
64
  return;
48
- if (d.socket)
49
- return d.socket.announce([...harbor.doors.keys()]);
50
- if (timer === undefined)
51
- return;
52
65
  clearTimeout(timer);
53
66
  timer = undefined;
54
- wait = 1000;
67
+ wait = 1000; // the device is back, and the wait it froze belongs to the sleep, not to this line
68
+ const old = held;
69
+ held = undefined; // put down before the new one is dialed, so a fallback list never holds the dead line ahead of the live one
70
+ old?.shed();
71
+ old?.socket.close();
55
72
  connect();
56
73
  };
57
74
  d.close = () => {
58
75
  stopped = true;
59
76
  clearTimeout(timer);
60
- d.socket?.close();
77
+ line?.close(); // open or opening: a line closed while connecting fires close and never open
61
78
  };
62
79
  connect();
63
80
  return d;
@@ -4,3 +4,4 @@ export { dial, type Dialer } from './dial.ts';
4
4
  export { MemoryStore, values, type Store, type Kept, type WardRecord } from './store.ts';
5
5
  export { request, Socket, SUITE, REFUSED, type Reach, type Carry, type Line, type Announce } from './reach.ts';
6
6
  export type { Ground, WardPointers } from '../ward/ground.ts';
7
+ export { maker, entropy, learnPk } from '../ward/ground.ts';
@@ -8,3 +8,6 @@ export { Harbor, DEFAULT_CODE } from './core.js';
8
8
  export { dial } from './dial.js';
9
9
  export { MemoryStore, values } from './store.js';
10
10
  export { request, Socket, SUITE, REFUSED } from './reach.js';
11
+ // What a harbor builds a ground out of. Convenience, never contract: a kit
12
+ // writing its own harbor may write these three again.
13
+ export { maker, entropy, learnPk } from '../ward/ground.js';
@@ -1,5 +1,5 @@
1
- import type { BeingClass } from '../being/types.ts';
2
- import type { Ground, WardPointers } from '../ward/ground.ts';
1
+ import type { BeingClass, BeingLike } from '../being/types.ts';
2
+ import { type Ground, type WardPointers } from '../ward/ground.ts';
3
3
  export type Booted = WardPointers & {
4
4
  pk: string;
5
5
  };
@@ -16,7 +16,7 @@ export declare class MemoryHarbor {
16
16
  cut: boolean;
17
17
  readonly partitions: Map<string, Record<string, unknown>>;
18
18
  readonly wards: Map<string, Booted>;
19
- readonly objects: Map<object, unknown>;
19
+ readonly objects: WeakMap<object, BeingLike>;
20
20
  route(farPk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
21
21
  link(harbor: MemoryHarbor): void;
22
22
  boot(seed: string, Ward: WardFactory, classes: Record<string, BeingClass>): Promise<Booted>;
@@ -1,3 +1,4 @@
1
+ import { maker, entropy, learnPk } from '../ward/ground.js';
1
2
  import { hex } from '../ward/arithmetic.js';
2
3
  export class MemoryHarbor {
3
4
  doors = new Map(); // ward pk -> door
@@ -8,7 +9,7 @@ export class MemoryHarbor {
8
9
  cut = false;
9
10
  partitions = new Map(); // seed -> memory. the harbor keeps it and reads nothing
10
11
  wards = new Map(); // seed -> pointers
11
- objects = new Map(); // cells -> being object. what instantiate constructed. a hand for tests, never the ward's
12
+ objects = new WeakMap(); // cells -> being object. what instantiate constructed. a hand for tests, never the ward's; it follows the cells out when she is unbooted
12
13
  // the directory: its own doors, else a peer it is linked to. Quo says nothing about how.
13
14
  async route(farPk, bytes) {
14
15
  if (this.down.has(farPk))
@@ -42,25 +43,19 @@ export class MemoryHarbor {
42
43
  const ground = {
43
44
  seed,
44
45
  memory,
45
- instantiate: (name, stance) => {
46
- const C = classes[name];
47
- if (!C)
48
- return null;
49
- const obj = new C(stance);
50
- this.objects.set(stance.cells, obj);
51
- return obj;
52
- },
46
+ // One objects map for the whole harbor, not one per ward: every ward
47
+ // here is in this process and a test reaches any being of any of them.
48
+ instantiate: maker(this.objects, classes),
53
49
  // bytes cross, copied, never a reference. both ways.
54
50
  carry: async (farPk, bytes) => {
55
51
  this.wire.push(hex(bytes));
56
52
  const back = await this.route(farPk, new Uint8Array(bytes));
57
53
  return back === undefined ? undefined : new Uint8Array(back);
58
54
  },
59
- random: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
55
+ random: entropy,
60
56
  };
61
57
  const w = await Ward(ground);
62
- const bp = (await w.ask()); // the harbor learns its ward's pk the way anyone learns anything: by asking
63
- const booted = { ...w, pk: bp.notes.pk };
58
+ const booted = { ...w, pk: await learnPk(w) };
64
59
  this.doors.set(booted.pk, booted.door);
65
60
  this.wards.set(seed, booted);
66
61
  return booted;
@@ -1,9 +1,9 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
2
  // Reach: carrying bytes to a pk off the device. One interface, two
3
- // implementations, as quo-harbor.md names them:
3
+ // implementations:
4
4
  //
5
- // request a URL, the quo. route of a world. POST the bytes to
6
- // `<url>/<pk>`, get bytes back. Listener to listener.
5
+ // request a URL. POST the bytes to `<url>/<pk>`, get bytes back.
6
+ // Listener to listener.
7
7
  // socket a held WebSocket at `<url>`, opened by whichever side can
8
8
  // dial, used in both directions with a frame id. Each side
9
9
  // announces the ward pks it holds when the socket opens, and the
@@ -19,7 +19,7 @@
19
19
  // at the far end, a connection that would not open, a socket that is gone.
20
20
  // A reach that sent the bytes and lost the line afterwards answers nothing
21
21
  // at all, and the ward's own bound ends the ask.
22
- import { hex, unhex } from '../ward/arithmetic.js';
22
+ import { concat, hex, unhex } from '../ward/arithmetic.js';
23
23
  const never = () => new Promise(() => { }); // sent, and no word since: the ward's bound decides
24
24
  // A request reach to one listener.
25
25
  export function request(url) {
@@ -28,14 +28,24 @@ export function request(url) {
28
28
  close: () => { },
29
29
  carry: async (pk, bytes) => {
30
30
  let res;
31
+ // A body of its own, not the caller's view: fetch reads it after this
32
+ // line returns, and a buffer somebody else may write is not a body. The
33
+ // copy is its own ArrayBuffer, which is what a body is; the bytes handed
34
+ // in are typed as backed by anything, shared memory included.
35
+ const body = new Uint8Array(bytes.length);
36
+ body.set(bytes);
31
37
  try {
32
- res = await fetch(`${base}/${pk}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'quo-suite': String(SUITE) }, body: new Uint8Array(bytes) });
38
+ res = await fetch(`${base}/${pk}`, { method: 'POST', headers: { 'content-type': 'application/octet-stream', 'quo-suite': String(SUITE) }, body });
33
39
  }
34
40
  catch {
35
41
  return undefined; // the connection never opened: nothing was delivered
36
42
  }
37
- if (res.status === 404)
38
- return undefined; // the listener holds no reach for that pk
43
+ // A status that says the listener did not take the bytes is nothing
44
+ // delivered: no reach for that pk, a request it refused on its face, a
45
+ // gateway with nobody behind it. Any other failure is a listener that
46
+ // took them and failed after, which is sent and no word since.
47
+ if (res.status === 404 || (res.status >= 400 && res.status < 500) || res.status === 502 || res.status === 503 || res.status === 504)
48
+ return undefined;
39
49
  if (!res.ok)
40
50
  return never();
41
51
  return new Uint8Array(await res.arrayBuffer());
@@ -68,6 +78,11 @@ export const SUITE = 1;
68
78
  // the refusal names itself on the way out, and the far side may read it.
69
79
  export const REFUSED = 4001;
70
80
  const OPEN = 1; // WebSocket.OPEN, on every terrain
81
+ // How many asks a socket keeps a resolver for. An ask the far side never
82
+ // answers is ended by the ward's bound, and its resolver stays until the
83
+ // line closes; a far side that answers nothing for long enough would grow
84
+ // the map for as long as this side asks, so the oldest goes when it is full.
85
+ const PENDING = 4096;
71
86
  // One held socket, either side of it. `deliver` is what this side does with
72
87
  // an ask that arrives for a pk: its own door, or a socket it holds for that
73
88
  // pk, and nothing else. `onAnnounce` learns the far side's pks; `onClose`
@@ -81,7 +96,17 @@ export class Socket {
81
96
  constructor(line, deliver, onAnnounce, onClose) {
82
97
  this.line = line;
83
98
  line.binaryType = 'arraybuffer';
84
- this.#open = line.readyState === OPEN ? Promise.resolve() : new Promise((ok, no) => (line.addEventListener('open', ok), line.addEventListener('error', no)));
99
+ // A line that opens is a reach; one that errors, or closes before it
100
+ // opened, is a reach that failed, and an ask waiting on the handshake is
101
+ // nothing delivered.
102
+ this.#open =
103
+ line.readyState === OPEN
104
+ ? Promise.resolve()
105
+ : new Promise((ok, no) => {
106
+ line.addEventListener('open', ok);
107
+ line.addEventListener('error', no);
108
+ line.addEventListener('close', () => no(new Error('closed before it opened')));
109
+ });
85
110
  this.#open.catch(() => { }); // a handshake refused is a reach that failed, not a process that dies
86
111
  line.addEventListener('message', (e) => {
87
112
  if (typeof e.data === 'string') {
@@ -109,12 +134,16 @@ export class Socket {
109
134
  const kind = buf[0], id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
110
135
  if (kind === ASK) {
111
136
  const pk = hex(buf.subarray(5, 69));
112
- void deliver(pk, new Uint8Array(buf.subarray(69))).then((back) => {
137
+ // A deliver that rejects has delivered nothing this side can vouch
138
+ // for, and a listener does not die of it: the far side hears none.
139
+ void deliver(pk, new Uint8Array(buf.subarray(69)))
140
+ .then((back) => back, () => undefined)
141
+ .then((back) => {
113
142
  const head = new Uint8Array(5);
114
143
  head[0] = back === undefined ? NONE : REPLY;
115
144
  new DataView(head.buffer).setUint32(1, id);
116
145
  if (line.readyState === OPEN)
117
- line.send(back === undefined ? head : concat(head, back));
146
+ line.send(back === undefined ? head : concat([head, back]));
118
147
  });
119
148
  return;
120
149
  }
@@ -150,17 +179,13 @@ export class Socket {
150
179
  new DataView(head.buffer).setUint32(1, id);
151
180
  head.set(unhex(pk), 5);
152
181
  return new Promise((ok) => {
182
+ if (this.pending.size >= PENDING)
183
+ this.pending.delete(this.pending.keys().next().value); // the oldest, which the ward's bound ended long ago
153
184
  this.pending.set(id, ok);
154
- this.line.send(concat(head, bytes));
185
+ this.line.send(concat([head, bytes]));
155
186
  });
156
187
  }
157
188
  close() {
158
189
  this.line.close();
159
190
  }
160
191
  }
161
- function concat(a, b) {
162
- const out = new Uint8Array(a.length + b.length);
163
- out.set(a, 0);
164
- out.set(b, a.length);
165
- return out;
166
- }