@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.
- package/dist/testing/src/config-coverage.d.ts.map +1 -1
- package/dist/testing/src/config-coverage.js +3 -2
- package/dist/testing/src/config-coverage.js.map +1 -1
- package/dist/testing/src/fixtures.d.ts +9 -3
- package/dist/testing/src/fixtures.d.ts.map +1 -1
- package/dist/testing/src/fixtures.js +98 -42
- package/dist/testing/src/fixtures.js.map +1 -1
- package/dist/testing/src/floors/node.js +11 -3
- package/dist/testing/src/floors/node.js.map +1 -1
- package/dist/testing/src/stack/browsers.d.ts +13 -3
- package/dist/testing/src/stack/browsers.d.ts.map +1 -1
- package/dist/testing/src/stack/browsers.js +117 -14
- package/dist/testing/src/stack/browsers.js.map +1 -1
- package/dist/testing/src/stack/containers.d.ts +6 -2
- package/dist/testing/src/stack/containers.d.ts.map +1 -1
- package/dist/testing/src/stack/containers.js +8 -1
- package/dist/testing/src/stack/containers.js.map +1 -1
- package/dist/testing/src/stack/control.d.ts +18 -3
- package/dist/testing/src/stack/control.d.ts.map +1 -1
- package/dist/testing/src/stack/control.js +50 -10
- package/dist/testing/src/stack/control.js.map +1 -1
- package/dist/testing/src/stack/gate-member.d.ts +10 -0
- package/dist/testing/src/stack/gate-member.d.ts.map +1 -1
- package/dist/testing/src/stack/gate-member.js +68 -7
- package/dist/testing/src/stack/gate-member.js.map +1 -1
- package/dist/testing/src/stack/gate-server.d.ts +37 -4
- package/dist/testing/src/stack/gate-server.d.ts.map +1 -1
- package/dist/testing/src/stack/gate-server.js +124 -12
- package/dist/testing/src/stack/gate-server.js.map +1 -1
- package/dist/testing/src/stack/hold.d.ts +24 -3
- package/dist/testing/src/stack/hold.d.ts.map +1 -1
- package/dist/testing/src/stack/hold.js +136 -11
- package/dist/testing/src/stack/hold.js.map +1 -1
- package/dist/testing/src/stack/host.d.ts.map +1 -1
- package/dist/testing/src/stack/host.js +26 -6
- package/dist/testing/src/stack/host.js.map +1 -1
- package/dist/testing/src/stack/lock.d.ts +15 -4
- package/dist/testing/src/stack/lock.d.ts.map +1 -1
- package/dist/testing/src/stack/lock.js +47 -18
- package/dist/testing/src/stack/lock.js.map +1 -1
- package/dist/testing/src/stack/preview-child.js +14 -3
- package/dist/testing/src/stack/preview-child.js.map +1 -1
- package/dist/testing/src/stack/runtime-apple.d.ts.map +1 -1
- package/dist/testing/src/stack/runtime-apple.js +16 -0
- package/dist/testing/src/stack/runtime-apple.js.map +1 -1
- package/dist/testing/src/stack/runtime-docker.d.ts +2 -1
- package/dist/testing/src/stack/runtime-docker.d.ts.map +1 -1
- package/dist/testing/src/stack/runtime-docker.js +29 -3
- package/dist/testing/src/stack/runtime-docker.js.map +1 -1
- package/dist/testing/src/stack/runtime.d.ts +10 -0
- package/dist/testing/src/stack/runtime.d.ts.map +1 -1
- package/dist/testing/src/stack/runtime.js +1 -0
- package/dist/testing/src/stack/runtime.js.map +1 -1
- package/dist/testing/src/stack/socks.d.ts +25 -0
- package/dist/testing/src/stack/socks.d.ts.map +1 -0
- package/dist/testing/src/stack/socks.js +195 -0
- package/dist/testing/src/stack/socks.js.map +1 -0
- package/dist/testing/src/stack/stall.d.ts +36 -0
- package/dist/testing/src/stack/stall.d.ts.map +1 -0
- package/dist/testing/src/stack/stall.js +93 -0
- package/dist/testing/src/stack/stall.js.map +1 -0
- package/package.json +4 -4
- package/testing/src/config-coverage.ts +3 -2
- package/testing/src/fixtures.ts +277 -197
- package/testing/src/floors/node.ts +19 -3
- package/testing/src/stack/browsers.ts +129 -15
- package/testing/src/stack/containers.ts +14 -2
- package/testing/src/stack/control.ts +62 -11
- package/testing/src/stack/gate-member.ts +90 -8
- package/testing/src/stack/gate-server.ts +166 -21
- package/testing/src/stack/hold.ts +163 -11
- package/testing/src/stack/host.ts +32 -9
- package/testing/src/stack/lock.ts +61 -21
- package/testing/src/stack/preview-child.ts +21 -4
- package/testing/src/stack/runtime-apple.ts +20 -1
- package/testing/src/stack/runtime-docker.ts +47 -3
- package/testing/src/stack/runtime.ts +11 -0
- package/testing/src/stack/socks.ts +233 -0
- 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
|
-
|
|
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:
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
181
|
-
|
|
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 (
|
|
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
|
|
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 {
|
|
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
|
-
/**
|
|
46
|
-
*
|
|
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
|
-
|
|
174
|
+
asking: Asking = {},
|
|
51
175
|
): Promise<() => void> => {
|
|
176
|
+
const { signal, id = randomUUID() } = asking;
|
|
52
177
|
const { promise, resolve, reject } = Promise.withResolvers<() => void>();
|
|
53
|
-
const
|
|
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
|
|
56
|
-
//
|
|
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
|
-
|
|
61
|
-
|
|
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
|
|
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 {
|
|
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.
|
|
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
|
-
|
|
117
|
-
|
|
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);
|