Skip to content

Commit 65250ac

Browse files
committed
Implement client idle timeout
1 parent c34d151 commit 65250ac

File tree

8 files changed

+136
-48
lines changed

8 files changed

+136
-48
lines changed

bin/qtap.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ if (opts.version) {
5555
} else if (!program.args.length) {
5656
program.help();
5757
} else {
58+
process.on('unhandledRejection', (reason) => {
59+
console.error(reason);
60+
});
61+
process.on('uncaughtException', (error) => {
62+
console.error(error);
63+
});
64+
5865
try {
5966
const exitCode = await qtap.run(opts.browser, program.args, {
6067
config: opts.config,

src/server.js

Lines changed: 74 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -8,26 +8,7 @@ import stream from 'node:stream';
88

99
import { Parser as TapParser } from 'tap-parser';
1010
import tapFinished from '@tapjs/tap-finished';
11-
12-
const MIME_TYPES = {
13-
bin: 'application/octet-stream',
14-
css: 'text/css; charset=utf-8',
15-
gif: 'image/gif',
16-
htm: 'text/html; charset=utf-8',
17-
html: 'text/html; charset=utf-8',
18-
jpe: 'image/jpeg',
19-
jpeg: 'image/jpeg',
20-
jpg: 'image/jpeg',
21-
js: 'application/javascript; charset=utf-8',
22-
json: 'application/json; charset=utf-8',
23-
mjs: 'application/javascript; charset=utf-8',
24-
png: 'image/png',
25-
svg: 'image/svg+xml',
26-
ttf: 'font/sfnt',
27-
txt: 'text/plain; charset=utf-8',
28-
woff2: 'application/font-woff2',
29-
woff: 'font/woff',
30-
};
11+
import { MIME_TYPES, humanSeconds } from './util.js';
3112

3213
const QTAP_DEBUG = process.env.QTAP_DEBUG === '1';
3314

@@ -51,11 +32,16 @@ class ControlServer {
5132
this.testFile = testFile;
5233
this.browsers = new Map();
5334
this.logger = logger.channel('qtap_server_' + this.constructor.nextServerId++);
54-
// Optimization: Prefetch test file in parallel with http.Server#listen.
35+
// Optimization: Prefetch test file in parallel with server starting and browser launching.
36+
// Once browsers are launched and they make their first HTTP request,
37+
// we'll await this in handleRequest/getTestFile.
5538
this.testFilePromise = this.fetchTestFile(this.testFile);
5639

5740
const server = http.createServer();
5841
this.proxyBase = null;
42+
43+
// Optimization: Allow qtap.js to proceed and load browser functions.
44+
// We'll await this later in launchBrowser().
5945
this.proxyBasePromise = new Promise((resolve) => {
6046
server.on('listening', () => {
6147
this.proxyBase = 'http://localhost:' + server.address().port;
@@ -77,7 +63,7 @@ class ControlServer {
7763
this.handleTap(req, url, resp);
7864
break;
7965
default:
80-
this.handleStatic(req, url, resp);
66+
this.handleRequest(req, url, resp);
8167
}
8268
} catch (e) {
8369
this.logger.warning('respond_uncaught', e);
@@ -113,58 +99,96 @@ class ControlServer {
11399
const controller = new AbortController();
114100
const summary = { ok: true };
115101

102+
const CLIENT_IDLE_TIMEOUT = 5000;
103+
const CLIENT_IDLE_INTERVAL = 1000;
104+
let clientIdleTimer = null;
105+
116106
let readableController;
117107
const readable = new ReadableStream({
118108
start (readableControllerParam) {
119109
readableController = readableControllerParam;
120110
}
121111
});
122112

123-
this.browsers.set(clientId, {
113+
const browser = {
124114
logger,
125115
readableController,
126-
});
116+
clientIdleActive: performance.now(),
117+
};
118+
this.browsers.set(clientId, browser);
119+
120+
// NOTE: The below does not need to check browsers.get() before
121+
// calling browsers.delete() or controller.abort() to guard against
122+
// race conditions, because both of these are idempotent and ignore
123+
// all but the first call for a given client.
124+
//
125+
// Stop for these reasons (whichever is first):
126+
// * tap-finished.
127+
// * tap-parser 'bailout' event (client knows it crashed),
128+
// because tap-finished doesn't handle this.
129+
// * timeout after browser has not been idle for too long
130+
// (likely failed to start, lost connection, or crashed unknowingly)
131+
132+
const stopBrowser = async (reason) => {
133+
clearTimeout(clientIdleTimer);
134+
this.browsers.delete(clientId);
135+
controller.abort(reason);
136+
};
127137

128138
const tapFinishFinder = tapFinished({ wait: 0 }, () => {
129139
logger.debug('browser_tap_finished', 'Requesting browser stop');
130-
// Check in case browser already gone (race condition)
131-
if (this.browsers.get(clientId)) {
132-
this.browsers.delete(clientId);
133-
controller.abort('QTap: browser_tap_finished');
134-
}
140+
141+
stopBrowser('QTap: browser_tap_finished');
135142
});
136143

137-
// Also stop on bailout (tap-finished doesn't handle this)
138144
const tapParser = new TapParser();
139145
tapParser.on('bailout', (reason) => {
140146
summary.ok = false;
141147
logger.debug('browser_tap_bailout', reason);
142148

143-
// Check in case browser already gone (race condition)
144-
if (this.browsers.get(clientId)) {
145-
this.browsers.delete(clientId);
146-
controller.abort('QTap: browser_tap_bailout');
147-
}
149+
stopBrowser('QTap: browser_tap_bailout');
148150
});
149151
tapParser.once('fail', () => {
150152
summary.ok = false;
151153
logger.debug('browser_tap_fail', 'One or more tests failed');
152154
});
153-
154155
// Debugging
155156
// tapParser.on('assert', logger.debug.bind(logger, 'browser_tap_assert'));
156157
// tapParser.on('plan', logger.debug.bind(logger, 'browser_tap_plan'));
157158

159+
// TODO: Make timeout configurable, e.g. --timeout=3000s
160+
//
161+
// TODO: Consider handling timeout client-side by setting options.timeout to
162+
// qunit_config_testtimeout in getTestFile() and send a "Bail out!" tap
163+
// message which makes the server stop the browser.
164+
// Pro - timeouts are in sync. Con - we still need a "real" timeout
165+
// on this side.
166+
//
167+
// TODO: Dynamically increase this to whatever timeout the client's test framework
168+
// has set (QUnit, Mocha, Jasmine), with one second tolerance added for delay
169+
// (network/interprocess) between client and QTap.
170+
// We could probably read out smth like QUnit.config.testTimeout
171+
// and collect it via an endpoint like /.qtap/set_timeout?clientId=
172+
//
173+
// NOTE: We use performance.now() instead of natively clearTimeout+setTimeout
174+
// on each event, because that would add significant overhead from Node.js/V8
175+
// creating tons of timers when processing a large batch of test results back-to-back.
176+
clientIdleTimer = setTimeout(function qtapClientTimeout () {
177+
if ((performance.now() - browser.clientIdleActive) > CLIENT_IDLE_TIMEOUT) {
178+
logger.debug('browser_idle_timeout', 'Requesting browser stop');
179+
// TODO:
180+
// Produce a tap line to report this test failure to CLI output/reporters.
181+
summary.ok = false;
182+
stopBrowser('QTap: browser_idle_timeout');
183+
} else {
184+
clientIdleTimer = setTimeout(qtapClientTimeout, CLIENT_IDLE_INTERVAL);
185+
}
186+
}, CLIENT_IDLE_INTERVAL);
187+
158188
const [readeableForParser, readableForFinished] = readable.tee();
159189
readeableForParser.pipeTo(stream.Writable.toWeb(tapParser));
160190
readableForFinished.pipeTo(stream.Writable.toWeb(tapFinishFinder));
161191

162-
// TODO: Implement timeout if stream is quiet for too long
163-
// --timeout=3000s
164-
// use in getTestFile() as QTAP_TIMEOUT for qunit_config_testtimeout
165-
// use in $something to send an error to reporters and force-quit
166-
// the browser.
167-
168192
let signal = controller.signal;
169193
if (QTAP_DEBUG) {
170194
// Replace with dummy signal that is never aborted
@@ -282,7 +306,7 @@ class ControlServer {
282306
return html;
283307
}
284308

285-
async handleStatic (req, url, resp) {
309+
async handleRequest (req, url, resp) {
286310
const filePath = path.join(this.root, url.pathname);
287311
const ext = path.extname(url.pathname).slice(1);
288312
if (!filePath.startsWith(this.root)) {
@@ -328,8 +352,14 @@ class ControlServer {
328352
const clientId = url.searchParams.get('qtap_clientId');
329353
const browser = this.browsers.get(clientId);
330354
if (browser) {
355+
const now = performance.now();
331356
browser.readableController.enqueue(body);
332-
browser.logger.debug('browser_tap_received', JSON.stringify(body.slice(0, 30) + '…'));
357+
browser.logger.debug('browser_tap_received',
358+
`+${humanSeconds(now - browser.clientIdleActive)}s`,
359+
JSON.stringify(body.slice(0, 30) + '…')
360+
);
361+
362+
browser.clientIdleActive = performance.now();
333363
} else {
334364
this.logger.debug('browser_tap_unhandled', clientId, JSON.stringify(body.slice(0, 30) + '…'));
335365
}

src/util.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use strict';
2+
3+
export const MIME_TYPES = {
4+
bin: 'application/octet-stream',
5+
css: 'text/css; charset=utf-8',
6+
gif: 'image/gif',
7+
htm: 'text/html; charset=utf-8',
8+
html: 'text/html; charset=utf-8',
9+
jpe: 'image/jpeg',
10+
jpeg: 'image/jpeg',
11+
jpg: 'image/jpeg',
12+
js: 'application/javascript; charset=utf-8',
13+
json: 'application/json; charset=utf-8',
14+
mjs: 'application/javascript; charset=utf-8',
15+
png: 'image/png',
16+
svg: 'image/svg+xml',
17+
ttf: 'font/sfnt',
18+
txt: 'text/plain; charset=utf-8',
19+
woff2: 'application/font-woff2',
20+
woff: 'font/woff',
21+
};
22+
23+
/**
24+
* @param {number} msDuration
25+
* @returns {string} Something like "0.7", "2", or "3.1"
26+
*/
27+
export function humanSeconds (msDuration) {
28+
return (msDuration / 1000)
29+
.toFixed(1)
30+
.replace(/\.(0+)?$/, '');
31+
}

test/bail.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!DOCTYPE html>
2-
<title>basic</title>
2+
<title>bail</title>
33
<script>
44
console.log('TAP version 13');
55
console.log('ok 1 Foo bar');

test/error.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!DOCTYPE html>
2-
<title>basic</title>
2+
<title>error</title>
33
<script>
44
console.log('TAP version 13');
55
console.log('ok 1 Foo bar');

test/fail.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!DOCTYPE html>
2-
<title>basic</title>
2+
<title>fail</title>
33
<script>
44
console.log('TAP version 13');
55
console.log('ok 1 Foo bar');

test/pass.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<!DOCTYPE html>
2-
<title>basic</title>
2+
<title>pass</title>
33
<script>
44
console.log('TAP version 13');
55
console.log('ok 1 Foo bar');

test/timeout.html

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<!DOCTYPE html>
2+
<title>timeout</title>
3+
<script>
4+
console.log('TAP version 13');
5+
function delay(ms, fn) {
6+
setTimeout(fn, ms);
7+
}
8+
delay(1000, () => {
9+
console.log('ok 1 Foo bar');
10+
delay(2100, () => {
11+
console.log('ok 2 Baz > this thing');
12+
delay(3200, () => {
13+
console.log('ok 3 Baz > another thing');
14+
delay(4000, () => {
15+
console.log('ok 4 Quux');
16+
});
17+
});
18+
});
19+
});
20+
</script>

0 commit comments

Comments
 (0)