@quo-systems/quo 0.2.8 → 0.2.10

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 (52) hide show
  1. package/README.md +19 -10
  2. package/SPEC.md +161 -89
  3. package/dist/being/being.d.ts +2 -2
  4. package/dist/being/being.js +27 -11
  5. package/dist/being/digest.js +11 -7
  6. package/dist/being/types.d.ts +2 -2
  7. package/dist/conformance/estate.js +1 -3
  8. package/dist/conformance/index.js +1 -1
  9. package/dist/harbor/core.d.ts +2 -0
  10. package/dist/harbor/core.js +114 -25
  11. package/dist/harbor/dial.js +5 -3
  12. package/dist/harbor/memory.d.ts +1 -1
  13. package/dist/harbor/memory.js +3 -2
  14. package/dist/harbor/reach.js +32 -7
  15. package/dist/ward/allowance.js +8 -2
  16. package/dist/ward/arithmetic.js +11 -3
  17. package/dist/ward/cells.d.ts +3 -1
  18. package/dist/ward/cells.js +79 -21
  19. package/dist/ward/door.d.ts +1 -0
  20. package/dist/ward/door.js +24 -5
  21. package/dist/ward/ground.d.ts +1 -0
  22. package/dist/ward/heirs.d.ts +1 -1
  23. package/dist/ward/heirs.js +15 -8
  24. package/dist/ward/owner.d.ts +5 -26
  25. package/dist/ward/owner.js +24 -15
  26. package/dist/ward/seal.js +3 -0
  27. package/dist/ward/stance.d.ts +4 -1
  28. package/dist/ward/stance.js +116 -54
  29. package/dist/ward/ward.d.ts +10 -0
  30. package/dist/ward/ward.js +87 -36
  31. package/package.json +4 -2
  32. package/src/being/being.ts +26 -10
  33. package/src/being/digest.ts +13 -11
  34. package/src/being/types.ts +10 -4
  35. package/src/conformance/estate.ts +1 -3
  36. package/src/conformance/index.ts +1 -1
  37. package/src/harbor/core.ts +110 -26
  38. package/src/harbor/dial.ts +6 -4
  39. package/src/harbor/memory.ts +3 -2
  40. package/src/harbor/reach.ts +38 -11
  41. package/src/ward/allowance.ts +8 -2
  42. package/src/ward/arithmetic.ts +11 -3
  43. package/src/ward/cells.ts +76 -25
  44. package/src/ward/door.ts +22 -5
  45. package/src/ward/ground.ts +9 -2
  46. package/src/ward/heirs.ts +13 -6
  47. package/src/ward/owner.ts +24 -24
  48. package/src/ward/seal.ts +2 -0
  49. package/src/ward/stance.ts +117 -48
  50. package/src/ward/ward.ts +86 -35
  51. package/vectors/arithmetic.json +7 -0
  52. package/vectors/framing.json +14 -5
@@ -79,7 +79,9 @@ export type Standings = {
79
79
  } & { readonly [id: string]: Standing | undefined };
80
80
 
81
81
  export type Occupants = {
82
- invite(id: string): Promise<Invitation | null>; // awaitable: a key is minted
82
+ // awaitable: a key is minted. The notes are the terms it is minted under,
83
+ // written on the occupant for her gate to read, and hers alone after that.
84
+ invite(id: string, notes?: JsonObject): Promise<Invitation | null>;
83
85
 
84
86
  remove(id: string): void;
85
87
  };
@@ -90,9 +92,13 @@ export type Stance = {
90
92
  readonly occupants: Occupants;
91
93
  readonly standings: Standings;
92
94
  // A new being of her ward, by class name, under a key she chooses. The key
93
- // back, or null: the key is taken or the harbor holds no such class. A
94
- // being may make; only the owner reaches into another.
95
- boot(className: string, key: string): string | null;
95
+ // back, or null: the key is taken, the harbor holds no such class, or the
96
+ // class threw at birth. A being may make; only the owner reaches into
97
+ // another. Naming an id asks for a way back too: the being made holds an
98
+ // occupant for her maker under the maker's own key, and the maker holds
99
+ // the standing under that id. A relation that could not be made is a boot
100
+ // that made nobody, and the being made goes with it.
101
+ boot(className: string, key: string, id?: string): Promise<string | null>;
96
102
  };
97
103
 
98
104
  // The raw shape of a being. Anything with these two is a being.
@@ -22,7 +22,7 @@
22
22
  // harbors, and that is where the promise of Quo is held: a being never learns
23
23
  // where the other one is, so every answer, after every move, under every
24
24
  // topology, is the answer the model wrote without knowing the topology
25
- // either. Held forty times a run, it is the promise at a scale a scene
25
+ // either. Held after every one of sixty moves, it is the promise at a scale a scene
26
26
  // cannot reach, and it is what makes a migration honest: if no answer can
27
27
  // tell the topology apart, moving a ward between harbors cannot be felt.
28
28
  // The model itself is the script's own bookkeeping and takes nothing from a
@@ -267,8 +267,6 @@ export function estate(label: string, make: () => Promise<World>, { canDown, tes
267
267
  return `${who}'s ward moves harbor`;
268
268
  });
269
269
 
270
- // weather: the host's door is gone. unreached, never silence, and the
271
- // relation is exactly where it was when it comes back.
272
270
  // weather: the host's door is gone. unreached, never silence, and the
273
271
  // relation is exactly where it was when it comes back. The move is on
274
272
  // the list under every topology even where there is nothing to cut,
@@ -12,7 +12,7 @@
12
12
  // handle.being the object, as the test drives it
13
13
  // handle.cells her cells, as the ward keeps them
14
14
  // handle.down = true | false her door is unreachable (needs canDown)
15
- // handle.minted.has(pk) every pk her side ever minted
15
+ // handle.minted.has(pk) the last eight pks her side minted
16
16
  // handle.forgeKnock(invitation) make her side knock with a key of its own, unannounced
17
17
  // world.census() every partition in the world, as values
18
18
  // world.migrate(key) her ward moves to another harbor, same seed, same pk
@@ -19,6 +19,7 @@
19
19
  import { Ward } from '../ward/ward.ts';
20
20
  import type { Ground, WardPointers } from '../ward/ground.ts';
21
21
  import type { BeingClass, BeingLike } from '../being/types.ts';
22
+ import { silence } from '../being/silence.ts';
22
23
  import { request, type Reach } from './reach.ts';
23
24
  import { openReply, wardSignPk } from '../ward/seal.ts';
24
25
  import { concat, sealingPair, KEY } from '../ward/arithmetic.ts';
@@ -26,6 +27,13 @@ import { within, LATE } from '../ward/allowance.ts';
26
27
  import type { Kept, Store, WardRecord } from './store.ts';
27
28
 
28
29
  export type Hosted = WardPointers & { pk: string; name: string; record: WardRecord; partition: Record<string, unknown>; being(key: string): BeingLike | undefined; save(): Promise<void> };
30
+
31
+ // One ward's saves, in a line: the ward says it wrote, the harbor writes the
32
+ // partition once the line is free, and never twice at once, so an older
33
+ // snapshot cannot land after a newer one. A save that fails is the harbor's
34
+ // to count and never a door's to answer: the door answered bytes, and a
35
+ // disk that is full is not a reason the far side may hear.
36
+ type Saving = { memory: Record<string, unknown>; dirty: boolean; running: Promise<void> | null; timer: ReturnType<typeof setTimeout> | undefined; gone: boolean };
29
37
  // A reach in the directory, and whether this harbor holds it as a socket a
30
38
  // dialer opened: only those, and its own doors, take bytes from the wire.
31
39
  export type Bound = { reach: Reach; held: boolean };
@@ -37,6 +45,12 @@ export const DEFAULT_CODE = 'classes/index.ts';
37
45
  // How long a probe waits for the door behind a claim. A claim that answers
38
46
  // nothing in this time is not bound; the next announce is another chance.
39
47
  const PROBE = 10_000;
48
+ // How many pks one announce may claim, and how many are proven at once.
49
+ const ANNOUNCE = 64;
50
+ const PROVING = 8;
51
+ // How many times each reach has announced: a proof from an older announce
52
+ // binds nothing when it lands.
53
+ const announces = new WeakMap<Reach, number>();
40
54
 
41
55
  export class Harbor {
42
56
  readonly store: Store;
@@ -46,7 +60,10 @@ export class Harbor {
46
60
  readonly reaches = new Map<string, Bound>(); // the directory: foreign ward pk -> reach
47
61
  readonly down = new Set<string>(); // pks this harbor will not reach right now: weather, for a test
48
62
  readonly refused = new Map<string, number>(); // own ward pk -> arrivals its door would not admit. what a terrain does with it is its own
63
+ readonly faults = new Map<string, number>(); // ward name -> saves the store refused. what a terrain does with it is its own
64
+ readonly unbooted: string[] = []; // the wards the store keeps that would not host this run, by name
49
65
  readonly classes: Record<string, BeingClass> = {}; // classes handed in-process, beside the loader's
66
+ readonly #saving = new Map<string, Saving>();
50
67
  // The dialers' sockets, in the order they were dialed: where a pk nobody
51
68
  // here knows is sent, because the listener a harbor dialed is a rendezvous
52
69
  // and may hold that pk for someone else. It is a list because a harbor may
@@ -72,15 +89,56 @@ export class Harbor {
72
89
  this.loader = loader;
73
90
  }
74
91
 
75
- // Boot every ward the store keeps, and learn the hints.
92
+ // Boot every ward the store keeps, and learn the hints. One ward that will
93
+ // not host, a partition this kit cannot read or a loader that throws, is
94
+ // named in `unbooted` and takes nobody else down with it.
76
95
  async boot(): Promise<void> {
77
96
  for (const name of await this.store.list()) {
78
- const kept = await this.store.load(name);
79
- if (kept) await this.host(name, kept);
97
+ try {
98
+ const kept = await this.store.load(name);
99
+ if (kept) await this.host(name, kept);
100
+ } catch {
101
+ this.unbooted.push(name);
102
+ }
80
103
  }
81
104
  for (const [pk, url] of Object.entries(await this.store.hints())) this.hint(pk, url);
82
105
  }
83
106
 
107
+ // ---- saving
108
+
109
+ // The ward wrote. Its partition is saved once the line is free, and once
110
+ // for every burst of writes, in the order the writes came.
111
+ #wrote(name: string): void {
112
+ const s = this.#saving.get(name);
113
+ if (!s || s.gone) return;
114
+ s.dirty = true;
115
+ if (s.timer === undefined) s.timer = setTimeout(() => void this.#flush(name), 0);
116
+ }
117
+ async #flush(name: string): Promise<void> {
118
+ const s = this.#saving.get(name);
119
+ if (!s) return;
120
+ if (s.timer !== undefined) {
121
+ clearTimeout(s.timer);
122
+ s.timer = undefined;
123
+ }
124
+ if (s.running) return s.running;
125
+ s.running = (async () => {
126
+ while (s.dirty && !s.gone) {
127
+ s.dirty = false;
128
+ try {
129
+ await this.store.save(name, s.memory);
130
+ } catch {
131
+ this.faults.set(name, (this.faults.get(name) ?? 0) + 1);
132
+ }
133
+ }
134
+ })();
135
+ try {
136
+ await s.running;
137
+ } finally {
138
+ s.running = null;
139
+ }
140
+ }
141
+
84
142
  // ---- the directory
85
143
 
86
144
  // Bytes to one of this harbor's own doors, and the one bit the door says
@@ -113,7 +171,7 @@ export class Harbor {
113
171
  }
114
172
 
115
173
  hint(pk: string, url: string): void {
116
- if (this.reaches.get(pk)?.held) return; // a socket in hand beats a hint
174
+ if (this.reaches.has(pk)) return; // a reach proven at a door beats a hint, held or reached through a dialed line
117
175
  this.reaches.set(pk, { reach: request(url), held: false });
118
176
  }
119
177
 
@@ -130,8 +188,8 @@ export class Harbor {
130
188
  // signed by the ward key. Only the holder of that ward's seed can write
131
189
  // that reply, the lid is fresh so nothing replays, and a box that does not
132
190
  // open writes nothing at the ward. This is the one box a harbor ever opens,
133
- // the one it sealed itself, and it reads nothing from it but that the
134
- // signature is the claimed key's.
191
+ // the one it sealed itself, and it reads nothing from it but that it is
192
+ // the silence a door owes such a box, signed by the claimed key.
135
193
  async prove(pk: string, reach: Reach): Promise<boolean> {
136
194
  if (!/^[0-9a-f]{128}$/.test(pk)) return false;
137
195
  try {
@@ -148,16 +206,27 @@ export class Harbor {
148
206
 
149
207
  // Bind what the far side claims, each pk proven at its door first. Both
150
208
  // sides bind this way: the listener the dialer's claims, the dialer the
151
- // listener's, since a claim is a claim whichever end made it.
209
+ // listener's, since a claim is a claim whichever end made it. An announce
210
+ // names at most this many pks, and they are proven a few at a time: a
211
+ // side that announced a thousand would otherwise cost a thousand keys, a
212
+ // thousand frames and a thousand waits at once, on its word alone.
152
213
  async bind(pks: string[], reach: Reach, held: boolean): Promise<void> {
153
214
  // An announce is the whole of what that side holds now, so a pk this
154
- // reach was bound for and no longer claims is unbound at once: a ward
215
+ // reach was bound for and does not claim now is unbound at once: a ward
155
216
  // that left a dialer is not reachable through it, proof or no proof.
217
+ // And it is the newest word: a proof still in flight from an earlier
218
+ // announce on this reach binds nothing when it lands, since the side
219
+ // has spoken again since.
220
+ const said = (announces.get(reach) ?? 0) + 1;
221
+ announces.set(reach, said);
156
222
  for (const [pk, b] of this.reaches) if (b.reach === reach && !pks.includes(pk)) this.reaches.delete(pk);
157
- await Promise.all(pks.map(async (pk) => {
223
+ const claimed = [...new Set(pks)].slice(0, ANNOUNCE);
224
+ const prove = async (pk: string) => {
158
225
  if (this.doors.has(pk) || !(await this.prove(pk, reach))) return;
226
+ if (announces.get(reach) !== said) return; // a newer announce has spoken since
159
227
  this.reaches.set(pk, { reach, held });
160
- }));
228
+ };
229
+ for (let i = 0; i < claimed.length; i += PROVING) await Promise.all(claimed.slice(i, i + PROVING).map(prove));
161
230
  }
162
231
  unbind(reach: Reach): void {
163
232
  for (const [pk, b] of this.reaches) if (b.reach === reach) this.reaches.delete(pk);
@@ -175,12 +244,15 @@ export class Harbor {
175
244
  }
176
245
 
177
246
  // Stop serving a ward and take it out of the store: what was kept comes
178
- // back, for another harbor to put. The partition is written first, because
179
- // a being driven in-process changes it without passing a door.
247
+ // back, for another harbor to put. The partition is written first, and
248
+ // never again under this name: whoever still holds the old pointers holds
249
+ // a ward that saves nothing and answers its owner silence.
180
250
  async drop(name: string): Promise<Kept | undefined> {
181
251
  const h = this.wards.get(name);
182
252
  if (h) {
183
253
  await h.save();
254
+ const s = this.#saving.get(name);
255
+ if (s) s.gone = true;
184
256
  this.wards.delete(name);
185
257
  this.doors.delete(h.pk);
186
258
  this.#announce(); // this harbor no longer claims it: a listener unbinds a pk an announce stops naming
@@ -198,12 +270,13 @@ export class Harbor {
198
270
  async host(name: string, kept: Kept): Promise<Hosted> {
199
271
  const { seed, partition: memory } = kept;
200
272
  const classes = await this.loader(kept.record);
201
- const objects = new Map<object, BeingLike>(); // cells -> the object instantiate made. a side's hand, never the ward's
273
+ const objects = new WeakMap<object, BeingLike>(); // cells -> the object instantiate made. a side's hand, never the ward's; it follows the cells out when she is unbooted
202
274
  const ground: Ground = {
203
275
  seed,
204
276
  memory,
205
277
  instantiate: (className, stance) => {
206
- const C = classes[className] ?? this.classes[className];
278
+ // Own keys only: `constructor` is a name Object lends every registry.
279
+ const C = Object.hasOwn(classes, className) ? classes[className] : Object.hasOwn(this.classes, className) ? this.classes[className] : undefined;
207
280
  if (!C) return null;
208
281
  const obj = new C(stance);
209
282
  objects.set(stance.cells, obj);
@@ -211,20 +284,31 @@ export class Harbor {
211
284
  },
212
285
  carry: (pk, bytes) => this.carry(pk, new Uint8Array(bytes)),
213
286
  random: (n) => globalThis.crypto.getRandomValues(new Uint8Array(n)),
287
+ wrote: () => this.#wrote(name),
214
288
  };
289
+ const saving: Saving = { memory, dirty: false, running: null, timer: undefined, gone: false };
290
+ this.#saving.set(name, saving);
215
291
  const w = await Ward(ground);
216
- const save = () => this.store.save(name, memory);
217
- const after =
218
- <A extends unknown[], R>(f: (...a: A) => Promise<R>) =>
219
- async (...a: A): Promise<R> => {
220
- try {
221
- return await f(...a);
222
- } finally {
223
- await save();
224
- }
225
- };
226
- const door = after(w.door);
227
- const ask = after(w.ask);
292
+ // A save the caller may wait for: what the ward wrote so far is in the
293
+ // store when this resolves, or the fault is counted. After a call through
294
+ // a pointer the store is current, so a restart right after it finds
295
+ // everything; a refusal at the door wrote nothing and saves nothing.
296
+ const save = async () => {
297
+ if (saving.gone) return;
298
+ saving.dirty = true;
299
+ await this.#flush(name);
300
+ };
301
+ const door = async (bytes: Uint8Array) => {
302
+ const r = await w.door(bytes);
303
+ if (r.heard) await save();
304
+ return r;
305
+ };
306
+ const ask = async (method?: string, args?: Record<string, unknown>) => {
307
+ if (saving.gone) return silence;
308
+ const out = await w.ask(method, args);
309
+ await save();
310
+ return out;
311
+ };
228
312
  const bp = (await w.ask()) as { notes: { pk: string } }; // learned by asking, as anyone learns anything
229
313
  const beings = memory.beings as Record<string, object> | undefined;
230
314
  const being = (key: string) => {
@@ -1,5 +1,5 @@
1
1
  // SPDX-License-Identifier: Apache-2.0
2
- // A harbor as a dialer: one held socket to a listener's quo. route, its own
2
+ // A harbor as a dialer: one held socket to a listener's URL, its own
3
3
  // pks announced, the listener's bound, and a reconnect with backoff from a
4
4
  // second to thirty when the line drops. Seed, partition and keys are here;
5
5
  // only the reach comes and goes. While the socket is open it is also the
@@ -23,11 +23,12 @@ export function dial(harbor: Harbor, url: string): Dialer {
23
23
  const d: Dialer = { url, socket: null, wake: () => {}, close: () => {} };
24
24
  let stopped = false,
25
25
  wait = 1000,
26
- timer: ReturnType<typeof setTimeout> | undefined;
26
+ timer: ReturnType<typeof setTimeout> | undefined,
27
+ line: Line | undefined; // the line in hand, open or still opening: close() closes it either way
27
28
  const connect = () => {
28
29
  timer = undefined; // no wait pending: this is the dial it was waiting for
29
30
  if (stopped) return;
30
- const line = new WebSocket(url.replace(/\/$/, '').replace(/^http/, 'ws')) as unknown as Line;
31
+ line = new WebSocket(url.replace(/\/$/, '').replace(/^http/, 'ws')) as unknown as Line;
31
32
  const s: Socket = new Socket(
32
33
  line,
33
34
  (pk, bytes) => harbor.deliver(pk, bytes),
@@ -59,6 +60,7 @@ export function dial(harbor: Harbor, url: string): Dialer {
59
60
  // the line already in hand, and a dropped one stops being claimed at once.
60
61
  const say = () => s.announce([...harbor.doors.keys()]);
61
62
  line.addEventListener('open', () => {
63
+ if (stopped) return s.close(); // closed while it was opening: it opens into nothing
62
64
  d.socket = s;
63
65
  harbor.announcers.add(say);
64
66
  say();
@@ -76,7 +78,7 @@ export function dial(harbor: Harbor, url: string): Dialer {
76
78
  d.close = () => {
77
79
  stopped = true;
78
80
  clearTimeout(timer);
79
- d.socket?.close();
81
+ line?.close(); // open or opening: a line closed while connecting fires close and never open
80
82
  };
81
83
  connect();
82
84
  return d;
@@ -20,7 +20,7 @@ export class MemoryHarbor {
20
20
  cut = false;
21
21
  readonly partitions = new Map<string, Record<string, unknown>>(); // seed -> memory. the harbor keeps it and reads nothing
22
22
  readonly wards = new Map<string, Booted>(); // seed -> pointers
23
- readonly objects = new Map<object, unknown>(); // cells -> being object. what instantiate constructed. a hand for tests, never the ward's
23
+ readonly objects = new WeakMap<object, unknown>(); // cells -> being object. what instantiate constructed. a hand for tests, never the ward's; it follows the cells out when she is unbooted
24
24
 
25
25
  // the directory: its own doors, else a peer it is linked to. Quo says nothing about how.
26
26
  async route(farPk: string, bytes: Uint8Array): Promise<Uint8Array | undefined> {
@@ -55,7 +55,8 @@ export class MemoryHarbor {
55
55
  seed,
56
56
  memory,
57
57
  instantiate: (name: string, stance: Stance) => {
58
- const C = classes[name];
58
+ // Own keys only: `constructor` is a name Object lends every registry.
59
+ const C = Object.hasOwn(classes, name) ? classes[name] : undefined;
59
60
  if (!C) return null;
60
61
  const obj = new C(stance);
61
62
  this.objects.set(stance.cells, obj);
@@ -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
@@ -38,7 +38,11 @@ export function request(url: string): Reach {
38
38
  } catch {
39
39
  return undefined; // the connection never opened: nothing was delivered
40
40
  }
41
- if (res.status === 404) return undefined; // the listener holds no reach for that pk
41
+ // A status that says the listener did not take the bytes is nothing
42
+ // delivered: no reach for that pk, a request it refused on its face, a
43
+ // gateway with nobody behind it. Any other failure is a listener that
44
+ // took them and failed after, which is sent and no word since.
45
+ if (res.status === 404 || (res.status >= 400 && res.status < 500) || res.status === 502 || res.status === 503 || res.status === 504) return undefined;
42
46
  if (!res.ok) return never();
43
47
  return new Uint8Array(await res.arrayBuffer());
44
48
  },
@@ -75,6 +79,11 @@ export const SUITE = 1;
75
79
  // the refusal names itself on the way out, and the far side may read it.
76
80
  export const REFUSED = 4001;
77
81
  const OPEN = 1; // WebSocket.OPEN, on every terrain
82
+ // How many asks a socket keeps a resolver for. An ask the far side never
83
+ // answers is ended by the ward's bound, and its resolver stays until the
84
+ // line closes; a far side that answers nothing for long enough would grow
85
+ // the map for as long as this side asks, so the oldest goes when it is full.
86
+ const PENDING = 4096;
78
87
 
79
88
  // The standard surface, and no more of it: what a browser WebSocket, Node's
80
89
  // WebSocket and a `ws` server socket all have.
@@ -106,7 +115,17 @@ export class Socket implements Reach {
106
115
  constructor(line: Line, deliver: Carry, onAnnounce: Announce, onClose: (why: { code?: number; reason?: string }) => void) {
107
116
  this.line = line;
108
117
  line.binaryType = 'arraybuffer';
109
- this.#open = line.readyState === OPEN ? Promise.resolve() : new Promise((ok, no) => (line.addEventListener('open', ok), line.addEventListener('error', no)));
118
+ // A line that opens is a reach; one that errors, or closes before it
119
+ // opened, is a reach that failed, and an ask waiting on the handshake is
120
+ // nothing delivered.
121
+ this.#open =
122
+ line.readyState === OPEN
123
+ ? Promise.resolve()
124
+ : new Promise((ok, no) => {
125
+ line.addEventListener('open', ok);
126
+ line.addEventListener('error', no);
127
+ line.addEventListener('close', () => no(new Error('closed before it opened')));
128
+ });
110
129
  this.#open.catch(() => {}); // a handshake refused is a reach that failed, not a process that dies
111
130
  line.addEventListener('message', (e) => {
112
131
  if (typeof e.data === 'string') {
@@ -133,12 +152,19 @@ export class Socket implements Reach {
133
152
  id = new DataView(buf.buffer, buf.byteOffset, buf.byteLength).getUint32(1);
134
153
  if (kind === ASK) {
135
154
  const pk = hex(buf.subarray(5, 69));
136
- void deliver(pk, new Uint8Array(buf.subarray(69))).then((back) => {
137
- const head = new Uint8Array(5);
138
- head[0] = back === undefined ? NONE : REPLY;
139
- new DataView(head.buffer).setUint32(1, id);
140
- if (line.readyState === OPEN) line.send(back === undefined ? head : concat(head, back));
141
- });
155
+ // A deliver that rejects has delivered nothing this side can vouch
156
+ // for, and a listener does not die of it: the far side hears none.
157
+ void deliver(pk, new Uint8Array(buf.subarray(69)))
158
+ .then(
159
+ (back) => back,
160
+ () => undefined,
161
+ )
162
+ .then((back) => {
163
+ const head = new Uint8Array(5);
164
+ head[0] = back === undefined ? NONE : REPLY;
165
+ new DataView(head.buffer).setUint32(1, id);
166
+ if (line.readyState === OPEN) line.send(back === undefined ? head : concat(head, back));
167
+ });
142
168
  return;
143
169
  }
144
170
  const waiting = this.pending.get(id);
@@ -171,6 +197,7 @@ export class Socket implements Reach {
171
197
  new DataView(head.buffer).setUint32(1, id);
172
198
  head.set(unhex(pk), 5);
173
199
  return new Promise((ok) => {
200
+ if (this.pending.size >= PENDING) this.pending.delete(this.pending.keys().next().value!); // the oldest, which the ward's bound ended long ago
174
201
  this.pending.set(id, ok);
175
202
  this.line.send(concat(head, bytes));
176
203
  });
@@ -5,7 +5,7 @@
5
5
  // is held to the ward's ceiling. The door reads it before anything is done
6
6
  // under it and refuses a budget already gone, as one silence like every other
7
7
  // refusal there. The sender bounds the whole of her ask to the same number,
8
- // and a wait that ran out is silence, never unreached.
8
+ // and a wait that ran out is the word late, never silence and never unreached.
9
9
  //
10
10
  // Each ask is bounded on its own. The time an arriving call has left does not
11
11
  // bound the asks a being makes while answering it: attributing her onward ask
@@ -33,7 +33,13 @@ export const CEILING: Allowance = { time: 300_000 };
33
33
  // is thirty seconds, silently, because the ceiling is not hers to know.
34
34
  // Asking for nothing at all is the default, which is the whole point.
35
35
  export function allow(wanted: Wanted | undefined, ceiling: Allowance = CEILING, base: Allowance = DEFAULT): Allowance {
36
- const whole = (n: unknown, fallback: number) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback);
36
+ // Floored first, then tested: a fraction of a millisecond is not a budget,
37
+ // and falls to the default like every other number that is not a positive
38
+ // whole one.
39
+ const whole = (n: unknown, fallback: number) => {
40
+ const w = typeof n === 'number' ? Math.floor(n) : NaN;
41
+ return Number.isFinite(w) && w > 0 ? w : fallback;
42
+ };
37
43
  return { time: Math.min(whole(wanted?.time, base.time), ceiling.time) };
38
44
  }
39
45
 
@@ -8,7 +8,6 @@
8
8
  // runs on. `test/floor.test.ts` names the floor and probes for it. Subtle is
9
9
  // asynchronous, so everything here is.
10
10
  //
11
- // Ported from an earlier kit's arithmetic. Same bytes, same vectors.
12
11
  // `crypto.subtle` is read at every use and never captured at load. A browser
13
12
  // on a plain http:// origin has `crypto` without `subtle`, and a terrain may
14
13
  // install one after this module is first imported; a reference taken here
@@ -59,7 +58,12 @@ export function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
59
58
  return diff === 0;
60
59
  }
61
60
 
62
- // The eight small-order points. A public key among them verifies nothing.
61
+ // The eight small-order points of Ed25519, in their canonical encoding. A
62
+ // public key among them verifies nothing. An encoding whose y coordinate is
63
+ // not reduced, y at or above the field's prime p, names one of the same
64
+ // points under another spelling, and a terrain's verify may accept a zero
65
+ // signature under it; the field has room for the spelling, so it is refused
66
+ // before the list is read.
63
67
  const SMALL_ORDER = [
64
68
  '0100000000000000000000000000000000000000000000000000000000000000',
65
69
  'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
@@ -70,7 +74,11 @@ const SMALL_ORDER = [
70
74
  '26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
71
75
  'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
72
76
  ].map(unhex);
73
- export const smallOrder = (pk: Uint8Array): boolean => SMALL_ORDER.some((p) => sameBytes(p, pk));
77
+ // y >= p, read little-endian with the sign bit masked off: the top byte is
78
+ // 0x7f under the mask, every middle byte is 0xff, and the low byte is at
79
+ // least 0xed, which is p's.
80
+ const unreduced = (pk: Uint8Array): boolean => (pk[31] & 0x7f) === 0x7f && pk[0] >= 0xed && pk.subarray(1, 31).every((b) => b === 0xff);
81
+ export const smallOrder = (pk: Uint8Array): boolean => unreduced(pk) || SMALL_ORDER.some((p) => sameBytes(p, pk));
74
82
 
75
83
  const key32 = (value: Uint8Array, what: string): Uint8Array => {
76
84
  if (!(value instanceof Uint8Array) || value.length !== KEY) throw new Error(`${what} is not a 32-byte key`);