Skip to content
File

Blob: src/workerd/api/node/tests/sidecar-supervisor.mjs

javascript186 lines
1// Copyright (c) 2017-2025 Cloudflare, Inc.
2// Licensed under the Apache 2.0 license found in the LICENSE file or at:
3// https://opensource.org/licenses/Apache-2.0
4 
5/*
6 * Sidecars test framework
7 * ------------------------
8 *
9 * A `wd_test` may specify a `sidecar` to run alongside it. The sidecar is an auxiliary server
10 * process that runs alongside the test to provide realistic network endpoints.
11 *
12 * The sidecar and test processes are run together by the `sidecar_supervisor` (this file).
13 * The supervisor is responsible for assigning a random IP address for the sidecar and test to
14 * communicate (stored in the environment variable `SIDECAR_HOSTNAME`), as well as a set of random
15 * port numbers. Each environment variable specified in `sidecar_port_bindings` will be filled in
16 * with a random port number.
17 *
18 * This architecture is designed to allow running unmodified TCP servers, such as wptserve for the
19 * WPT tests. If necessary, IP address randomization can be disabled by setting
20 * `sidecar_randomize_ip` to False.
21 *
22 *
23 * ┌───────────────────────────────────────────────────────┐
24 * │ │
25 * │ bazel test │
26 * │ │
27 * └───────────────────────────┬───────────────────────────┘
28 * │
29 * env vars: PORTS_TO_ASSIGN
30 * ▼
31 * ┌───────────────────────────────────────────────────────┐
32 * │ │
33 * │ sidecar supervisor ├────────────────────────────────────────────────────────────┐
34 * │ │ │
35 * └───────────────────────────┬───────────────────────────┘ │
36 * │ env vars: SIDECAR_HOSTNAME, SERVER_PORT, ...
37 * env vars: SIDECAR_HOSTNAME, SERVER_PORT, ... │
38 * ▼ ▼
39 * ┌───────────────────────────────────────────────────────┐ ┌──────────────────────────────────────────────┐
40 * │ │ │ │
41 * │ wd-test │ │ sidecar │
42 * │ │ │ │
43 * └───────────────────────────┬───────────────────────────┘ └──────────────────────────────────────────────┘
44 * │ ▲
45 * env var bindings: SIDECAR_HOSTNAME, SERVER_PORT, ... │
46 * ▼ │
47 * ┌───────────────────────────────────────────────────────┐ │
48 * │ │ │
49 * │ test ├────────────tcp:─SIDECAR_HOSTNAME,─SERVER_PORT──────────────┘
50 * │ │
51 * └───────────────────────────────────────────────────────┘
52 */
53 
54import net from 'node:net';
55import child_process from 'node:child_process';
56import crypto from 'node:crypto';
57 
58const ANY_PORT = 0;
59const CONNECT_POLL_INTERVAL_MS = 500;
60 
61function getListeningServer(hostname) {
62 const { promise, resolve } = Promise.withResolvers();
63 const server = net.createServer();
64 server.listen(ANY_PORT, hostname).once('listening', () => resolve(server));
65 return promise;
66}
67 
68function closeServer(server) {
69 const { promise, resolve } = Promise.withResolvers();
70 server.close(resolve);
71 return promise;
72}
73 
74async function reservePorts(hostname, envVarNames) {
75 const servers = await Promise.all(
76 envVarNames.map((_) => getListeningServer(hostname))
77 );
78 const ports = Object.fromEntries(
79 envVarNames.map((envVar, i) => [envVar, servers[i].address().port])
80 );
81 Object.assign(process.env, ports);
82 
83 // TODO(soon): We need to close the ports we found so sidecarCommand can bind to them.
84 // During this time, another unrelated process could end up taking the ports we're using.
85 // SO_REUSEPORT is safer but the sidecarCommand must know to use it.
86 await Promise.all(servers.map(closeServer));
87 
88 return ports;
89}
90 
91function waitForListening(port, hostname) {
92 const { promise, resolve, reject } = Promise.withResolvers();
93 const interval = setInterval(() => {
94 const conn = net
95 .connect(port, hostname, () => {
96 conn.destroy();
97 clearInterval(interval);
98 resolve();
99 })
100 .once('error', (err) => console.log('waiting for sidecar...', err.code));
101 }, CONNECT_POLL_INTERVAL_MS);
102 return promise;
103}
104 
105function runSidecar(cmd) {
106 const { promise, resolve } = Promise.withResolvers();
107 const proc = child_process.spawn(cmd, {
108 shell: true,
109 stdio: ['inherit', 'inherit', 'inherit'],
110 });
111 proc.once('exit', resolve);
112 return { promise, proc };
113}
114 
115function getRandomLoopbackAddress() {
116 // Pick a random address in 127.0.0.0/8.
117 // Range is chosen not to use network address, gateway address, or broadcast address.
118 
119 // TODO: There is still a faint possibility of collision. We could use OS-specific APIs to see
120 // if a potential address is in use.
121 return `127.${crypto.randomInt(2, 255)}.${crypto.randomInt(2, 255)}.${crypto.randomInt(2, 255)}`;
122}
123 
124function canUseRandomAddress() {
125 if (process.env.RANDOMIZE_IP === 'false') {
126 // Test explicitly disabled randomization
127 return false;
128 }
129 
130 switch (process.platform) {
131 case 'linux':
132 return true;
133 
134 case 'win32':
135 return true;
136 
137 case 'darwin':
138 // Address randomization works on macOS but requires sudo, so we'll only
139 // use it in CI for dev convenience.
140 return process.env.CI === 'true';
141 
142 default:
143 throw new Error(
144 `Address randomization strategy not known for ${process.platform}`
145 );
146 }
147}
148 
149function assignLoopbackAddress() {
150 if (!canUseRandomAddress()) {
151 return '127.0.0.1';
152 }
153 
154 const randomAddress = getRandomLoopbackAddress();
155 if (process.platform === 'darwin') {
156 // On macOS, we need to explicitly assign this IP to the loopback interface
157 child_process.spawnSync(
158 'sudo',
159 ['/sbin/ifconfig', 'lo0', 'alias', randomAddress],
160 { stdio: 'inherit' }
161 );
162 }
163 
164 return randomAddress;
165}
166 
167const portsToAssign = process.env.PORTS_TO_ASSIGN.split(',');
168const sidecarCommand = process.env.SIDECAR_COMMAND;
169const testArgv = process.argv.slice(2); // Shift off the stuff Node puts in argv
170 
171const hostname = assignLoopbackAddress();
172process.env.SIDECAR_HOSTNAME = hostname;
173 
174const ports = await reservePorts(hostname, portsToAssign);
175const sidecar = runSidecar(sidecarCommand);
176await Promise.all(
177 Object.values(ports).map((port) => waitForListening(port, hostname))
178);
179 
180process.exitCode = child_process.spawnSync(testArgv[0], testArgv.slice(1), {
181 stdio: ['inherit', 'inherit', 'inherit'],
182}).status;
183 
184sidecar.proc.kill();
185await sidecar.promise;