Skip to content

Commit d7d7325

Browse files
committed
refactor: automatically aborb browser error after abort signal
1 parent d029107 commit d7d7325

File tree

6 files changed

+101
-103
lines changed

6 files changed

+101
-103
lines changed

API.md

+9-17
Original file line numberDiff line numberDiff line change
@@ -26,32 +26,24 @@ module.exports = {
2626

2727
## QTap browser interface
2828

29-
Browsers are defined by implementing a launcher function with the following signature. Launchers are either an async function, or a function that returns a Promise.
29+
You can define a browser launcher by implementing a function with the following signature. These launchers should either be async functions, or functions that return a Promise.
3030

3131
```js
3232
/**
33-
* A browser launcher is responsible for knowing whether the process failed to
34-
* launch or spawn, and whether it exited unexpectedly.
33+
* A browser launcher is responsible for opening a URL in the browser.
3534
*
36-
* A launcher is not responsible for knowing whether it succeeded in
37-
* opening or navigating to the given URL.
35+
* A launcher is not responsible for knowing whether the browser succeeded in
36+
* opening or navigating to this URL.
3837
*
39-
* It is the responsiblity of ControlServer to send the "abort" event
40-
* to AbortSignal if it believes the browser has failed to load the
41-
* URL within a reasonable timeout, or if the browser has not sent
42-
* any message for a while.
43-
*
44-
* If a browser exits on its own (i.e. ControlServer did not call send
45-
* an abort signal), then launch() should throw an Error or reject its
46-
* returned Promise.
38+
* If a browser crashes or otherwise exits unexpectedly,
39+
* you should throw an Error or reject your returned Promise.
4740
*
4841
* @param {string} url
4942
* URL that the browser should navigate to, HTTP or HTTPS.
5043
* @param {AbortSignal} signal
51-
* The launched browser process must be terminated when this signal
52-
* receives an "abort" event. QTap sends the abort event when it finds that
53-
* a test has finished, or if it needs to stop the browser for any other
54-
* reason.
44+
* The browser process must be terminated when this signal receives an "abort" event.
45+
* QTap sends the "abort" event when it finds that a test run has finished, or if it
46+
* needs to stop the browser for any other reason.
5547
* @param {qtap-Logger} logger
5648
*/
5749
async function myBrowserLauncher(url, signal, logger);

ARCHITECTURE.md

+7-11
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,13 @@ Examples:
4747

4848
Performance is a first-class principle in QTap.
4949

50-
The first priority (after the "Simple" and "Lean" values above) is time to first result. This means the CLI endpoint should as directly as possible launch browsers and start the ControlServer. Any computation, imports and other overhead is deferred when possible.
50+
The first priority (after the "Simple" and "Lean" values above) is time to first result. This means the CLI endpoint should as directly as possible start browsers and start the ControlServer. Any computation, imports and other overhead is deferred when possible.
5151

5252
The second piority is time to last result (e.g. "Done!"), which is generally what a human in local development (especially in watch mode) will be waiting for. Note that this is separate from when the CLI process technically exits, which is less important to us. It is expected that the process will in practice exit immediately after the last result is printed, but when we have a choice, it is important to first get and communicate test results. In particular for watch mode, shutdown logic will not happen on re-runs and thus is avoided if we don't do it in the critical path toward obtaining test results.
5353

5454
## Debugging
5555

56-
Set `QTAP_DEBUG=1` as environment variable, when calling the QTap CLI, to launch local browsers visibly instead of headless.
56+
Set `QTAP_DEBUG=1` as environment variable, when calling the QTap CLI, to start local browsers visibly instead of headless.
5757

5858
Set `--verbose` in the QTap CLI to enable verbose debug logging.
5959

@@ -122,11 +122,11 @@ Set `--verbose` in the QTap CLI to enable verbose debug logging.
122122

123123
* **Page-side script injection**. The HTML file is modified to include an inline script that subscribes to `console.log` and other events, and beacons to our command-line process. More details at [QTap Internal: Client send](#qtap-internal-client-send).
124124

125-
This means browser can be launched by any means, pointed at a URL, and then forgotten about until we shut it down. It requires no control over the browser process. And the requirements for someone writing a browser launcher, is thus merely to open a single URL. More details at [QTap API: Browser launcher](#qtap-internal-client-send).
125+
This means browser can be started by any means, pointed at a URL, and then forgotten about until we shut it down. It requires no control over the browser process. And the requirements for someone writing a browser launcher, is thus merely to open a single URL. More details at [QTap API: Browser launcher](#qtap-internal-client-send).
126126

127127
## QTap API: Browser launcher
128128

129-
Each browser is implemented as a single async function that launches the browser. The function is called with all required information and services. The [injected](https://en.wikipedia.org/wiki/Dependency_injection) parameters include a URL, an abort signal, and a logger function.
129+
Each browser is implemented as a single async function that opens the browser. The function is called with all required information and services. The [injected](https://en.wikipedia.org/wiki/Dependency_injection) parameters include a URL, an abort signal, and a logger function.
130130

131131
The function is expected to run as long as the browser is running, with the returned Promise representing the browser process. If the browser exits for any reason, you may run any cleanup and then return. If the browser fails or crashes for any reason, this can be conveyed by throwing an error (or rejecting a Promise) from your async function.
132132

@@ -147,7 +147,7 @@ async function myBrowser (url, signal, logger) {
147147
const spawned = child_process.spawn('/bin/mybrowser', ['-headless', url], { signal });
148148
await new Promise((resolve, reject) => {
149149
spawned.on('error', (error) => reject(error));
150-
spawned.on('exit', (code) => reject(new Error(`Process exited ${code}`)));
150+
spawned.on('exit', () => reject(new Error('Process exited'));
151151
});
152152
}
153153

@@ -240,12 +240,8 @@ async function myBrowser (url, signal, logger) {
240240
async function myBrowser (url, signal, logger) {
241241
const spawned = child_process.spawn('/bin/mybrowser', ['-headless', url], { signal });
242242
await new Promise((resolve, reject) => {
243-
spawned.on('error', (error) => {
244-
(signal.aborted ? resolve() : reject(error));
245-
});
246-
spawned.on('exit', () => {
247-
(signal.aborted ? resolve(): reject(new Error('Process exited'));
248-
});
243+
spawned.on('error', (error) => reject(error));
244+
spawned.on('exit', () => reject(new Error('Process exited'));
249245
});
250246
}
251247
```

src/qtap.js

+21-14
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
import util from 'node:util';
4+
import path from 'node:path';
45

56
import kleur from 'kleur';
67
import browsers from './browsers.js';
@@ -37,9 +38,11 @@ function makeLogger (defaultChannel, printDebug, verbose = false) {
3738
: function debug (messageCode, ...params) {
3839
printDebug(kleur.grey(`[${prefix}] ${kleur.bold(messageCode)} ${paramsFmt(params)}`));
3940
},
40-
warning: function warning (messageCode, ...params) {
41-
printDebug(kleur.yellow(`[${prefix}] WARNING ${kleur.bold(messageCode)}`) + ` ${paramsFmt(params)}`);
42-
}
41+
warning: !verbose
42+
? function () {}
43+
: function warning (messageCode, ...params) {
44+
printDebug(kleur.yellow(`[${prefix}] WARNING ${kleur.bold(messageCode)}`) + ` ${paramsFmt(params)}`);
45+
}
4346
};
4447
}
4548

@@ -74,8 +77,7 @@ function makeLogger (defaultChannel, printDebug, verbose = false) {
7477

7578
/**
7679
* @param {string|string[]} browserNames One or more browser names, referring either
77-
* to a built-in browser launcher from QTap, or to a key in the optional
78-
* `config.browsers` object.
80+
* to a built-in browser from QTap, or to a key in the optional `config.browsers` object.
7981
* @param {string|string[]} files Files and/or URLs.
8082
* @param {qtap.RunOptions} [options]
8183
* @return {Promise<number>} Exit code. 0 is success, 1 is failed.
@@ -98,13 +100,18 @@ async function run (browserNames, files, options = {}) {
98100
}));
99101
}
100102

101-
// TODO: Add integration test for config file not found
102-
// TODO: Add integration test for config file with runtime errors
103-
const config = typeof options.config === 'string' ? await import(options.config) : options.config;
103+
// TODO: Add test for config file not found
104+
// TODO: Add test for config file with runtime errors
105+
// TODO: Add test for relative config file without leading `./`, handled by process.resolve()
106+
let config;
107+
if (typeof options.config === 'string') {
108+
logger.debug('load_config', options.config);
109+
config = (await import(path.resolve(process.cwd(), options.config))).default;
110+
}
104111
const globalController = new AbortController();
105112
const globalSignal = globalController.signal;
106113

107-
const browserLaunches = [];
114+
const browerPromises = [];
108115
for (const browserName of browserNames) {
109116
logger.debug('get_browser', browserName);
110117
const browserFn = browsers[browserName] || config?.browsers?.[browserName];
@@ -114,18 +121,18 @@ async function run (browserNames, files, options = {}) {
114121
for (const server of servers) {
115122
// Each launchBrowser() returns a Promise that settles when the browser exits.
116123
// Launch concurrently, and await afterwards.
117-
browserLaunches.push(server.launchBrowser(browserFn, browserName, globalSignal));
124+
browerPromises.push(server.launchBrowser(browserFn, browserName, globalSignal));
118125
}
119126
}
120127

121128
try {
122129
// Wait for all tests and browsers to finish/stop, regardless of errors thrown,
123130
// to avoid dangling browser processes.
124-
await Promise.allSettled(browserLaunches);
131+
await Promise.allSettled(browerPromises);
125132

126133
// Re-wait, this time letting the first of any errors bubble up.
127-
for (const launched of browserLaunches) {
128-
await launched;
134+
for (const browerPromise of browerPromises) {
135+
await browerPromise;
129136
}
130137

131138
logger.debug('shared_cleanup', 'Invoke global signal to clean up shared resources');
@@ -139,7 +146,7 @@ async function run (browserNames, files, options = {}) {
139146
}
140147

141148
// TODO: Set exit status to 1 on failures, to ease programmatic use and testing.
142-
// TODO: Return an event emitter for custom reportering via programmatic use.
149+
// TODO: Return an event emitter for custom reporting via programmatic use.
143150
return 0;
144151
}
145152

src/safari.js

+11-6
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,24 @@ async function safari (url, signals, logger) {
8080
}
8181

8282
// Step 2: Create a session
83+
// This re-tries indefinitely until safaridriver is ready, or until we get an abort signal.
8384
let session;
84-
for (let i = 1; i <= 20; i++) {
85+
for (let i = 1; true; i++) {
8586
try {
8687
session = await webdriverReq('POST', '/session/', { capabilities: { browserName: 'safari' } });
8788
// Connected!
8889
break;
8990
} catch (err) {
90-
/** @type {any} - TypeScript can't set type without reassign, and @types/node lacks Error.code */
91+
/** @type {any} - TypeScript @types/node lacks Error.code */
9192
const e = err;
9293
if (e.code === 'ECONNREFUSED' || (e.cause && e.cause.code === 'ECONNREFUSED')) {
93-
// Wait for safaridriver to be ready, try again in another 10ms-200ms, upto ~2s in total.
94-
logger.debug('safaridriver_waiting', `Attempt #${i}: ${e.code || e.cause.code}. Try again in ${i * 10}ms.`);
95-
await new Promise(resolve => setTimeout(resolve, i * 10));
94+
// Give up once QTap declared browser_connect_timeout
95+
if (signals.browser.aborted) return;
96+
97+
// Back off from 50ms upto 1.0s each attempt
98+
const wait = Math.min(i * 50, 1000);
99+
logger.debug('safaridriver_waiting', `Attempt #${i}: ${e.code || e.cause.code}. Try again in ${wait}ms.`);
100+
await new Promise(resolve => setTimeout(resolve, wait));
96101
continue;
97102
}
98103
logger.warning('safaridriver_session_error', e);
@@ -110,7 +115,7 @@ async function safari (url, signals, logger) {
110115

111116
// Step 4: Close the tab once we receive an 'abort' signal.
112117
return await new Promise((resolve, reject) => {
113-
signals.client.addEventListener('abort', async () => {
118+
signals.browser.addEventListener('abort', async () => {
114119
try {
115120
await webdriverReq('DELETE', `/session/${session.sessionId}`);
116121
resolve();

src/server.js

+41-38
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ class ControlServer {
4343
this.connectTimeout = options.connectTimeout || 60;
4444

4545
this.browsers = new Map();
46-
// Optimization: Prefetch test file in parallel with server starting and browser launching.
47-
// Once browsers are launched and they make their first HTTP request,
46+
// Optimization: Prefetch test file in parallel with server creation and browser launching.
47+
// Once browsers are running and they make their first HTTP request,
4848
// we'll await this in handleRequest/getTestFile.
4949
this.testFilePromise = this.fetchTestFile(this.testFile);
5050

@@ -111,55 +111,45 @@ class ControlServer {
111111

112112
// TODO: Remove `summary` in favour of `eventbus`
113113
const summary = { ok: true };
114+
let clientIdleTimer = null;
114115

115116
const controller = new AbortController();
116117
let signal = controller.signal;
117118
if (QTAP_DEBUG) {
118119
// Replace with a dummy signal that we never invoke
119120
signal = (new AbortController()).signal;
120121
controller.signal.addEventListener('abort', () => {
121-
logger.warning('browser_debugging_abort', 'Keeping browser open for debugging');
122+
logger.warning('browser_signal_debugging', 'Keeping browser open for debugging');
122123
});
123124
}
124125

125-
// TODO: Write test for --connect-timeout by using a no-op browser.
126-
const TIMEOUT_CONNECT = this.connectTimeout;
127-
const TIMEOUT_IDLE = this.idleTimeout;
128-
const TIMEOUT_CHECK_MS = 1000;
129-
const launchStart = performance.now();
130-
let clientIdleTimer = null;
131-
132126
// Reasons to stop a browser, whichever comes first:
133127
// 1. tap-finished.
134128
// 2. tap-parser 'bailout' event (client knows it crashed).
135129
// 3. timeout (client didn't start, lost connection, or unknowingly crashed).
136-
const stopBrowser = async (reason) => {
137-
// NOTE: The below does not need to check browsers.get() before calling
138-
// browsers.delete() or controller.abort(), because both of these are
139-
// idempotent and safely ignore all but the first call.
140-
// Hence we don't need to guard against race conditions where two reasons
141-
// may both try to stop the same browser.
130+
const stopBrowser = async (messageCode) => {
131+
// Ignore any duplicate or late reasons to stop
132+
if (!this.browsers.has(clientId)) return;
133+
142134
clearTimeout(clientIdleTimer);
143135
this.browsers.delete(clientId);
144-
controller.abort(reason);
136+
controller.abort(`QTap: ${messageCode}`);
145137
};
146138

147139
const tapParser = tapFinished({ wait: 0 }, () => {
148-
logger.debug('browser_tap_finished', 'Test has finished, stopping browser');
149-
stopBrowser('QTap: browser_tap_finished');
140+
logger.debug('browser_tap_finished', 'Test run finished, stopping browser');
141+
stopBrowser('browser_tap_finished');
150142
});
151143

152144
tapParser.on('bailout', (reason) => {
153-
logger.warning('browser_tap_bailout', `Test ended unexpectedly, stopping browser. Reason: ${reason}`);
154-
summary.ok = false;
155-
stopBrowser('QTap: browser_tap_bailout');
156-
});
157-
tapParser.once('fail', () => {
158-
logger.debug('browser_tap_fail', 'Results indicate at least one test has failed assertions');
145+
logger.warning('browser_tap_bailout', `Test run bailed, stopping browser. Reason: ${reason}`);
159146
summary.ok = false;
147+
stopBrowser('browser_tap_bailout');
160148
});
161149
// Debugging
150+
// tapParser.on('line', logger.debug.bind(logger, 'browser_tap_line'));
162151
// tapParser.on('assert', logger.debug.bind(logger, 'browser_tap_assert'));
152+
// tapParser.once('fail', () => logger.debug('browser_tap_fail', 'Found one or more failing tests'));
163153
// tapParser.on('plan', logger.debug.bind(logger, 'browser_tap_plan'));
164154

165155
const browser = {
@@ -176,37 +166,50 @@ class ControlServer {
176166
// in `handleTap()` or `tapParser.on('line')`. But that adds significant overhead from
177167
// Node.js/V8 natively allocating many timers when processing large batches of test results.
178168
// Instead, merely store performance.now() and check that periodically.
179-
clientIdleTimer = setTimeout(function qtapCheckTimeout () {
180-
// TODO: Report timeout failure to reporter/TAP/CLI.
169+
// TODO: Write test for --connect-timeout by using a no-op browser.
170+
const TIMEOUT_CHECK_MS = 1000;
171+
const browserStart = performance.now();
172+
const qtapCheckTimeout = () => {
181173
if (!browser.clientIdleActive) {
182-
if ((performance.now() - launchStart) > (TIMEOUT_CONNECT * 1000)) {
183-
logger.warning('browser_connect_timeout', `Browser did not start within ${TIMEOUT_CONNECT}s, stopping browser`);
174+
if ((performance.now() - browserStart) > (this.connectTimeout * 1000)) {
175+
logger.warning('browser_connect_timeout', `Browser did not start within ${this.connectTimeout}s, stopping browser`);
184176
summary.ok = false;
185-
stopBrowser('QTap: browser_connect_timeout');
177+
stopBrowser('browser_connect_timeout');
186178
return;
187179
}
188180
} else {
189-
if ((performance.now() - browser.clientIdleActive) > (TIMEOUT_IDLE * 1000)) {
190-
logger.warning('browser_idle_timeout', `Browser idle for ${TIMEOUT_IDLE}s, stopping browser`);
181+
if ((performance.now() - browser.clientIdleActive) > (this.idleTimeout * 1000)) {
182+
logger.warning('browser_idle_timeout', `Browser idle for ${this.idleTimeout}s, stopping browser`);
191183
summary.ok = false;
192-
stopBrowser('QTap: browser_idle_timeout');
184+
stopBrowser('browser_idle_timeout');
193185
return;
194186
}
195187
}
196188
clientIdleTimer = setTimeout(qtapCheckTimeout, TIMEOUT_CHECK_MS);
197-
}, TIMEOUT_CHECK_MS);
189+
};
190+
clientIdleTimer = setTimeout(qtapCheckTimeout, TIMEOUT_CHECK_MS);
198191

199192
const url = await this.getProxyBase() + '/?qtap_clientId=' + clientId;
200-
const signals = { client: signal, global: globalSignal };
193+
const signals = { browser: signal, global: globalSignal };
201194

202195
try {
203196
logger.debug('browser_launch_call');
204197
await browserFn(url, signals, logger);
205-
logger.debug('browser_launch_ended');
206-
} finally {
198+
199+
// This stopBrowser() is most likely a no-op (e.g. if we received test results
200+
// or some error, and we asked the browser to stop). Just in case the browser
201+
// ended by itself, call it again here so that we can convey it as an error
202+
// if it was still running from our POV.
203+
logger.debug('browser_launch_exit');
204+
stopBrowser('browser_launch_exit');
205+
} catch (e) {
207206
// TODO: Report error to TAP. Eg. "No executable found"
208207
// TODO: logger.warning('browser_launch_exit', err); but catch in qtap.js?
209-
stopBrowser('QTap: browser_launch_ended');
208+
if (!signal.aborted) {
209+
logger.warning('browser_launch_error', e);
210+
stopBrowser('browser_launch_error');
211+
throw e;
212+
}
210213
}
211214
}
212215

0 commit comments

Comments
 (0)