Skip to content
File

Blob: src/workerd/server/tests/server-harness.mjs

javascript175 lines
1// Copyright (c) 2024 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
4import { spawn } from 'node:child_process';
5import assert from 'node:assert';
6 
7// A convenience class to:
8// - start workerd
9// - wait for its listen ports to be opened and reported
10// - stop workerd
11export class WorkerdServerHarness {
12 // Properties set by our constructor and never changed.
13 #workerdBinary = null;
14 #workerdConfig = null;
15 #listenPortNames = null;
16 
17 // Properties set by `start()` and cleared by `stop()`.
18 #child = null;
19 #listenPorts = null;
20 #listenInspectorPort = null;
21 #closed = null;
22 
23 constructor({ workerdBinary, workerdConfig, listenPortNames }) {
24 this.#workerdBinary = workerdBinary;
25 this.#workerdConfig = workerdConfig;
26 this.#listenPortNames = listenPortNames;
27 }
28 
29 // Spawn our workerd process and wait for it to start.
30 async start() {
31 assert.equal(this.#child, null);
32 
33 // One after STDIN, STDOUT, and STDERR.
34 const CONTROL_FD = 3;
35 
36 const args = [
37 'serve',
38 this.#workerdConfig,
39 '--verbose',
40 '--inspector-addr=127.0.0.1:0',
41 `--control-fd=${CONTROL_FD}`,
42 ];
43 
44 const options = {
45 stdio: [
46 'inherit',
47 'inherit',
48 'inherit',
49 // One more for our control FD.
50 'pipe',
51 ],
52 };
53 
54 // Start the subprocess.
55 console.log('[HARNESS] Starting workerd with args:', args);
56 this.#child = spawn(this.#workerdBinary, args, options);
57 
58 // Create a promise for every named listen port we were told in our constructor to expect. Parse
59 // messages from our control FD and resolve the promises as we see ports come online.
60 //
61 // TODO(perf): Registering a separate callback for every named port isn't very efficient --
62 // we'll parse JSON N times -- but we typically don't have many named ports, and I don't want to
63 // spend forever on this code.
64 this.#listenPorts = new Map();
65 for (const listenPort of this.#listenPortNames) {
66 this.#listenPorts.set(
67 listenPort,
68 new Promise((resolve, reject) => {
69 this.#child.stdio[CONTROL_FD].on('data', (data) => {
70 const parsed = JSON.parse(data);
71 console.log('[HARNESS] Control message:', parsed);
72 if (parsed.event === 'listen' && parsed.socket === listenPort) {
73 resolve(parsed.port);
74 }
75 });
76 this.#child.once('error', reject);
77 })
78 );
79 }
80 
81 // Do the same as the above for the inspector port.
82 this.#listenInspectorPort = new Promise((resolve, reject) => {
83 this.#child.stdio[CONTROL_FD].on('data', (data) => {
84 const parsed = JSON.parse(data);
85 console.log('[HARNESS] Inspector message:', parsed);
86 if (parsed.event === 'listen-inspector') {
87 resolve(parsed.port);
88 }
89 });
90 this.#child.once('error', reject);
91 });
92 
93 // Set up a closed promise, too.
94 this.#closed = new Promise((resolve, reject) => {
95 this.#child
96 .once('close', (code, signal) => resolve([code, signal]))
97 .once('error', reject);
98 });
99 
100 // Wait for the subprocess to complete spawning before we return.
101 await new Promise((resolve, reject) => {
102 this.#child.once('spawn', resolve).once('error', reject);
103 console.log('[HARNESS] Workerd process spawned');
104 });
105 }
106 
107 // Return a promise for the inspector port.
108 async getListenInspectorPort() {
109 assert.notEqual(this.#listenInspectorPort, null);
110 return await this.#listenInspectorPort;
111 }
112 
113 // Return a promise for the named listen port.
114 async getListenPort(name) {
115 assert.notEqual(this.#listenPorts, null);
116 assert.notEqual(this.#listenPorts.get(name), undefined);
117 return await this.#listenPorts.get(name);
118 }
119 
120 // Send SIGTERM to workerd and wait for it to completely finish.
121 async stop() {
122 assert.notEqual(this.#child, null);
123 
124 console.log('[HARNESS] Sending SIGTERM to workerd...');
125 
126 // First try SIGTERM with a timeout
127 const SIGTERM_TIMEOUT = 5000; // 5 seconds
128 const SIGKILL_TIMEOUT = 2000; // 2 more seconds for SIGKILL
129 
130 let result;
131 let killed = false;
132 
133 // Set up a timeout for SIGTERM
134 const sigtermTimeout = setTimeout(() => {
135 if (!killed) {
136 console.log('[HARNESS] SIGTERM timeout, sending SIGKILL...');
137 this.#child.kill('SIGKILL');
138 killed = true;
139 }
140 }, SIGTERM_TIMEOUT);
141 
142 // Set up a final timeout for SIGKILL
143 const sigkillTimeout = setTimeout(() => {
144 if (!killed) {
145 console.error(
146 '[HARNESS] Process did not respond to signals, forcing termination'
147 );
148 // This shouldn't happen, but just in case...
149 process.exit(1);
150 }
151 }, SIGTERM_TIMEOUT + SIGKILL_TIMEOUT);
152 
153 try {
154 // Send SIGTERM
155 this.#child.kill('SIGTERM');
156 
157 // Wait for the process to close
158 result = await this.#closed;
159 killed = true;
160 console.log('[HARNESS] Workerd process terminated successfully');
161 } finally {
162 // Clean up timeouts
163 clearTimeout(sigtermTimeout);
164 clearTimeout(sigkillTimeout);
165 }
166 
167 this.#child = null;
168 this.#listenPorts = null;
169 this.#listenInspectorPort = null;
170 this.#closed = null;
171 
172 return result;
173 }
174}