@specific.dev/spectest 0.68.0 → 0.70.0
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/browser.js +1 -1
- package/dist/daemon.js +144 -118
- package/dist/harness/hostmatch.d.ts +17 -0
- package/dist/harness/hostmatch.js +33 -0
- package/dist/harness/ingress-table.d.ts +29 -18
- package/dist/harness/ingress-table.js +28 -17
- package/dist/harness/tls-terminator.d.ts +32 -0
- package/dist/harness/tls-terminator.js +199 -0
- package/dist/index.js +3 -3
- package/dist/locator-errors.js +1 -1
- package/dist/locator.d.ts +10 -5
- package/dist/locator.js +11 -6
- package/dist/terminal.js +4 -1
- package/package.json +1 -1
- package/src/browser.ts +1 -1
- package/src/daemon.ts +140 -121
- package/src/harness/hostmatch.ts +35 -0
- package/src/harness/ingress-rebind.test.ts +18 -10
- package/src/harness/ingress-table.test.ts +23 -8
- package/src/harness/ingress-table.ts +30 -19
- package/src/harness/tls-terminator.test.ts +240 -0
- package/src/harness/tls-terminator.ts +244 -0
- package/src/index.ts +3 -3
- package/src/locator-errors.ts +1 -1
- package/src/locator.ts +12 -7
- package/src/terminal.ts +4 -1
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adding a certificate must cost no connection.
|
|
3
|
+
*
|
|
4
|
+
* This is the file the previous shape of :443 could not have: when the
|
|
5
|
+
* listener was a `Bun.serve` whose `tls` array was fixed at serve time,
|
|
6
|
+
* every new certificate meant a swap, and the best a swap could do was
|
|
7
|
+
* save requests that were already in flight (`ingress-rebind.test.ts`).
|
|
8
|
+
* The failure that kept reaching production was the other half — an
|
|
9
|
+
* **idle kept-alive connection**, severed by `stop(false)` within 1-2 ms,
|
|
10
|
+
* surfacing in an unrelated test as `SocketError: other side closed`.
|
|
11
|
+
*
|
|
12
|
+
* So these drive real TLS sockets rather than a model of them: the whole
|
|
13
|
+
* bug was in socket behaviour, and a model would have agreed with the
|
|
14
|
+
* broken code. The client is Node's own `node:tls`/HTTP-over-socket so
|
|
15
|
+
* that keep-alive is under the test's control rather than a pool's.
|
|
16
|
+
*/
|
|
17
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
18
|
+
import { spawnSync } from "node:child_process";
|
|
19
|
+
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
20
|
+
import net from "node:net";
|
|
21
|
+
import { tmpdir } from "node:os";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
import tls from "node:tls";
|
|
24
|
+
|
|
25
|
+
import type { Leaf } from "./ingress-table";
|
|
26
|
+
import { selectCertName } from "./hostmatch";
|
|
27
|
+
import { startTlsTerminator, type TlsTerminator } from "./tls-terminator";
|
|
28
|
+
|
|
29
|
+
// ── a throwaway CA and leaves, so the client can verify for real ──────────
|
|
30
|
+
let dir: string;
|
|
31
|
+
const leaves = new Map<string, Leaf>();
|
|
32
|
+
let caPem: string;
|
|
33
|
+
|
|
34
|
+
function openssl(args: string[], input?: string): void {
|
|
35
|
+
const r = spawnSync("openssl", args, { cwd: dir, input, encoding: "utf8" });
|
|
36
|
+
if (r.status !== 0) throw new Error(`openssl ${args.join(" ")}: ${r.stderr}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function mintLeaf(name: string, san: string): Leaf {
|
|
40
|
+
const file = name.replace("*", "wild");
|
|
41
|
+
openssl(["req", "-newkey", "rsa:2048", "-keyout", `${file}.key`, "-out", `${file}.csr`,
|
|
42
|
+
"-nodes", "-subj", `/CN=${name}`]);
|
|
43
|
+
openssl(["x509", "-req", "-in", `${file}.csr`, "-CA", "ca.crt", "-CAkey", "ca.key",
|
|
44
|
+
"-CAcreateserial", "-out", `${file}.crt`, "-days", "2", "-extfile", "ext.cnf"]);
|
|
45
|
+
// -extfile is rewritten per call; write it first.
|
|
46
|
+
return {
|
|
47
|
+
cert: readFileSync(join(dir, `${file}.crt`), "utf8"),
|
|
48
|
+
key: readFileSync(join(dir, `${file}.key`), "utf8"),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
beforeAll(() => {
|
|
53
|
+
dir = mkdtempSync(join(tmpdir(), "spectest-tls-"));
|
|
54
|
+
openssl(["req", "-x509", "-newkey", "rsa:2048", "-keyout", "ca.key", "-out", "ca.crt",
|
|
55
|
+
"-days", "2", "-nodes", "-subj", "/CN=spectest-test-ca"]);
|
|
56
|
+
caPem = readFileSync(join(dir, "ca.crt"), "utf8");
|
|
57
|
+
for (const [name, san] of [
|
|
58
|
+
["app.test", "DNS:app.test"],
|
|
59
|
+
["late.test", "DNS:late.test"],
|
|
60
|
+
["*.db.test", "DNS:*.db.test"],
|
|
61
|
+
] as const) {
|
|
62
|
+
require("node:fs").writeFileSync(join(dir, "ext.cnf"), `subjectAltName=${san}\n`);
|
|
63
|
+
leaves.set(name, mintLeaf(name, san));
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
afterAll(() => rmSync(dir, { recursive: true, force: true }));
|
|
67
|
+
|
|
68
|
+
// ── the ingress shape: a plaintext Bun.serve behind the terminator ────────
|
|
69
|
+
const PORT = 19701;
|
|
70
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
71
|
+
let plain: any;
|
|
72
|
+
let term: TlsTerminator;
|
|
73
|
+
/** The live SNI table, exactly as the daemon mutates `INGRESS.certByHost`. */
|
|
74
|
+
let certByHost: Map<string, Leaf>;
|
|
75
|
+
/** What the plaintext server reported as the client's address, per request. */
|
|
76
|
+
let seenClientIp: string | undefined;
|
|
77
|
+
|
|
78
|
+
beforeAll(async () => {
|
|
79
|
+
certByHost = new Map([["app.test", leaves.get("app.test")!]]);
|
|
80
|
+
plain = Bun.serve({
|
|
81
|
+
port: 0,
|
|
82
|
+
hostname: "127.0.0.1",
|
|
83
|
+
idleTimeout: 0,
|
|
84
|
+
fetch(req, server) {
|
|
85
|
+
const peer = server.requestIP(req);
|
|
86
|
+
seenClientIp = term.clientIpFor(peer?.port) ?? peer?.address;
|
|
87
|
+
return new Response(`served ${new URL(req.url).hostname}`);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
term = await startTlsTerminator({
|
|
91
|
+
port: PORT,
|
|
92
|
+
hostname: "127.0.0.1",
|
|
93
|
+
upstreamPort: plain.port,
|
|
94
|
+
certFor: (serverName) => {
|
|
95
|
+
if (serverName === undefined) return certByHost.values().next().value;
|
|
96
|
+
const name = selectCertName(certByHost.keys(), serverName.toLowerCase());
|
|
97
|
+
return name === undefined ? undefined : certByHost.get(name);
|
|
98
|
+
},
|
|
99
|
+
onWarning: () => {
|
|
100
|
+
/* a client hanging up mid-handshake is a case under test */
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
term?.close();
|
|
106
|
+
plain?.stop(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const sockets: tls.TLSSocket[] = [];
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
for (const s of sockets.splice(0)) s.destroy();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/** A kept-alive TLS connection, handshaken and left open. */
|
|
115
|
+
async function connect(servername: string, localAddress?: string): Promise<tls.TLSSocket> {
|
|
116
|
+
const sock = tls.connect({
|
|
117
|
+
host: "127.0.0.1", port: PORT, servername, ca: caPem,
|
|
118
|
+
...(localAddress ? { localAddress } : {}),
|
|
119
|
+
});
|
|
120
|
+
sockets.push(sock);
|
|
121
|
+
await new Promise<void>((resolve, reject) => {
|
|
122
|
+
sock.once("secureConnect", () => resolve());
|
|
123
|
+
sock.once("error", reject);
|
|
124
|
+
});
|
|
125
|
+
return sock;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One keep-alive request on an existing connection. Rejects if the peer
|
|
129
|
+
* closed it instead of answering — which is exactly the reported bug. */
|
|
130
|
+
function request(sock: tls.TLSSocket, host: string): Promise<string> {
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
let buf = "";
|
|
133
|
+
const onData = (d: Buffer): void => {
|
|
134
|
+
buf += d.toString();
|
|
135
|
+
if (buf.includes("\r\n\r\n")) {
|
|
136
|
+
sock.off("data", onData);
|
|
137
|
+
resolve(buf);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
sock.on("data", onData);
|
|
141
|
+
sock.once("close", () => reject(new Error("other side closed")));
|
|
142
|
+
sock.once("error", (err) => reject(err));
|
|
143
|
+
sock.write(`GET / HTTP/1.1\r\nHost: ${host}\r\nConnection: keep-alive\r\n\r\n`);
|
|
144
|
+
setTimeout(() => reject(new Error("timed out")), 4000);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
describe("adding a certificate", () => {
|
|
149
|
+
/**
|
|
150
|
+
* THE regression. An idle pooled connection is what a client actually
|
|
151
|
+
* holds between polls, and a listener swap severed it: the next request
|
|
152
|
+
* written into that socket came back `SocketError: other side closed`,
|
|
153
|
+
* unretried, failing whichever test happened to be deploying. Adding a
|
|
154
|
+
* leaf to the live table must be invisible to it.
|
|
155
|
+
*/
|
|
156
|
+
test("an idle kept-alive connection survives it", async () => {
|
|
157
|
+
const sock = await connect("app.test");
|
|
158
|
+
expect(await request(sock, "app.test")).toContain("served app.test");
|
|
159
|
+
|
|
160
|
+
// …the connection now sits idle in the client's pool, as a CLI polling
|
|
161
|
+
// a deploy would leave it, while an unrelated service is provisioned.
|
|
162
|
+
certByHost.set("late.test", leaves.get("late.test")!);
|
|
163
|
+
await Bun.sleep(50);
|
|
164
|
+
|
|
165
|
+
expect(await request(sock, "app.test")).toContain("served app.test");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("many certificates later, the same connection is still good", async () => {
|
|
169
|
+
const sock = await connect("app.test");
|
|
170
|
+
await request(sock, "app.test");
|
|
171
|
+
for (let i = 0; i < 10; i++) {
|
|
172
|
+
certByHost.set(`svc-${i}.db.test`, leaves.get("*.db.test")!);
|
|
173
|
+
await Bun.sleep(5);
|
|
174
|
+
}
|
|
175
|
+
expect(await request(sock, "app.test")).toContain("served app.test");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/** The other side of the same coin: the point of adding a leaf is that
|
|
179
|
+
* it serves the next handshake, with no restart to wait for. */
|
|
180
|
+
test("the new hostname is served on the next handshake", async () => {
|
|
181
|
+
certByHost.set("late.test", leaves.get("late.test")!);
|
|
182
|
+
const sock = await connect("late.test");
|
|
183
|
+
expect((sock.getPeerCertificate().subject as { CN: string }).CN).toBe("late.test");
|
|
184
|
+
expect(await request(sock, "late.test")).toContain("served late.test");
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
/** A wildcard leaf serves one label under it — the same RFC 6125 rule
|
|
188
|
+
* `certCovers` plans against, now enforced at the handshake. */
|
|
189
|
+
test("a wildcard leaf serves a name one label under it", async () => {
|
|
190
|
+
certByHost.set("*.db.test", leaves.get("*.db.test")!);
|
|
191
|
+
const sock = await connect("tenant-7.db.test");
|
|
192
|
+
expect((sock.getPeerCertificate().subject as { CN: string }).CN).toBe("*.db.test");
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("the pipe", () => {
|
|
197
|
+
/** `x-forwarded-for` is built from this. The plaintext server's peer is
|
|
198
|
+
* the terminator, so without the port map every proxied request would
|
|
199
|
+
* tell the app under test its caller was 127.0.0.1. */
|
|
200
|
+
test("reports the real client address, not the loopback hop", async () => {
|
|
201
|
+
seenClientIp = undefined;
|
|
202
|
+
const sock = await connect("app.test", "127.0.0.2");
|
|
203
|
+
await request(sock, "app.test");
|
|
204
|
+
expect(seenClientIp).toBe("127.0.0.2");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
/** A handshake the terminator cannot complete must cost the process
|
|
208
|
+
* nothing: an unhandled 'error' on a node server is thrown, and taking
|
|
209
|
+
* the daemon down over one bad client would be worse than the bug. */
|
|
210
|
+
test("a client that speaks no TLS does not disturb the listener", async () => {
|
|
211
|
+
const raw = net.connect(PORT, "127.0.0.1");
|
|
212
|
+
await new Promise<void>((r) => raw.on("connect", () => r()));
|
|
213
|
+
raw.write("this is not a ClientHello\r\n\r\n");
|
|
214
|
+
await Bun.sleep(100);
|
|
215
|
+
raw.destroy();
|
|
216
|
+
|
|
217
|
+
const sock = await connect("app.test");
|
|
218
|
+
expect(await request(sock, "app.test")).toContain("served app.test");
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
/** A test VM is short-lived, but a fake that provisions in a loop opens
|
|
222
|
+
* a connection per probe — the terminator must not accumulate them. */
|
|
223
|
+
test("connections are released when they close", async () => {
|
|
224
|
+
// Earlier tests' sockets are destroyed in afterEach; let those close
|
|
225
|
+
// events land so this measures only what it opens itself.
|
|
226
|
+
const settle = async (): Promise<void> => {
|
|
227
|
+
for (let i = 0; i < 100 && term.connectionCount() > 0; i++) await Bun.sleep(20);
|
|
228
|
+
};
|
|
229
|
+
await settle();
|
|
230
|
+
expect(term.connectionCount()).toBe(0);
|
|
231
|
+
|
|
232
|
+
const sock = await connect("app.test");
|
|
233
|
+
await request(sock, "app.test");
|
|
234
|
+
expect(term.connectionCount()).toBe(1);
|
|
235
|
+
|
|
236
|
+
sock.destroy();
|
|
237
|
+
await settle();
|
|
238
|
+
expect(term.connectionCount()).toBe(0);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The :443 TLS terminator — one listener, for the life of the harness.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Ingress certificates are minted *while tests run*: a fake that
|
|
7
|
+
* provisions a database hands the app a CA-trusted `https://<new-host>/`
|
|
8
|
+
* endpoint, and that hostname did not exist a second earlier. Bun fixes a
|
|
9
|
+
* server's TLS configuration at `Bun.serve` time — `reload({ tls })` is
|
|
10
|
+
* accepted and then ignored (re-measured on Bun 1.4.0: the added
|
|
11
|
+
* `serverName` still gets the first entry's leaf) — so for as long as
|
|
12
|
+
* :443 was a `Bun.serve`, every new certificate meant swapping the
|
|
13
|
+
* listener.
|
|
14
|
+
*
|
|
15
|
+
* **No listener swap can be made safe.** Binding the replacement first
|
|
16
|
+
* under SO_REUSEPORT and draining the original with `stop(false)` saves a
|
|
17
|
+
* request that is already *in flight*, and that is all it saves: measured
|
|
18
|
+
* on Bun 1.4.0, `stop(false)` sends FIN to an **idle kept-alive
|
|
19
|
+
* connection within 1–2 ms**. A client that writes a request into that
|
|
20
|
+
* socket in the same instant gets `SocketError: other side closed`
|
|
21
|
+
* (`UND_ERR_SOCKET` under undici), and Node's `fetch` does not retry it.
|
|
22
|
+
* That is a flake in whatever unrelated test happened to be talking
|
|
23
|
+
* through the ingress — never in the one that provisioned the service —
|
|
24
|
+
* and it is why the app under test would die far from any ingress code.
|
|
25
|
+
*
|
|
26
|
+
* So the listener must stop being swapped, which means the certificate
|
|
27
|
+
* must be chosen **per handshake** rather than baked into the socket.
|
|
28
|
+
* That is what every real server does, and under Bun it is reachable
|
|
29
|
+
* through `node:tls`'s `SNICallback` (verified working on Bun 1.4.0:
|
|
30
|
+
* adding an entry to the table is served on the next handshake, with no
|
|
31
|
+
* restart and no effect on any established connection).
|
|
32
|
+
*
|
|
33
|
+
* ## The shape, and why it is a byte pipe
|
|
34
|
+
*
|
|
35
|
+
* The terminator does TLS and *nothing else*: it accepts on :443, picks a
|
|
36
|
+
* leaf by SNI, and pipes the decrypted stream to a plaintext `Bun.serve`
|
|
37
|
+
* bound on loopback. Everything downstream — routing, fakes,
|
|
38
|
+
* interceptors, reverse-proxying, and `server.upgrade()` WebSocket
|
|
39
|
+
* bridging — is the same `Bun.serve` code that already serves :80, run
|
|
40
|
+
* unchanged. Terminating HTTP here instead (`node:https`, `(req, res)`)
|
|
41
|
+
* would have meant reimplementing the WebSocket bridge against a
|
|
42
|
+
* different API, which is exactly the kind of trade this change exists to
|
|
43
|
+
* avoid: one flake removed, another introduced somewhere subtler.
|
|
44
|
+
*
|
|
45
|
+
* Measured cost of the extra hop on this host (Bun 1.4.0, undici client,
|
|
46
|
+
* 50 MB bodies): ~11 % fewer small requests per second, download
|
|
47
|
+
* throughput unchanged, upload ~500 MB/s against ~800 MB/s direct. Ingress
|
|
48
|
+
* traffic in a test VM is nowhere near either bound.
|
|
49
|
+
*
|
|
50
|
+
* ## Recovering the client's address
|
|
51
|
+
*
|
|
52
|
+
* The plaintext server's peer is the terminator, so `server.requestIP()`
|
|
53
|
+
* reports loopback and `x-forwarded-for` would lose the caller. The
|
|
54
|
+
* upstream socket's *source port* is unique among live connections, so the
|
|
55
|
+
* terminator records `source port → real client address` for the life of
|
|
56
|
+
* each connection and {@link TlsTerminator.clientIpFor} hands it back.
|
|
57
|
+
* Verified end to end: a client connecting from 127.0.0.2 is reported as
|
|
58
|
+
* 127.0.0.2 by the plaintext server behind the pipe.
|
|
59
|
+
*/
|
|
60
|
+
import net from "node:net";
|
|
61
|
+
import tls from "node:tls";
|
|
62
|
+
|
|
63
|
+
import type { Leaf } from "./ingress-table";
|
|
64
|
+
|
|
65
|
+
export interface TlsTerminatorOptions {
|
|
66
|
+
/** Public port to accept TLS on (443 in the harness). */
|
|
67
|
+
port: number;
|
|
68
|
+
/** Interface to accept on. */
|
|
69
|
+
hostname: string;
|
|
70
|
+
/** Loopback port of the plaintext ingress server to pipe into. */
|
|
71
|
+
upstreamPort: number;
|
|
72
|
+
/**
|
|
73
|
+
* The leaf to serve for an SNI server name, read live on every
|
|
74
|
+
* handshake — this is what lets a certificate be added with no restart.
|
|
75
|
+
* `undefined` server name means the client sent no SNI.
|
|
76
|
+
*/
|
|
77
|
+
certFor(serverName: string | undefined): Leaf | undefined;
|
|
78
|
+
/** Non-fatal diagnostics (a handshake a client aborted, a pipe error). */
|
|
79
|
+
onWarning?(message: string, err: unknown): void;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface TlsTerminator {
|
|
83
|
+
/** The port actually bound. */
|
|
84
|
+
readonly port: number;
|
|
85
|
+
/**
|
|
86
|
+
* The real client address behind an upstream connection, keyed by the
|
|
87
|
+
* source port the plaintext server sees. `undefined` for a port this
|
|
88
|
+
* terminator did not open.
|
|
89
|
+
*/
|
|
90
|
+
clientIpFor(upstreamPort: number | undefined): string | undefined;
|
|
91
|
+
/** Open TLS connections right now (diagnostics and tests). */
|
|
92
|
+
connectionCount(): number;
|
|
93
|
+
/** Stop accepting and force-close every connection. */
|
|
94
|
+
close(): void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Start the terminator. Resolves once :443 is accepting, so a caller can
|
|
99
|
+
* register the hostname in DNS immediately afterwards and know the port is
|
|
100
|
+
* live.
|
|
101
|
+
*/
|
|
102
|
+
/**
|
|
103
|
+
* Is this the ordinary end of a connection rather than a fault? A peer
|
|
104
|
+
* that goes away mid-stream, or a half-finished handshake, is routine on a
|
|
105
|
+
* listener that fronts browsers.
|
|
106
|
+
*/
|
|
107
|
+
function isRoutineDisconnect(err: unknown): boolean {
|
|
108
|
+
const code = (err as { code?: string } | undefined)?.code;
|
|
109
|
+
return (
|
|
110
|
+
code === "ECONNRESET" ||
|
|
111
|
+
code === "EPIPE" ||
|
|
112
|
+
code === "ERR_STREAM_PREMATURE_CLOSE" ||
|
|
113
|
+
code === "ECONNABORTED"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function startTlsTerminator(opts: TlsTerminatorOptions): Promise<TlsTerminator> {
|
|
118
|
+
// Parsing a PEM into a SecureContext is the expensive half of choosing a
|
|
119
|
+
// certificate, and the table is stable — cache by the certificate's own
|
|
120
|
+
// bytes so a handshake is a Map lookup. Keyed on the PEM rather than the
|
|
121
|
+
// hostname so a re-minted leaf for the same name cannot be served stale.
|
|
122
|
+
const contexts = new Map<string, tls.SecureContext>();
|
|
123
|
+
const contextFor = (leaf: Leaf): tls.SecureContext => {
|
|
124
|
+
let ctx = contexts.get(leaf.cert);
|
|
125
|
+
if (!ctx) {
|
|
126
|
+
ctx = tls.createSecureContext({ cert: leaf.cert, key: leaf.key });
|
|
127
|
+
contexts.set(leaf.cert, ctx);
|
|
128
|
+
}
|
|
129
|
+
return ctx;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const warn = (message: string, err: unknown): void => {
|
|
133
|
+
// A client hanging up is not a fault, and this listener carries every
|
|
134
|
+
// browser in the VM: Chromium opens speculative connections and drops
|
|
135
|
+
// them, a fetch is abandoned when its test ends, and each of those
|
|
136
|
+
// arrives here as a reset. Logging them would bury the boot log a user
|
|
137
|
+
// reads with `spectest env logs` in noise that means nothing.
|
|
138
|
+
if (isRoutineDisconnect(err)) return;
|
|
139
|
+
if (opts.onWarning) opts.onWarning(message, err);
|
|
140
|
+
// eslint-disable-next-line no-console
|
|
141
|
+
else console.warn(`[ingress] ${message}:`, err);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** upstream source port → the address the TLS client connected from. */
|
|
145
|
+
const clientIpByUpstreamPort = new Map<number, string>();
|
|
146
|
+
const live = new Set<net.Socket>();
|
|
147
|
+
|
|
148
|
+
// A default leaf is only ever used for a client that sends no SNI at
|
|
149
|
+
// all; `SNICallback` decides every other handshake. This mirrors what
|
|
150
|
+
// Bun did with the first entry of its `tls` array.
|
|
151
|
+
const fallback = opts.certFor(undefined);
|
|
152
|
+
|
|
153
|
+
const server = tls.createServer(
|
|
154
|
+
{
|
|
155
|
+
...(fallback ? { cert: fallback.cert, key: fallback.key } : {}),
|
|
156
|
+
SNICallback: (serverName, cb) => {
|
|
157
|
+
const leaf = opts.certFor(serverName) ?? fallback;
|
|
158
|
+
// No certificate at all is a handshake we cannot complete. Answer
|
|
159
|
+
// with the fallback context rather than an error so the client
|
|
160
|
+
// gets a certificate-mismatch alert it can report, which is the
|
|
161
|
+
// same thing it saw when Bun served the first entry.
|
|
162
|
+
cb(null, leaf ? contextFor(leaf) : undefined);
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
(client) => {
|
|
166
|
+
// No idle timeout anywhere on the ingress path: it fronts app
|
|
167
|
+
// endpoints that legitimately take minutes (a deploy), and a
|
|
168
|
+
// timeout here would surface as a truncated response with no
|
|
169
|
+
// explanation.
|
|
170
|
+
client.setTimeout(0);
|
|
171
|
+
live.add(client);
|
|
172
|
+
|
|
173
|
+
// Nothing reads from `client` until `pipe` below, so a socket that
|
|
174
|
+
// is written to before the upstream is connected stays paused and
|
|
175
|
+
// its bytes are buffered rather than dropped — which is the normal
|
|
176
|
+
// case, since a client sends its first request the instant the
|
|
177
|
+
// handshake completes.
|
|
178
|
+
const upstream = net.connect(opts.upstreamPort, "127.0.0.1");
|
|
179
|
+
upstream.setTimeout(0);
|
|
180
|
+
upstream.setNoDelay(true);
|
|
181
|
+
client.setNoDelay(true);
|
|
182
|
+
|
|
183
|
+
let recordedPort: number | undefined;
|
|
184
|
+
upstream.on("connect", () => {
|
|
185
|
+
recordedPort = upstream.localPort;
|
|
186
|
+
if (recordedPort !== undefined && client.remoteAddress) {
|
|
187
|
+
clientIpByUpstreamPort.set(recordedPort, client.remoteAddress);
|
|
188
|
+
}
|
|
189
|
+
// `pipe` carries backpressure and the half-close in both
|
|
190
|
+
// directions, which is what a WebSocket bridge and a streamed
|
|
191
|
+
// upload each need.
|
|
192
|
+
client.pipe(upstream);
|
|
193
|
+
upstream.pipe(client);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const teardown = (): void => {
|
|
197
|
+
if (recordedPort !== undefined) clientIpByUpstreamPort.delete(recordedPort);
|
|
198
|
+
live.delete(client);
|
|
199
|
+
client.destroy();
|
|
200
|
+
upstream.destroy();
|
|
201
|
+
};
|
|
202
|
+
client.on("error", (err) => {
|
|
203
|
+
warn("tls client connection failed", err);
|
|
204
|
+
teardown();
|
|
205
|
+
});
|
|
206
|
+
upstream.on("error", (err) => {
|
|
207
|
+
warn(`ingress upstream :${opts.upstreamPort} failed`, err);
|
|
208
|
+
teardown();
|
|
209
|
+
});
|
|
210
|
+
client.on("close", teardown);
|
|
211
|
+
upstream.on("close", teardown);
|
|
212
|
+
},
|
|
213
|
+
);
|
|
214
|
+
|
|
215
|
+
// A client that aborts mid-handshake, or offers a protocol version we
|
|
216
|
+
// do not speak, emits this. It must be handled: an unhandled 'error' on
|
|
217
|
+
// a net server is thrown, and taking the daemon down over one bad
|
|
218
|
+
// handshake would be a far worse failure than the one being fixed.
|
|
219
|
+
server.on("tlsClientError", (err) => warn("tls handshake failed", err));
|
|
220
|
+
server.on("error", (err) => warn(`tls listener :${opts.port} failed`, err));
|
|
221
|
+
|
|
222
|
+
return new Promise<TlsTerminator>((resolve, reject) => {
|
|
223
|
+
server.once("error", reject);
|
|
224
|
+
server.listen(opts.port, opts.hostname, () => {
|
|
225
|
+
server.removeListener("error", reject);
|
|
226
|
+
resolve({
|
|
227
|
+
port: opts.port,
|
|
228
|
+
clientIpFor: (upstreamPort) =>
|
|
229
|
+
upstreamPort === undefined ? undefined : clientIpByUpstreamPort.get(upstreamPort),
|
|
230
|
+
connectionCount: () => live.size,
|
|
231
|
+
close: () => {
|
|
232
|
+
try {
|
|
233
|
+
server.close();
|
|
234
|
+
} catch (err) {
|
|
235
|
+
warn("closing the tls listener failed", err);
|
|
236
|
+
}
|
|
237
|
+
for (const sock of [...live]) sock.destroy();
|
|
238
|
+
live.clear();
|
|
239
|
+
clientIpByUpstreamPort.clear();
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -2900,7 +2900,7 @@ function buildLocatorMatchers(
|
|
|
2900
2900
|
// The last error a probe threw, kept for the failure message: a matcher
|
|
2901
2901
|
// whose read waits for the element (textContent, inputValue, isEnabled …)
|
|
2902
2902
|
// reports a missing element as a playwright timeout, and that — not
|
|
2903
|
-
// "got <error: Timeout
|
|
2903
|
+
// "got <error: Timeout 10000ms exceeded …>" — is the sentence to fail with.
|
|
2904
2904
|
let probeError: unknown;
|
|
2905
2905
|
for (;;) {
|
|
2906
2906
|
let satisfied: boolean;
|
|
@@ -2939,8 +2939,8 @@ function buildLocatorMatchers(
|
|
|
2939
2939
|
}
|
|
2940
2940
|
if (Date.now() >= deadline) {
|
|
2941
2941
|
// The deadline, not the stopwatch: every one of these polled until it
|
|
2942
|
-
// ran out, and "(waited
|
|
2943
|
-
// number twice in a row — a measured
|
|
2942
|
+
// ran out, and "(waited 10s)" is both what the author set and the same
|
|
2943
|
+
// number twice in a row — a measured 9_987ms is neither.
|
|
2944
2944
|
const msg = (await elementFailure(probeError)) ??
|
|
2945
2945
|
`${describe(actual)} (waited ${formatWaited(budget)})`;
|
|
2946
2946
|
const sourceSeq = await probe.settle(matcher, Date.now() - started, msg, settleOpts);
|
package/src/locator-errors.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Readable failures for locator steps.
|
|
2
2
|
//
|
|
3
3
|
// Playwright reports every unmet actionability wait the same way: a
|
|
4
|
-
// `TimeoutError` whose message is "Timeout
|
|
4
|
+
// `TimeoutError` whose message is "Timeout 10000ms exceeded." with the real
|
|
5
5
|
// story — did the element exist at all? was it disabled? did something cover
|
|
6
6
|
// it? — buried in a call log below it. A test that clicks a button that is not
|
|
7
7
|
// on the page therefore fails with a sentence about OUR deadline, which reads
|
package/src/locator.ts
CHANGED
|
@@ -32,11 +32,16 @@ import { resolveExistingProjectPath } from "./project-files.js";
|
|
|
32
32
|
import { truncateUtf8 } from "./recorder.js";
|
|
33
33
|
|
|
34
34
|
/** Default deadline for a locator action/read's target to become actionable.
|
|
35
|
-
* Playwright's own default is 30s — far too slow-failing for tests
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
|
|
35
|
+
* Playwright's own default is 30s — far too slow-failing for tests. This was
|
|
36
|
+
* 5s (the pre-Playwright behavior) until 2026-09-03, and 5s turned out to be
|
|
37
|
+
* about one page load: a real app under a full VM fan-out took 1.8-2.0s for a
|
|
38
|
+
* full navigation plus its client data chain on a quiet box, and 4x that on
|
|
39
|
+
* the tail, so a step that was correct and merely late failed. 10s still
|
|
40
|
+
* fails fast enough to be useful and leaves room for the tail. A per-call
|
|
41
|
+
* `{ timeout }` overrides it; `undefined` falls through to the context
|
|
42
|
+
* default (also set to this in browser.ts's `newViewContext`). Navigations
|
|
43
|
+
* keep a longer deadline. */
|
|
44
|
+
export const DEFAULT_ACTION_TIMEOUT_MS = 10_000;
|
|
40
45
|
|
|
41
46
|
// Brand + chain carrier. Both are `Symbol.for` keys so `JSON.stringify` drops
|
|
42
47
|
// them (locators are never serialized) while runtime code can still detect a
|
|
@@ -656,7 +661,7 @@ export interface Locator {
|
|
|
656
661
|
/** Explicit strict-mode opt-out: the i-th resolved element (0-based). */
|
|
657
662
|
nth(index: number): Locator;
|
|
658
663
|
|
|
659
|
-
// ── Actions (auto-wait; default
|
|
664
|
+
// ── Actions (auto-wait; default 10s, `{ timeout }` overrides) ─────────
|
|
660
665
|
click(opts?: ClickOptions): Promise<void>;
|
|
661
666
|
dblclick(opts?: ClickOptions): Promise<void>;
|
|
662
667
|
/** Touch-tap (mobile sessions only; throws on desktop). `duration`
|
|
@@ -761,7 +766,7 @@ export function makeLocator(
|
|
|
761
766
|
};
|
|
762
767
|
|
|
763
768
|
// Every terminal op runs through this: playwright's actionability timeouts
|
|
764
|
-
// say "Timeout
|
|
769
|
+
// say "Timeout 10000ms exceeded" and hide what actually went wrong in a call
|
|
765
770
|
// log, so they are rewritten into a sentence naming the element and its
|
|
766
771
|
// state (see locator-errors.ts), and a failure that found NO element asks
|
|
767
772
|
// the page what it does hold. Applied INSIDE `pageOp`, so the recorded step
|
package/src/terminal.ts
CHANGED
|
@@ -32,6 +32,9 @@ import { recordTerminalStep, reserveEvent, truncateUtf8 } from "./recorder.js";
|
|
|
32
32
|
import type { EventReservation } from "./recorder.js";
|
|
33
33
|
import type { TerminalStepAction } from "./recorder.js";
|
|
34
34
|
import { wrap } from "./inspect.js";
|
|
35
|
+
// One default deadline for "wait until the app shows it", whichever surface
|
|
36
|
+
// the author is watching — see the constant for why it is what it is.
|
|
37
|
+
import { DEFAULT_ACTION_TIMEOUT_MS } from "./locator.js";
|
|
35
38
|
import type { Wrapped } from "./inspect.js";
|
|
36
39
|
|
|
37
40
|
export interface TerminalOpts {
|
|
@@ -614,7 +617,7 @@ export async function openTerminal(args: OpenTerminalArgs): Promise<InternalTerm
|
|
|
614
617
|
matcher: string | RegExp | ((screen: string, output: string) => unknown),
|
|
615
618
|
waitOpts?: { timeoutMs?: number; intervalMs?: number },
|
|
616
619
|
) {
|
|
617
|
-
const timeoutMs = waitOpts?.timeoutMs ??
|
|
620
|
+
const timeoutMs = waitOpts?.timeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS;
|
|
618
621
|
const intervalMs = waitOpts?.intervalMs ?? 100;
|
|
619
622
|
const t = Date.now();
|
|
620
623
|
const resv = reserveEvent();
|