@tonalisk-labs/kit 1.0.0 → 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 (80) hide show
  1. package/README.md +7 -80
  2. package/dist/testing/src/config-coverage.d.ts.map +1 -1
  3. package/dist/testing/src/config-coverage.js +3 -2
  4. package/dist/testing/src/config-coverage.js.map +1 -1
  5. package/dist/testing/src/fixtures.d.ts +9 -3
  6. package/dist/testing/src/fixtures.d.ts.map +1 -1
  7. package/dist/testing/src/fixtures.js +98 -42
  8. package/dist/testing/src/fixtures.js.map +1 -1
  9. package/dist/testing/src/floors/node.js +11 -3
  10. package/dist/testing/src/floors/node.js.map +1 -1
  11. package/dist/testing/src/stack/browsers.d.ts +13 -3
  12. package/dist/testing/src/stack/browsers.d.ts.map +1 -1
  13. package/dist/testing/src/stack/browsers.js +117 -14
  14. package/dist/testing/src/stack/browsers.js.map +1 -1
  15. package/dist/testing/src/stack/containers.d.ts +6 -2
  16. package/dist/testing/src/stack/containers.d.ts.map +1 -1
  17. package/dist/testing/src/stack/containers.js +8 -1
  18. package/dist/testing/src/stack/containers.js.map +1 -1
  19. package/dist/testing/src/stack/control.d.ts +18 -3
  20. package/dist/testing/src/stack/control.d.ts.map +1 -1
  21. package/dist/testing/src/stack/control.js +50 -10
  22. package/dist/testing/src/stack/control.js.map +1 -1
  23. package/dist/testing/src/stack/gate-member.d.ts +10 -0
  24. package/dist/testing/src/stack/gate-member.d.ts.map +1 -1
  25. package/dist/testing/src/stack/gate-member.js +68 -7
  26. package/dist/testing/src/stack/gate-member.js.map +1 -1
  27. package/dist/testing/src/stack/gate-server.d.ts +37 -4
  28. package/dist/testing/src/stack/gate-server.d.ts.map +1 -1
  29. package/dist/testing/src/stack/gate-server.js +124 -12
  30. package/dist/testing/src/stack/gate-server.js.map +1 -1
  31. package/dist/testing/src/stack/hold.d.ts +24 -3
  32. package/dist/testing/src/stack/hold.d.ts.map +1 -1
  33. package/dist/testing/src/stack/hold.js +136 -11
  34. package/dist/testing/src/stack/hold.js.map +1 -1
  35. package/dist/testing/src/stack/host.d.ts.map +1 -1
  36. package/dist/testing/src/stack/host.js +26 -6
  37. package/dist/testing/src/stack/host.js.map +1 -1
  38. package/dist/testing/src/stack/lock.d.ts +15 -4
  39. package/dist/testing/src/stack/lock.d.ts.map +1 -1
  40. package/dist/testing/src/stack/lock.js +47 -18
  41. package/dist/testing/src/stack/lock.js.map +1 -1
  42. package/dist/testing/src/stack/preview-child.js +14 -3
  43. package/dist/testing/src/stack/preview-child.js.map +1 -1
  44. package/dist/testing/src/stack/runtime-apple.d.ts.map +1 -1
  45. package/dist/testing/src/stack/runtime-apple.js +16 -0
  46. package/dist/testing/src/stack/runtime-apple.js.map +1 -1
  47. package/dist/testing/src/stack/runtime-docker.d.ts +2 -1
  48. package/dist/testing/src/stack/runtime-docker.d.ts.map +1 -1
  49. package/dist/testing/src/stack/runtime-docker.js +29 -3
  50. package/dist/testing/src/stack/runtime-docker.js.map +1 -1
  51. package/dist/testing/src/stack/runtime.d.ts +10 -0
  52. package/dist/testing/src/stack/runtime.d.ts.map +1 -1
  53. package/dist/testing/src/stack/runtime.js +1 -0
  54. package/dist/testing/src/stack/runtime.js.map +1 -1
  55. package/dist/testing/src/stack/socks.d.ts +25 -0
  56. package/dist/testing/src/stack/socks.d.ts.map +1 -0
  57. package/dist/testing/src/stack/socks.js +195 -0
  58. package/dist/testing/src/stack/socks.js.map +1 -0
  59. package/dist/testing/src/stack/stall.d.ts +36 -0
  60. package/dist/testing/src/stack/stall.d.ts.map +1 -0
  61. package/dist/testing/src/stack/stall.js +93 -0
  62. package/dist/testing/src/stack/stall.js.map +1 -0
  63. package/package.json +4 -4
  64. package/testing/src/config-coverage.ts +3 -2
  65. package/testing/src/fixtures.ts +277 -197
  66. package/testing/src/floors/node.ts +19 -3
  67. package/testing/src/stack/browsers.ts +129 -15
  68. package/testing/src/stack/containers.ts +14 -2
  69. package/testing/src/stack/control.ts +62 -11
  70. package/testing/src/stack/gate-member.ts +90 -8
  71. package/testing/src/stack/gate-server.ts +166 -21
  72. package/testing/src/stack/hold.ts +163 -11
  73. package/testing/src/stack/host.ts +32 -9
  74. package/testing/src/stack/lock.ts +61 -21
  75. package/testing/src/stack/preview-child.ts +21 -4
  76. package/testing/src/stack/runtime-apple.ts +20 -1
  77. package/testing/src/stack/runtime-docker.ts +47 -3
  78. package/testing/src/stack/runtime.ts +11 -0
  79. package/testing/src/stack/socks.ts +233 -0
  80. package/testing/src/stack/stall.ts +145 -0
@@ -4,19 +4,38 @@ import { availableParallelism } from 'node:os';
4
4
  import path from 'node:path';
5
5
 
6
6
  import { isObject } from '../unknown.ts';
7
- import { container } from './containers.ts';
7
+ import { container, exec, networkOf } from './containers.ts';
8
8
  import type { Service } from './process.ts';
9
9
  import type { ContainerSpec } from './runtime.ts';
10
+ import { type Socks, startSocks } from './socks.ts';
10
11
  import { LOCAL_CA_DIR, localCaCertificate } from './tls.ts';
11
12
 
13
+ /** The engines the image serves, one browser each: the visual projects' Chromium, and WebKit for
14
+ * the rows macOS can't run it on. */
15
+ const ENGINES = ['chromium', 'webkit'] as const;
16
+
17
+ type Engine = (typeof ENGINES)[number];
18
+
19
+ /** Each engine's browser on a port of its own, at a path named for it. */
20
+ const PORTS: Readonly<Record<Engine, number>> = { chromium: 3000, webkit: 3001 };
21
+
22
+ /** Where each engine's browser is served, by engine. */
23
+ type Endpoints = Readonly<Record<Engine, string>>;
24
+
12
25
  export interface BrowserServer extends Service {
13
- readonly wsEndpoint: string;
26
+ readonly endpoints: Endpoints;
14
27
  }
15
28
 
16
- const PORT = 3000;
29
+ /** The endpoint of the image's browser for `engine` in `given`, the stack host's variable; none
30
+ * where the variable is unset or names no such engine. */
31
+ export const endpointOf = (engine: string, given?: string): string | undefined => {
32
+ const parsed: unknown = given === undefined ? undefined : JSON.parse(given);
33
+ const endpoint = isObject(parsed) ? parsed[engine] : undefined;
34
+ return typeof endpoint === 'string' ? endpoint : undefined;
35
+ };
17
36
 
18
- /** Browser contexts the image serves at once, a seat each: 4 in one browser peak at 2,272 MiB, 4
19
- * browsers of one each at 2,947, and neither stalls nor is killed. */
37
+ /** Browser contexts the image serves at once, a seat each, taken by a test for its own time: each
38
+ * engine's one browser is shared by every worker, so a connection costs nothing. */
20
39
  export const BROWSER_SEATS = 4;
21
40
 
22
41
  /** A test's devices against the `declared` contexts it may have open, its own included: each is
@@ -40,8 +59,8 @@ export const deviceBudget = (declared: number): (() => () => void) => {
40
59
  };
41
60
  };
42
61
 
43
- /** A browser costs about 225 MiB and a context about 460; the worst full pool, 4 browsers, peaked
44
- * at 2,947 MiB of these 3,328 with no stall: a VM keeps what it touched. */
62
+ /** A browser costs about 225 MiB and a context about 460, so the two browsers and a full pool's
63
+ * contexts come to about 2,290 MiB of these 3,328: a VM keeps what it touched. */
45
64
  const MACHINE = { memory: 3328, cpus: availableParallelism() } as const;
46
65
 
47
66
  /** The playwright-core the tests run, mounted into the image of the same release. */
@@ -63,11 +82,60 @@ const LOG_FILE = '/tmp/browsers.log';
63
82
  * server's Node makes a context's own requests, as `route.fetch` and `context.request` do. */
64
83
  const CA_FILE = '/usr/local/share/ca-certificates/tonalisk-kit-edge.crt';
65
84
 
66
- /** Takes the local CA's certificate into the image's store, then serves. */
85
+ /** The pipe the host writes the proxy's address to, once it listens, and the IPv6 address the
86
+ * container's network gives it: only then may the browsers launch, as each takes its proxy at its
87
+ * launch. */
88
+ const PROXY_PIPE = '/tmp/proxy';
89
+
90
+ /** Launches each engine's browser behind the host's proxy, serving it to every worker. A browser
91
+ * that crashes fails the tests it was serving, as one per worker did, and is launched again at
92
+ * the same address for the workers that follow. On the container's stop, Playwright closes each
93
+ * browser and keeps the process; it exits once both are closed, launching none again. */
94
+ const SERVE = `
95
+ const playwright = require('/playwright-core');
96
+ const engines = ${JSON.stringify(Object.entries(PORTS))};
97
+ const proxy = { server: process.env.PROXY };
98
+ const state = { stopping: false, open: engines.length };
99
+ // A browser launched before its network has given the container its IPv6 address hears the
100
+ // address arrive as a network change, and Chromium fails the navigations it has open
101
+ // (ERR_NETWORK_CHANGED): the browsers launch once it's there, or after 30 s without it.
102
+ const settled = async (left = 300) => {
103
+ const wanted = process.env.SETTLED;
104
+ const addresses = Object.values(require('node:os').networkInterfaces()).flat();
105
+ if (!wanted || left === 0 || addresses.some((one) => one.address === wanted)) return;
106
+ await new Promise((resolve) => setTimeout(resolve, 100));
107
+ await settled(left - 1);
108
+ };
109
+ process.once('SIGTERM', () => { state.stopping = true; });
110
+ const failed = (error) => { console.error(error); process.exit(1); };
111
+ const serve = async ([engine, port]) => {
112
+ const server = await playwright[engine].launchServer({ port, host: '0.0.0.0', wsPath: '/' + engine, proxy });
113
+ server.once('close', () => {
114
+ if (state.stopping) {
115
+ state.open -= 1;
116
+ if (state.open === 0) process.exit(0);
117
+ return;
118
+ }
119
+ console.log(engine + ' closed, launched again');
120
+ serve([engine, port]).catch(failed);
121
+ });
122
+ console.log(engine + ' serves ' + server.wsEndpoint());
123
+ };
124
+ (async () => {
125
+ await settled();
126
+ for (const entry of engines) await serve(entry);
127
+ })().catch(failed);
128
+ `;
129
+
130
+ /** Takes the local CA's certificate into the image's store, waits for the proxy's address, then
131
+ * serves. A pipe the host wrote first is a plain file, which reads as well. */
67
132
  const TRUSTED_SERVER = [
68
133
  String.raw`printf "%s\n" "$EDGE_CA" > ${CA_FILE}`,
69
134
  'update-ca-certificates > /dev/null',
70
- `exec node /playwright-core/cli.js run-server --port ${String(PORT)} --host 0.0.0.0 > ${LOG_FILE} 2>&1`,
135
+ `{ mkfifo ${PROXY_PIPE} 2> /dev/null || true; }`,
136
+ `{ read -r PROXY && read -r SETTLED; } < ${PROXY_PIPE}`,
137
+ 'export PROXY SETTLED',
138
+ `exec node -e "$1" > ${LOG_FILE} 2>&1`,
71
139
  ].join(' && ');
72
140
 
73
141
  /** The image serving `core`'s browsers, trusting the CA in `ca`: a certificate passed in its
@@ -77,17 +145,40 @@ export const browserServerSpec = (
77
145
  ca: string,
78
146
  ): ContainerSpec => ({
79
147
  image: `mcr.microsoft.com/playwright:v${core.version}-noble`,
80
- ports: [PORT],
81
- ready: ['Listening on'],
148
+ ports: Object.values(PORTS),
149
+ ready: ENGINES.map((engine) => `${engine} serves`),
82
150
  logFile: LOG_FILE,
83
151
  env: { EDGE_CA: ca, NODE_EXTRA_CA_CERTS: CA_FILE },
84
152
  init: true,
85
153
  sharesMemory: true,
86
154
  machine: MACHINE,
87
155
  files: [core.root, '/playwright-core'],
88
- command: ['sh', '-c', TRUSTED_SERVER],
156
+ command: ['sh', '-c', TRUSTED_SERVER, 'sh', SERVE],
89
157
  });
90
158
 
159
+ /** Starts the host's proxy for the container `id`, on the host's address on its network and for
160
+ * its address alone, and gives the browsers its address. */
161
+ const proxyFor = async (id: string, signal: AbortSignal): Promise<Socks> => {
162
+ const { own, host, settled = '' } = await networkOf(id);
163
+ const socks = await startSocks({ host, peer: own });
164
+ try {
165
+ const address = `socks5://${host}:${String(socks.port)}`;
166
+ const told = [
167
+ 'sh',
168
+ '-c',
169
+ `printf "%s\\n%s\\n" "$1" "$2" > ${PROXY_PIPE}`,
170
+ 'sh',
171
+ address,
172
+ settled,
173
+ ];
174
+ await exec(id, told, signal);
175
+ return socks;
176
+ } catch (error) {
177
+ await socks.stop();
178
+ throw error;
179
+ }
180
+ };
181
+
91
182
  /** Playwright's browsers in its official Linux image, served to the host's tests so screenshots
92
183
  * render the same on every machine, trusting the local CA in `caDir` that signs every edge. */
93
184
  export const startBrowserServer = async (
@@ -95,7 +186,30 @@ export const startBrowserServer = async (
95
186
  caDir = LOCAL_CA_DIR,
96
187
  ): Promise<BrowserServer> => {
97
188
  const ca = await localCaCertificate(caDir, signal);
98
- const server = await container(browserServerSpec(playwrightCore(), ca), signal);
99
- const { host, port } = server.address(PORT);
100
- return { wsEndpoint: `ws://${host}:${String(port)}/`, stop: server.stop };
189
+ const proxied: { socks?: Socks } = {};
190
+ const started = async (id: string): Promise<void> => {
191
+ proxied.socks = await proxyFor(id, signal);
192
+ };
193
+ const stopProxy = async (): Promise<void> => {
194
+ if (proxied.socks !== undefined) {
195
+ await proxied.socks.stop();
196
+ }
197
+ };
198
+ const server = await container(browserServerSpec(playwrightCore(), ca), signal, started).catch(
199
+ async (error: unknown) => {
200
+ await stopProxy();
201
+ throw error;
202
+ },
203
+ );
204
+ const endpointFor = (engine: Engine): string => {
205
+ const { host, port } = server.address(PORTS[engine]);
206
+ return `ws://${host}:${String(port)}/${engine}`;
207
+ };
208
+ return {
209
+ endpoints: { chromium: endpointFor('chromium'), webkit: endpointFor('webkit') },
210
+ stop: async () => {
211
+ await server.stop();
212
+ await stopProxy();
213
+ },
214
+ };
101
215
  };
@@ -3,7 +3,7 @@ import type { Service } from './process.ts';
3
3
  import { isGone, RUN } from './run.ts';
4
4
  import { appleContainerOf } from './runtime-apple.ts';
5
5
  import { dockerOf } from './runtime-docker.ts';
6
- import type { Address, ContainerSpec, Runtime } from './runtime.ts';
6
+ import type { Address, ContainerSpec, Network, Runtime } from './runtime.ts';
7
7
 
8
8
  export interface Container extends Service {
9
9
  readonly id: string;
@@ -83,7 +83,13 @@ const addressIn =
83
83
  return found;
84
84
  };
85
85
 
86
- export const container = async (spec: ContainerSpec, signal: AbortSignal): Promise<Container> => {
86
+ /** A container started from `spec` once it says it's ready; `started` runs as it starts, before
87
+ * that, for a service that waits on something the host gives it. */
88
+ export const container = async (
89
+ spec: ContainerSpec,
90
+ signal: AbortSignal,
91
+ started?: (id: string) => Promise<void>,
92
+ ): Promise<Container> => {
87
93
  signal.throwIfAborted();
88
94
  await sweptOnce();
89
95
  const { id, forget } = await created(spec);
@@ -92,6 +98,9 @@ export const container = async (spec: ContainerSpec, signal: AbortSignal): Promi
92
98
  forget();
93
99
  };
94
100
  try {
101
+ if (started !== undefined) {
102
+ await started(id);
103
+ }
95
104
  const texts = spec.readyInAnyOrder === true ? spec.ready.map((text) => [text]) : [spec.ready];
96
105
  await Promise.all(
97
106
  texts.map(async (text) => RUNTIME.ready(id, { texts: text, logFile: spec.logFile }, signal)),
@@ -104,6 +113,9 @@ export const container = async (spec: ContainerSpec, signal: AbortSignal): Promi
104
113
  }
105
114
  };
106
115
 
116
+ /** A running container's address on its network, and the host's there. */
117
+ export const networkOf = async (id: string): Promise<Network> => RUNTIME.network(id);
118
+
107
119
  export const exec = async (
108
120
  id: string,
109
121
  command: readonly string[],
@@ -2,8 +2,9 @@ import { request } from 'node:http';
2
2
 
3
3
  import { test } from '@playwright/test';
4
4
 
5
+ import { holdCpuTurn } from './gate-member.ts';
5
6
  import { WHOLE } from './gate-server.ts';
6
- import { type HoldKind, openHold } from './hold.ts';
7
+ import { openHold } from './hold.ts';
7
8
 
8
9
  /** The variable naming the stack host, set by the global setup for every worker. */
9
10
  export const HOST_ENV = 'KIT_STACK_HOST';
@@ -21,7 +22,8 @@ export const STACK_ENV = 'KIT_STACK';
21
22
  * a keychain prompt, and the visual projects' Chromium, so screenshots match on every machine. */
22
23
  export const LINUX_BROWSERS = 'linux-browsers';
23
24
 
24
- /** The variable WebKit rows and the visual projects read that server's endpoint from. */
25
+ /** The variable WebKit rows and the visual projects read that server's endpoints from, by engine,
26
+ * in JSON. */
25
27
  export const LINUX_ENV = 'KIT_LINUX_BROWSERS';
26
28
 
27
29
  export type Action = 'start' | 'stop' | 'restart';
@@ -65,6 +67,59 @@ export const control = async (action: Action, key: string): Promise<Record<strin
65
67
  return promise;
66
68
  };
67
69
 
70
+ /** The running test's clock, as Playwright keeps its timeout: when it began, and how long it has
71
+ * waited for holds in fixtures of their own timeout, which aren't charged to it. */
72
+ export interface TestClock {
73
+ start: number;
74
+ waited: number;
75
+ }
76
+
77
+ /** This worker's running test's clock: a worker runs one test at a time. */
78
+ const running: TestClock = { start: Date.now(), waited: 0 };
79
+
80
+ /** Starts the running test's clock (fixtures.ts), at `now`. */
81
+ export const testBegins = (now: number = Date.now()): void => {
82
+ running.start = now;
83
+ running.waited = 0;
84
+ };
85
+
86
+ /** When a test must end, by Playwright's count: its start, plus the hold waits it isn't charged,
87
+ * plus its timeout; none for a test with no timeout. A hold's server lets a stillness pass while
88
+ * its holders are inside their deadlines; the test moves them as its timeout moves. */
89
+ export const deadlineFrom = (timeout: number, clock: TestClock = running): number | undefined =>
90
+ timeout > 0 ? clock.start + clock.waited + timeout : undefined;
91
+
92
+ /** `wait` for a hold, its time added to the running test's waits, which its deadline is past. */
93
+ export const waitedFor = async <Value>(wait: () => Promise<Value>): Promise<Value> => {
94
+ const asked = Date.now();
95
+ try {
96
+ return await wait();
97
+ } finally {
98
+ running.waited += Date.now() - asked;
99
+ }
100
+ };
101
+
102
+ const holderDeadline = (): number | undefined => {
103
+ try {
104
+ return deadlineFrom(test.info().timeout);
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ };
109
+
110
+ /** Who asks for a hold, for its server's holder record: the running test, if any, its worker and
111
+ * its process, so a wait past its bound names who held what. */
112
+ const holderName = (): string => {
113
+ const worker = process.env['TEST_WORKER_INDEX'];
114
+ const where = `${worker === undefined ? '' : `worker ${worker}, `}pid ${String(process.pid)}`;
115
+ try {
116
+ const info = test.info();
117
+ return `${info.project.name} › ${info.title} (${where})`;
118
+ } catch {
119
+ return where;
120
+ }
121
+ };
122
+
68
123
  /**
69
124
  * Holds a target's stack, shared or owned alone, until the returned release is called: the hold
70
125
  * is an open request, so a worker that dies releases it by closing its connection.
@@ -79,10 +134,11 @@ export const holdStack = async (hold: 'share' | 'own', key: string): Promise<()
79
134
  held: `a ${hold} hold on ${key}`,
80
135
  refused: () => `the stack host refused a ${hold} hold on ${key}`,
81
136
  },
137
+ { who: holderName(), deadline: holderDeadline() },
82
138
  );
83
139
 
84
- /** A seat among the Linux image's browsers for each context the worker's tests have open at once,
85
- * all taken together and held as a stack hold is, for the worker's life. */
140
+ /** A seat among the Linux image's browsers for each context the test may have open at once, all
141
+ * taken together and held as a stack hold is, for the test's life. */
86
142
  export const holdBrowserSeat = async (contexts: number): Promise<() => void> =>
87
143
  openHold(
88
144
  new URL(
@@ -96,19 +152,14 @@ export const holdBrowserSeat = async (contexts: number): Promise<() => void> =>
96
152
  ? `the Linux browsers seat fewer than the ${String(contexts)} browserContexts declared`
97
153
  : 'the stack host refused seats among the Linux browsers',
98
154
  },
155
+ { who: holderName(), deadline: holderDeadline() },
99
156
  );
100
157
 
101
158
  const runTurns = (): string => required(CPU_GATE_ENV, 'the global setup joins the CPU gate');
102
159
 
103
- const TURN: HoldKind = {
104
- held: 'a CPU turn',
105
- refused: (status) =>
106
- `the CPU gate refused a turn (${String(status)}): the run has left it, or lost it`,
107
- };
108
-
109
160
  /** A CPU turn, until the returned release; an abort gives up a turn still waiting. */
110
161
  export const holdTurn = async (turns: string, signal?: AbortSignal): Promise<() => void> =>
111
- openHold(new URL(turns), TURN, signal);
162
+ holdCpuTurn(turns, { signal, who: holderName(), deadline: holderDeadline() });
112
163
 
113
164
  /** A test's CPU turn: its release, its widening once claimed, and the test's end, which gives up
114
165
  * a widening still waiting, so no every-slot turn is granted after its test. */
@@ -11,10 +11,15 @@ import {
11
11
  } from 'node:http';
12
12
 
13
13
  import {
14
+ answerDeadline,
15
+ answerHolders,
14
16
  GATE_MARK,
15
17
  GATE_PORT,
16
18
  GATE_SCRIPT,
17
19
  GATE_VERSION,
20
+ deadlineOf,
21
+ holderOf,
22
+ holdIdOf,
18
23
  holdOpen,
19
24
  LAUNCH,
20
25
  SERVES,
@@ -183,7 +188,13 @@ const notYet = (error: unknown): boolean => isAbsent(error) || statusOf(error) =
183
188
  * can't give it. One the gate ends cuts the holder's off, or ends it whole once the run let go. */
184
189
  const turnAtGate = async (
185
190
  held: Held,
186
- asked: { readonly whole: boolean; readonly holder: ServerResponse },
191
+ asked: {
192
+ readonly whole: boolean;
193
+ readonly holder: ServerResponse;
194
+ readonly who: string;
195
+ readonly deadline: number | undefined;
196
+ readonly id: string | undefined;
197
+ },
187
198
  gone: AbortSignal,
188
199
  ): Promise<() => void> => {
189
200
  const { promise: changed } = held.changed;
@@ -201,7 +212,8 @@ const turnAtGate = async (
201
212
  },
202
213
  };
203
214
  try {
204
- return await openHold(url, kind, gone);
215
+ const { who, deadline, id } = asked;
216
+ return await openHold(url, kind, { signal: gone, who, deadline, id });
205
217
  } catch (error) {
206
218
  if (held.left || !notYet(error)) {
207
219
  throw held.left ? new Error('the run has left the CPU gate', { cause: held.reason }) : error;
@@ -211,22 +223,54 @@ const turnAtGate = async (
211
223
  }
212
224
  };
213
225
 
226
+ /** A turn's moved deadline, passed on to the gate the turn is held at, which a gate too old to
227
+ * know the route refuses: the turn keeps the deadline it was asked with. */
228
+ const passDeadline = (port: number, path: string): void => {
229
+ const call = request(`http://127.0.0.1:${String(port)}${path}`, { method: 'POST' }, (answer) => {
230
+ answer.resume();
231
+ });
232
+ call.once('error', () => {
233
+ // A gate gone: there is no turn there to move.
234
+ });
235
+ call.end();
236
+ };
237
+
214
238
  /** Serves the run's turns to its holders, in this process or its workers, at a local URL; the
215
239
  * server lets the process end, and stops taking turns as the run leaves. */
216
240
  const serveTurns = async (held: Held): Promise<Server> => {
217
241
  const server = createServer({ requestTimeout: 0 }, (incoming, holder) => {
218
242
  incoming.resume();
219
- const asked = { whole: incoming.url === `/cpu/${WHOLE}`, holder };
220
- const turn = { release: (): void => undefined };
221
- holdOpen(
243
+ const path = incoming.url ?? '';
244
+ if (path === '/holders') {
245
+ answerHolders(holder);
246
+ return;
247
+ }
248
+ if (path.startsWith('/deadline/')) {
249
+ const parts = path.split('/');
250
+ answerDeadline(holder, parts[2] ?? '', parts[3] ?? '');
251
+ passDeadline(held.port, path);
252
+ return;
253
+ }
254
+ const asked = {
255
+ whole: path === `/cpu/${WHOLE}`,
222
256
  holder,
223
- async (gone) => {
257
+ who: holderOf(incoming),
258
+ deadline: deadlineOf(incoming),
259
+ id: holdIdOf(incoming),
260
+ };
261
+ const turn = { release: (): void => undefined };
262
+ holdOpen(holder, {
263
+ take: async (gone) => {
224
264
  turn.release = await turnAtGate(held, asked, gone);
225
265
  },
226
- () => {
266
+ give: () => {
227
267
  turn.release();
228
268
  },
229
- );
269
+ what: asked.whole ? 'every CPU slot' : 'a CPU turn',
270
+ who: asked.who,
271
+ deadline: asked.deadline,
272
+ id: asked.id,
273
+ });
230
274
  });
231
275
  server.listen(0, '127.0.0.1');
232
276
  await once(server, 'listening');
@@ -268,3 +312,41 @@ export const joinGate = async (
268
312
  },
269
313
  };
270
314
  };
315
+
316
+ /** What a CPU turn is called, and what a refusal of one says. */
317
+ const TURN: HoldKind = {
318
+ held: 'a CPU turn',
319
+ refused: (status) =>
320
+ `the CPU gate refused a turn (${String(status)}): the run has left it, or lost it`,
321
+ };
322
+
323
+ /** A CPU turn at `turns` (a run's, or with `/whole`, every slot), until the returned release; an
324
+ * abort gives up a turn still waiting. */
325
+ export const holdCpuTurn = async (
326
+ turns: string,
327
+ asking: {
328
+ readonly signal?: AbortSignal | undefined;
329
+ readonly who: string;
330
+ readonly deadline?: number | undefined;
331
+ },
332
+ ): Promise<() => void> => openHold(new URL(turns), TURN, asking);
333
+
334
+ /** Runs `work` holding every slot of the CPU gate on `port`, joined for it alone, once the gate's
335
+ * queue reaches it: no other run's tests share the cores while it runs. */
336
+ export const inWholeTurn = async <Value>(
337
+ work: () => Promise<Value>,
338
+ who: string,
339
+ port = GATE_PORT,
340
+ ): Promise<Value> => {
341
+ const gate = await joinGate(port);
342
+ try {
343
+ const release = await holdCpuTurn(`${gate.turns}/${WHOLE}`, { who });
344
+ try {
345
+ return await work();
346
+ } finally {
347
+ release();
348
+ }
349
+ } finally {
350
+ gate.leave();
351
+ }
352
+ };