@tonalisk-labs/kit 1.0.1 → 1.0.6

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 (79) hide show
  1. package/dist/testing/src/config-coverage.d.ts.map +1 -1
  2. package/dist/testing/src/config-coverage.js +3 -2
  3. package/dist/testing/src/config-coverage.js.map +1 -1
  4. package/dist/testing/src/fixtures.d.ts +9 -3
  5. package/dist/testing/src/fixtures.d.ts.map +1 -1
  6. package/dist/testing/src/fixtures.js +98 -42
  7. package/dist/testing/src/fixtures.js.map +1 -1
  8. package/dist/testing/src/floors/node.js +11 -3
  9. package/dist/testing/src/floors/node.js.map +1 -1
  10. package/dist/testing/src/stack/browsers.d.ts +13 -3
  11. package/dist/testing/src/stack/browsers.d.ts.map +1 -1
  12. package/dist/testing/src/stack/browsers.js +117 -14
  13. package/dist/testing/src/stack/browsers.js.map +1 -1
  14. package/dist/testing/src/stack/containers.d.ts +6 -2
  15. package/dist/testing/src/stack/containers.d.ts.map +1 -1
  16. package/dist/testing/src/stack/containers.js +8 -1
  17. package/dist/testing/src/stack/containers.js.map +1 -1
  18. package/dist/testing/src/stack/control.d.ts +18 -3
  19. package/dist/testing/src/stack/control.d.ts.map +1 -1
  20. package/dist/testing/src/stack/control.js +50 -10
  21. package/dist/testing/src/stack/control.js.map +1 -1
  22. package/dist/testing/src/stack/gate-member.d.ts +10 -0
  23. package/dist/testing/src/stack/gate-member.d.ts.map +1 -1
  24. package/dist/testing/src/stack/gate-member.js +68 -7
  25. package/dist/testing/src/stack/gate-member.js.map +1 -1
  26. package/dist/testing/src/stack/gate-server.d.ts +37 -4
  27. package/dist/testing/src/stack/gate-server.d.ts.map +1 -1
  28. package/dist/testing/src/stack/gate-server.js +124 -12
  29. package/dist/testing/src/stack/gate-server.js.map +1 -1
  30. package/dist/testing/src/stack/hold.d.ts +24 -3
  31. package/dist/testing/src/stack/hold.d.ts.map +1 -1
  32. package/dist/testing/src/stack/hold.js +136 -11
  33. package/dist/testing/src/stack/hold.js.map +1 -1
  34. package/dist/testing/src/stack/host.d.ts.map +1 -1
  35. package/dist/testing/src/stack/host.js +26 -6
  36. package/dist/testing/src/stack/host.js.map +1 -1
  37. package/dist/testing/src/stack/lock.d.ts +15 -4
  38. package/dist/testing/src/stack/lock.d.ts.map +1 -1
  39. package/dist/testing/src/stack/lock.js +47 -18
  40. package/dist/testing/src/stack/lock.js.map +1 -1
  41. package/dist/testing/src/stack/preview-child.js +14 -3
  42. package/dist/testing/src/stack/preview-child.js.map +1 -1
  43. package/dist/testing/src/stack/runtime-apple.d.ts.map +1 -1
  44. package/dist/testing/src/stack/runtime-apple.js +16 -0
  45. package/dist/testing/src/stack/runtime-apple.js.map +1 -1
  46. package/dist/testing/src/stack/runtime-docker.d.ts +2 -1
  47. package/dist/testing/src/stack/runtime-docker.d.ts.map +1 -1
  48. package/dist/testing/src/stack/runtime-docker.js +29 -3
  49. package/dist/testing/src/stack/runtime-docker.js.map +1 -1
  50. package/dist/testing/src/stack/runtime.d.ts +10 -0
  51. package/dist/testing/src/stack/runtime.d.ts.map +1 -1
  52. package/dist/testing/src/stack/runtime.js +1 -0
  53. package/dist/testing/src/stack/runtime.js.map +1 -1
  54. package/dist/testing/src/stack/socks.d.ts +25 -0
  55. package/dist/testing/src/stack/socks.d.ts.map +1 -0
  56. package/dist/testing/src/stack/socks.js +195 -0
  57. package/dist/testing/src/stack/socks.js.map +1 -0
  58. package/dist/testing/src/stack/stall.d.ts +36 -0
  59. package/dist/testing/src/stack/stall.d.ts.map +1 -0
  60. package/dist/testing/src/stack/stall.js +93 -0
  61. package/dist/testing/src/stack/stall.js.map +1 -0
  62. package/package.json +4 -4
  63. package/testing/src/config-coverage.ts +3 -2
  64. package/testing/src/fixtures.ts +277 -197
  65. package/testing/src/floors/node.ts +19 -3
  66. package/testing/src/stack/browsers.ts +129 -15
  67. package/testing/src/stack/containers.ts +14 -2
  68. package/testing/src/stack/control.ts +62 -11
  69. package/testing/src/stack/gate-member.ts +90 -8
  70. package/testing/src/stack/gate-server.ts +166 -21
  71. package/testing/src/stack/hold.ts +163 -11
  72. package/testing/src/stack/host.ts +32 -9
  73. package/testing/src/stack/lock.ts +61 -21
  74. package/testing/src/stack/preview-child.ts +21 -4
  75. package/testing/src/stack/runtime-apple.ts +20 -1
  76. package/testing/src/stack/runtime-docker.ts +47 -3
  77. package/testing/src/stack/runtime.ts +11 -0
  78. package/testing/src/stack/socks.ts +233 -0
  79. package/testing/src/stack/stall.ts +145 -0
@@ -5,6 +5,9 @@ import { once } from 'node:events';
5
5
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http';
6
6
  import { availableParallelism } from 'node:os';
7
7
 
8
+ import { DEADLINE_HEADER, described, HOLD_ID_HEADER, HOLDER_HEADER } from './hold.ts';
9
+ import { type Held, STILLNESS, type Stillness, type Watch, watchOf } from './stall.ts';
10
+
8
11
  /** The fixed local port the machine's one CPU gate listens on: every run finds it there. */
9
12
  export const GATE_PORT = 47_391;
10
13
 
@@ -26,18 +29,121 @@ export const SLOTS = availableParallelism();
26
29
  /** The last segment of a turn's path that asks for every slot: a measurement load would skew. */
27
30
  export const WHOLE = 'whole';
28
31
 
32
+ /** A hold this process serves, held or waited for, by whom, since when and by when its test must
33
+ * end (epoch ms). */
34
+ interface Holding {
35
+ readonly what: string;
36
+ readonly who: string;
37
+ deadline: number | undefined;
38
+ since: number;
39
+ held: boolean;
40
+ }
41
+
42
+ /** Every hold this process serves, so a wait past its bound can name who holds and who waits. */
43
+ const holdings = new Set<Holding>();
44
+
45
+ /** Each hold's way to move its deadline, by the id its request gave. */
46
+ const renewers = new Map<string, (deadline: number | undefined) => void>();
47
+
48
+ /** A hold a server serves: how it is taken and given back, what it is, who asked for it and by
49
+ * when their test must end, and the watch over its resource's waits, which counts its holders. */
50
+ export interface Served {
51
+ readonly take: (gone: AbortSignal) => Promise<void>;
52
+ readonly give: () => void;
53
+ readonly what: string;
54
+ readonly who: string;
55
+ readonly deadline?: number | undefined;
56
+ readonly watch?: Watch;
57
+ readonly id?: string | undefined;
58
+ }
59
+
60
+ /** A deadline as given: epoch milliseconds, or none. */
61
+ const deadlineIn = (given: unknown): number | undefined => {
62
+ const value = Number(given);
63
+ return typeof given === 'string' && Number.isFinite(value) && value > 0 ? value : undefined;
64
+ };
65
+
66
+ /** The deadline a request gives its holder's test, or none. */
67
+ export const deadlineOf = (incoming: IncomingMessage): number | undefined =>
68
+ deadlineIn(incoming.headers[DEADLINE_HEADER]);
69
+
70
+ /** The id a hold's request gives itself, or none. */
71
+ export const holdIdOf = (incoming: IncomingMessage): string | undefined => {
72
+ const id = incoming.headers[HOLD_ID_HEADER];
73
+ return typeof id === 'string' && id !== '' ? id : undefined;
74
+ };
75
+
76
+ /** A hold's id as openHold makes it, a random UUID: nothing else names a hold. */
77
+ const HOLD_ID = /^[\da-f]{8}-[\da-f]{4}-4[\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}$/u;
78
+
79
+ /** Moves the deadline of the hold `id` names to `given`, epoch milliseconds or none at all, as
80
+ * its test's timeout moved: 204, or 404 for a hold this process doesn't serve. A request names
81
+ * only an entry of the table: an id of another shape is looked up nowhere. */
82
+ export const answerDeadline = (outgoing: ServerResponse, id: string, given: string): void => {
83
+ const renew = HOLD_ID.test(id) && renewers.has(id) ? renewers.get(id) : undefined;
84
+ if (typeof renew !== 'function') {
85
+ outgoing.writeHead(404).end('no such hold here');
86
+ return;
87
+ }
88
+ renew(deadlineIn(given));
89
+ outgoing.writeHead(204).end();
90
+ };
91
+
92
+ /** The holder a request names, decoded, or as sent where it isn't URI-encoded; or that it named
93
+ * none. */
94
+ export const holderOf = (incoming: IncomingMessage): string => {
95
+ const named = incoming.headers[HOLDER_HEADER];
96
+ if (typeof named !== 'string' || named === '') {
97
+ return 'an unnamed holder';
98
+ }
99
+ try {
100
+ return decodeURIComponent(named);
101
+ } catch {
102
+ return named;
103
+ }
104
+ };
105
+
106
+ /** This process's holdings, each named as GET /holders gives it: what, by whom, for how long. */
107
+ export const holdingsNamed = (): string => {
108
+ const now = Date.now();
109
+ return holdings.size === 0
110
+ ? 'it records no holder or waiter'
111
+ : [...holdings].map((holding) => described(holding, now)).join('; ');
112
+ };
113
+
114
+ /** Answers a GET /holders with this process's holdings, as JSON. */
115
+ export const answerHolders = (outgoing: ServerResponse): void => {
116
+ outgoing
117
+ .writeHead(200, { 'content-type': 'application/json' })
118
+ .end(JSON.stringify([...holdings]));
119
+ };
120
+
29
121
  /** A hold (a stack lock, a CPU turn) lasts as long as its request stays open: granted with the
30
122
  * response head, given back when the caller closes it, however its worker ends. */
31
- export const holdOpen = (
32
- outgoing: ServerResponse,
33
- take: (gone: AbortSignal) => Promise<void>,
34
- give: () => void,
35
- ): void => {
123
+ export const holdOpen = (outgoing: ServerResponse, served: Served): void => {
124
+ const { take, give, what, who, deadline, watch, id } = served;
36
125
  const gone = new AbortController();
37
- const granted = { now: false };
126
+ const granted: { now: boolean; held?: Held } = { now: false };
127
+ const holding: Holding = { what, who, deadline, since: Date.now(), held: false };
128
+ holdings.add(holding);
129
+ if (id !== undefined) {
130
+ renewers.set(id, (moved) => {
131
+ holding.deadline = moved;
132
+ if (granted.held !== undefined) {
133
+ granted.held.renew(moved);
134
+ }
135
+ });
136
+ }
38
137
  outgoing.once('close', () => {
138
+ holdings.delete(holding);
139
+ if (id !== undefined) {
140
+ renewers.delete(id);
141
+ }
39
142
  gone.abort();
40
143
  if (granted.now) {
144
+ if (granted.held !== undefined) {
145
+ granted.held.release();
146
+ }
41
147
  give();
42
148
  }
43
149
  });
@@ -49,10 +155,15 @@ export const holdOpen = (
49
155
  return;
50
156
  }
51
157
  granted.now = true;
158
+ if (watch !== undefined) {
159
+ granted.held = watch.holding(holding.deadline);
160
+ }
161
+ holding.held = true;
162
+ holding.since = Date.now();
52
163
  outgoing.writeHead(200).flushHeaders();
53
164
  })
54
165
  .catch((error: unknown) => {
55
- outgoing.writeHead(503).end(String(error));
166
+ outgoing.writeHead(503).end(error instanceof Error ? error.message : String(error));
56
167
  });
57
168
  };
58
169
 
@@ -67,12 +178,15 @@ export interface Semaphore {
67
178
  readonly take: (run: string, slots: number, gone: AbortSignal) => Promise<void>;
68
179
  readonly give: (slots: number) => void;
69
180
  readonly refuse: (run: string) => void;
181
+ /** The watch over its waits, which a server counts each holder in. */
182
+ readonly watch: Watch;
70
183
  }
71
184
 
72
- /** The free slots and the turns waiting for them, in the order they asked. */
185
+ /** The free slots and the turns waiting for them, in the order they asked, and its watch. */
73
186
  interface Queue {
74
187
  free: number;
75
188
  readonly waiting: Set<Waiting>;
189
+ readonly watch: Watch;
76
190
  }
77
191
 
78
192
  /** Grants the waiting turns in order while the first fits, so a turn asking more than are free
@@ -94,17 +208,28 @@ const enqueue = async (
94
208
  gone: AbortSignal,
95
209
  ): Promise<void> => {
96
210
  const { promise, resolve, reject } = Promise.withResolvers<boolean>();
211
+ const done = queue.watch.waiting((stalled) => {
212
+ queue.waiting.delete(waiting);
213
+ waiting.refuse(stalled);
214
+ admit(queue);
215
+ });
97
216
  const waiting: Waiting = {
98
217
  run,
99
218
  slots,
100
219
  grant: () => {
220
+ done();
221
+ queue.watch.moved();
101
222
  resolve(true);
102
223
  },
103
- refuse: reject,
224
+ refuse: (reason) => {
225
+ done();
226
+ reject(reason);
227
+ },
104
228
  };
105
229
  gone.addEventListener(
106
230
  'abort',
107
231
  () => {
232
+ done();
108
233
  queue.waiting.delete(waiting);
109
234
  admit(queue);
110
235
  },
@@ -116,12 +241,19 @@ const enqueue = async (
116
241
  };
117
242
 
118
243
  /** The slots, first come, first served, so a whole-machine turn is never starved. A set keeps the
119
- * order with O(1) removal. */
120
- export const semaphore = (slots: number): Semaphore => {
121
- const queue: Queue = { free: slots, waiting: new Set() };
244
+ * order with O(1) removal. A wait fails once no slot has changed hands for the stillness's stall,
245
+ * saying `what` stood still. */
246
+ export const semaphore = (
247
+ slots: number,
248
+ { stillness = STILLNESS, what = 'no slot' }: { stillness?: Stillness; what?: string } = {},
249
+ ): Semaphore => {
250
+ const watch = watchOf(stillness, { what, named: holdingsNamed });
251
+ const queue: Queue = { free: slots, waiting: new Set(), watch };
122
252
  return {
253
+ watch,
123
254
  take: async (run, asked, gone) => enqueue(queue, { run, slots: asked }, gone),
124
255
  give: (given) => {
256
+ watch.moved();
125
257
  queue.free += given;
126
258
  admit(queue);
127
259
  },
@@ -171,18 +303,26 @@ interface Runs {
171
303
  * for a run never seen, whose gate was stopped and which is rejoining this one. */
172
304
  const turnFor = (
173
305
  runs: Runs,
174
- { run, size }: { readonly run: string; readonly size: string },
306
+ {
307
+ run,
308
+ size,
309
+ incoming,
310
+ }: { readonly run: string; readonly size: string; readonly incoming: IncomingMessage },
175
311
  outgoing: ServerResponse,
176
312
  ): void => {
177
313
  if (runs.members.has(run)) {
178
314
  const asked = size === WHOLE ? runs.slots : 1;
179
- holdOpen(
180
- outgoing,
181
- async (gone) => runs.turns.take(run, asked, gone),
182
- () => {
315
+ holdOpen(outgoing, {
316
+ take: async (gone) => runs.turns.take(run, asked, gone),
317
+ give: () => {
183
318
  runs.turns.give(asked);
184
319
  },
185
- );
320
+ what: `${String(asked)} of the CPU gate's slots for run ${run}`,
321
+ who: holderOf(incoming),
322
+ deadline: deadlineOf(incoming),
323
+ watch: runs.turns.watch,
324
+ id: holdIdOf(incoming),
325
+ });
186
326
  } else if (runs.left.has(run)) {
187
327
  outgoing.writeHead(410).end(`no cpu for ${run}, which has left`);
188
328
  } else {
@@ -196,7 +336,11 @@ const answer =
196
336
  const [, action = '', encoded = '', size = ''] = (incoming.url ?? '').split('/');
197
337
  const run = decodeURIComponent(encoded);
198
338
  incoming.resume();
199
- if (run === '') {
339
+ if (action === 'holders') {
340
+ answerHolders(outgoing);
341
+ } else if (action === 'deadline') {
342
+ answerDeadline(outgoing, run, size);
343
+ } else if (run === '') {
200
344
  outgoing.writeHead(404).end(`no run named for ${action}`);
201
345
  } else if (action === 'join') {
202
346
  gate.hold(run);
@@ -205,7 +349,7 @@ const answer =
205
349
  });
206
350
  outgoing.writeHead(200, { [GATE_MARK]: GATE_VERSION }).flushHeaders();
207
351
  } else if (action === 'cpu') {
208
- turnFor(runs, { run, size }, outgoing);
352
+ turnFor(runs, { run, size, incoming }, outgoing);
209
353
  } else {
210
354
  outgoing.writeHead(404).end(`no ${action} for ${run}, which has not joined`);
211
355
  }
@@ -214,7 +358,8 @@ const answer =
214
358
  /** The gate on a listening server: a run joins, holds turns while joined, and leaves. Its last
215
359
  * member's leave closes the port before anything else, so a join it then refuses finds it free. */
216
360
  const gateOn = (server: Server, slots: number): Gate => {
217
- const runs: Runs = { turns: semaphore(slots), slots, members: new Set(), left: new Set() };
361
+ const turns = semaphore(slots, { what: 'no CPU turn' });
362
+ const runs: Runs = { turns, slots, members: new Set(), left: new Set() };
218
363
  const gate: Gate = {
219
364
  hold: (run) => {
220
365
  runs.left.delete(run);
@@ -1,6 +1,19 @@
1
1
  // A hold (a stack lock, a CPU turn) is an open request: granted with the response head, and held
2
2
  // until the holder closes it, so a holder that dies gives it back.
3
- import { type ClientRequest, type IncomingMessage, request } from 'node:http';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { type ClientRequest, get, type IncomingMessage, request } from 'node:http';
5
+
6
+ import { isObject } from '../unknown.ts';
7
+
8
+ /** The header a hold's request names its holder in (its test, worker and process), URI-encoded
9
+ * for the characters a test's title holds and a header can't. */
10
+ export const HOLDER_HEADER = 'x-kit-holder';
11
+
12
+ /** The header a hold's request gives its test's deadline in, as epoch milliseconds. */
13
+ export const DEADLINE_HEADER = 'x-kit-deadline';
14
+
15
+ /** The header a hold's request gives its id in, by which its test moves its deadline later. */
16
+ export const HOLD_ID_HEADER = 'x-kit-hold';
4
17
 
5
18
  /** A kind of hold: what it is called, what a refusal of it says, and who hears should the server
6
19
  * end it first; with no one named, the holder's release fails instead. */
@@ -10,6 +23,87 @@ export interface HoldKind {
10
23
  readonly ended?: () => void;
11
24
  }
12
25
 
26
+ /** One holding a server lists: what, by whom, since when, held or waited for. */
27
+ export const described = (entry: unknown, now: number): string => {
28
+ if (!isObject(entry)) {
29
+ return String(entry);
30
+ }
31
+ const { what, who, since, held, deadline } = entry;
32
+ const seconds = typeof since === 'number' ? Math.round((now - since) / 1000) : '?';
33
+ const state = held === true ? 'held' : 'waited for';
34
+ const left = typeof deadline === 'number' ? Math.round((deadline - now) / 1000) : undefined;
35
+ const due =
36
+ left === undefined
37
+ ? ''
38
+ : `, ${left >= 0 ? `due in ${String(left)} s` : `${String(-left)} s past its deadline`}`;
39
+ return `${String(what)} ${state} by ${String(who)} for ${String(seconds)} s${due}`;
40
+ };
41
+
42
+ /** The holdings a GET /holders listed, each named. */
43
+ const namedIn = (listed: unknown): string => {
44
+ if (!Array.isArray(listed) || listed.length === 0) {
45
+ return 'it records no holder or waiter';
46
+ }
47
+ const now = Date.now();
48
+ return listed.map((entry) => described(entry, now)).join('; ');
49
+ };
50
+
51
+ /** Who holds and waits at `origin`, as its GET /holders lists them, or why it couldn't say. Read
52
+ * through node:http, which tells a reset as its request's error: Node 24.21's fetch (undici
53
+ * 7.29.1) throws it uncaught from `setTypeOfService` on macOS, ending the process. */
54
+ const holdersAt = async (origin: string): Promise<string> => {
55
+ const { promise, resolve } = Promise.withResolvers<string>();
56
+ const failed = (error: unknown): void => {
57
+ resolve(`its holders could not be read: ${String(error)}`);
58
+ };
59
+ const call = get(new URL('/holders', origin), { signal: AbortSignal.timeout(5000) }, (answer) => {
60
+ const chunks: Buffer[] = [];
61
+ answer
62
+ .on('data', (chunk: Buffer) => {
63
+ chunks.push(chunk);
64
+ })
65
+ .once('end', () => {
66
+ try {
67
+ const listed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'));
68
+ resolve(namedIn(listed));
69
+ } catch (error) {
70
+ failed(error);
71
+ }
72
+ })
73
+ .once('error', failed);
74
+ });
75
+ call.once('error', failed);
76
+ return promise;
77
+ };
78
+
79
+ /** The holds this process has open, granted or waiting, by id: where each is served. */
80
+ const live = new Map<string, string>();
81
+
82
+ /** Moves the deadline of every hold this process has open to `deadline` (epoch milliseconds, or
83
+ * none), as its test's timeout moved; a server that can't be told keeps the one it had. */
84
+ export const announceDeadline = async (deadline?: number): Promise<void> => {
85
+ const given = deadline === undefined ? 'none' : String(Math.ceil(deadline));
86
+ await Promise.all(
87
+ [...live].map(async ([id, origin]) => {
88
+ const { promise, resolve } = Promise.withResolvers<boolean>();
89
+ const call = request(
90
+ new URL(`/deadline/${id}/${given}`, origin),
91
+ { method: 'POST' },
92
+ (answer) => {
93
+ answer.resume().once('end', () => {
94
+ resolve(true);
95
+ });
96
+ },
97
+ );
98
+ call.once('error', () => {
99
+ resolve(false);
100
+ });
101
+ call.end();
102
+ await promise;
103
+ }),
104
+ );
105
+ };
106
+
13
107
  /** A hold refused, with the status the server answered. */
14
108
  const refusal = (message: string, status: number | undefined): Error =>
15
109
  Object.assign(new Error(message), { status });
@@ -42,26 +136,84 @@ const releaseOf = (
42
136
  };
43
137
  };
44
138
 
45
- /** Asks `url` for a hold, answering its release once granted; an abort gives up a hold not yet
46
- * granted, and ends one that is. */
139
+ /** How a hold is asked for: the signal that gives it up, who asks, by when their test must end,
140
+ * and the id its test moves that deadline by (one of its own, unless a relay passes it on). */
141
+ interface Asking {
142
+ readonly signal?: AbortSignal | undefined;
143
+ readonly who?: string;
144
+ readonly deadline?: number | undefined;
145
+ readonly id?: string | undefined;
146
+ }
147
+
148
+ /** A hold request's headers: its id, its holder's name, URI-encoded, and its test's deadline. */
149
+ const headersOf = ({ who, deadline }: Asking, id: string): Record<string, string> => {
150
+ const headers: Record<string, string> = { [HOLD_ID_HEADER]: id };
151
+ if (who !== undefined) {
152
+ headers[HOLDER_HEADER] = encodeURIComponent(who);
153
+ }
154
+ if (deadline !== undefined) {
155
+ headers[DEADLINE_HEADER] = String(Math.ceil(deadline));
156
+ }
157
+ return headers;
158
+ };
159
+
160
+ /** Counts a hold among this process's open ones, waiting or granted, until its request closes: its
161
+ * test may move its deadline meanwhile. */
162
+ const counted = (call: ClientRequest, id: string, origin: string): void => {
163
+ live.set(id, origin);
164
+ call.once('close', () => {
165
+ live.delete(id);
166
+ });
167
+ };
168
+
169
+ /** Asks `url` for a hold, answering its release once granted; an abort ends one that is, and gives
170
+ * up one not yet granted with an AbortError naming who held what it waited for. */
47
171
  export const openHold = async (
48
172
  url: URL,
49
173
  kind: HoldKind,
50
- signal?: AbortSignal,
174
+ asking: Asking = {},
51
175
  ): Promise<() => void> => {
176
+ const { signal, id = randomUUID() } = asking;
52
177
  const { promise, resolve, reject } = Promise.withResolvers<() => void>();
53
- const call = request(url, { method: 'POST', signal }, (response) => {
178
+ const headers = headersOf(asking, id);
179
+ const waiting: { granted: boolean; failing?: Promise<void> } = { granted: false };
180
+ const call = request(url, { method: 'POST', signal, headers }, (response) => {
54
181
  const { statusCode: status } = response;
55
- // Read: a refusal to its end, so its socket is freed, as one left unread holds the process open;
56
- // a grant, so that its server ending it whole is heard.
57
- response.resume();
182
+ // Read: a grant, so that its server ending it whole is heard; a refusal to its end, so its
183
+ // socket is freed, as one left unread holds the process open, and what it says is told.
58
184
  if (status === 200) {
185
+ waiting.granted = true;
186
+ response.resume();
59
187
  resolve(releaseOf(call, response, kind));
60
- } else {
61
- reject(refusal(kind.refused(status), status));
188
+ return;
189
+ }
190
+ const said: Buffer[] = [];
191
+ response
192
+ .on('data', (chunk: Buffer) => {
193
+ said.push(chunk);
194
+ })
195
+ .once('close', () => {
196
+ const reason = Buffer.concat(said).toString('utf8').trim();
197
+ const message = kind.refused(status);
198
+ reject(refusal(reason === '' ? message : `${message}: ${reason}`, status));
199
+ });
200
+ });
201
+ call.on('error', (error) => {
202
+ // A granted hold's end, by its release or its server, is the release's to hear.
203
+ if (waiting.granted) {
204
+ return;
205
+ }
206
+ if (signal === undefined || !signal.aborted) {
207
+ reject(error);
208
+ return;
62
209
  }
210
+ waiting.failing = holdersAt(url.origin).then((holders) => {
211
+ const given = `the wait for ${kind.held} was given up before a grant`;
212
+ const named = new Error(`${given}: at its server, ${holders}`, { cause: error });
213
+ reject(Object.assign(named, { name: 'AbortError' }));
214
+ });
63
215
  });
64
- call.on('error', reject);
216
+ counted(call, id, url.origin);
65
217
  call.end();
66
218
  return promise;
67
219
  };
@@ -12,7 +12,14 @@ import {
12
12
  STACK_ENV,
13
13
  } from './control.ts';
14
14
  import { joinGate, type Membership } from './gate-member.ts';
15
- import { holdOpen } from './gate-server.ts';
15
+ import {
16
+ answerDeadline,
17
+ answerHolders,
18
+ deadlineOf,
19
+ holderOf,
20
+ holdIdOf,
21
+ holdOpen,
22
+ } from './gate-server.ts';
16
23
  import { seatPool, stackLocks } from './lock.ts';
17
24
  import type { Service } from './process.ts';
18
25
  import { startTarget } from './stack.ts';
@@ -32,7 +39,7 @@ interface Entry {
32
39
  const startKey = async (key: string, turns: string, signal: AbortSignal): Promise<Started> => {
33
40
  if (key === LINUX_BROWSERS) {
34
41
  const server = await startBrowserServer(signal);
35
- return { env: { [LINUX_ENV]: server.wsEndpoint }, stop: server.stop };
42
+ return { env: { [LINUX_ENV]: JSON.stringify(server.endpoints) }, stop: server.stop };
36
43
  }
37
44
  const target = discover().find(({ name }) => name === key);
38
45
  if (target === undefined) {
@@ -105,20 +112,36 @@ interface Hosted {
105
112
  const handle =
106
113
  (hosted: Hosted) =>
107
114
  (incoming: IncomingMessage, outgoing: ServerResponse): void => {
108
- const [, action = '', key = ''] = (incoming.url ?? '').split('/');
115
+ const [, action = '', key = '', given = ''] = (incoming.url ?? '').split('/');
116
+ if (action === 'holders') {
117
+ answerHolders(outgoing);
118
+ return;
119
+ }
120
+ if (action === 'deadline') {
121
+ answerDeadline(outgoing, key, given);
122
+ return;
123
+ }
109
124
  if (action === 'browser') {
110
- browsers(outgoing, key);
125
+ browsers(outgoing, key, {
126
+ who: holderOf(incoming),
127
+ deadline: deadlineOf(incoming),
128
+ id: holdIdOf(incoming),
129
+ });
111
130
  return;
112
131
  }
113
132
  if (action === 'share' || action === 'own') {
114
133
  const stack = decodeURIComponent(key);
115
- holdOpen(
116
- outgoing,
117
- async (gone) => locks.take(stack, action, gone),
118
- () => {
134
+ holdOpen(outgoing, {
135
+ take: async (gone) => locks.take(stack, action, gone),
136
+ give: () => {
119
137
  locks.give(stack, action);
120
138
  },
121
- );
139
+ what: `a ${action} hold on ${stack}`,
140
+ who: holderOf(incoming),
141
+ deadline: deadlineOf(incoming),
142
+ watch: locks.watch(stack),
143
+ id: holdIdOf(incoming),
144
+ });
122
145
  return;
123
146
  }
124
147
  const called = ACTIONS.find((known) => known === action);