File
Blob: src/workerd/api/node/tests/sidecar-supervisor.mjs
| 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 | |
| 54 | import net from 'node:net'; |
| 55 | import child_process from 'node:child_process'; |
| 56 | import crypto from 'node:crypto'; |
| 57 | |
| 58 | const ANY_PORT = 0; |
| 59 | const CONNECT_POLL_INTERVAL_MS = 500; |
| 60 | |
| 61 | function 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 | |
| 68 | function closeServer(server) { |
| 69 | const { promise, resolve } = Promise.withResolvers(); |
| 70 | server.close(resolve); |
| 71 | return promise; |
| 72 | } |
| 73 | |
| 74 | async 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 | |
| 91 | function 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 | |
| 105 | function 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 | |
| 115 | function 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 | |
| 124 | function 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 | |
| 149 | function 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 | |
| 167 | const portsToAssign = process.env.PORTS_TO_ASSIGN.split(','); |
| 168 | const sidecarCommand = process.env.SIDECAR_COMMAND; |
| 169 | const testArgv = process.argv.slice(2); // Shift off the stuff Node puts in argv |
| 170 | |
| 171 | const hostname = assignLoopbackAddress(); |
| 172 | process.env.SIDECAR_HOSTNAME = hostname; |
| 173 | |
| 174 | const ports = await reservePorts(hostname, portsToAssign); |
| 175 | const sidecar = runSidecar(sidecarCommand); |
| 176 | await Promise.all( |
| 177 | Object.values(ports).map((port) => waitForListening(port, hostname)) |
| 178 | ); |
| 179 | |
| 180 | process.exitCode = child_process.spawnSync(testArgv[0], testArgv.slice(1), { |
| 181 | stdio: ['inherit', 'inherit', 'inherit'], |
| 182 | }).status; |
| 183 | |
| 184 | sidecar.proc.kill(); |
| 185 | await sidecar.promise; |