Skip to content

Commit 185b2c9

Browse files
authored
Harden local server readiness and navigation retries
1 parent 1f93e8a commit 185b2c9

2 files changed

Lines changed: 103 additions & 5 deletions

File tree

src/lib/plugins/run-code-blocks/directives/screenshot.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,19 @@ function compile(steps: string, path: `${string}.png`, args: Args): string {
5858
let script = [
5959
`const puppeteer = require('puppeteer');
6060
const NAVIGATION_TIMEOUT = 180000;
61+
const MAX_NAVIGATION_RETRIES = 8;
6162
const RETRYABLE_NAVIGATION_ERRORS = [
6263
'Navigation timeout',
6364
'net::ERR_CONNECTION_REFUSED',
64-
'net::ERR_CONNECTION_RESET'
65+
'net::ERR_CONNECTION_RESET',
66+
'net::ERR_ABORTED',
67+
'ERR_HTTP_RESPONSE_CODE_FAILURE'
6568
];
6669
70+
function retryDelay(attempt) {
71+
return Math.min(500 * Math.pow(2, attempt), 5000);
72+
}
73+
6774
async function main() {
6875
let browser = await puppeteer.launch();
6976
let page = await browser.newPage();
@@ -98,14 +105,14 @@ async function main() {
98105
script.push(` await page.evaluate(${js(params[0])});`);
99106
break;
100107
case 'visit':
101-
script.push(` for (let _attempt = 0; _attempt < 3; _attempt++) {`);
108+
script.push(` for (let _attempt = 0; _attempt < MAX_NAVIGATION_RETRIES; _attempt++) {`);
102109
script.push(` try {`);
103110
script.push(` await page.goto(${js(params[0])}, { waitUntil: 'domcontentloaded', timeout: NAVIGATION_TIMEOUT });`);
104111
script.push(` break;`);
105112
script.push(` } catch (e) {`);
106113
script.push(` let message = e instanceof Error ? e.message : String(e);`);
107114
script.push(` let shouldRetry = RETRYABLE_NAVIGATION_ERRORS.some(pattern => message.includes(pattern));`);
108-
script.push(` if (_attempt === 2 || !shouldRetry) throw e;`);
115+
script.push(` if (_attempt === MAX_NAVIGATION_RETRIES - 1 || !shouldRetry) throw e;`);
109116
script.push(` try {`);
110117
script.push(` await page.close();`);
111118
script.push(` } catch (closeError) {`);
@@ -115,7 +122,7 @@ async function main() {
115122
script.push(` page = await browser.newPage();`);
116123
script.push(` page.setDefaultNavigationTimeout(NAVIGATION_TIMEOUT);`);
117124
script.push(` await page.setViewport(${js(viewport)});`);
118-
script.push(` await new Promise(r => setTimeout(r, 2000));`);
125+
script.push(` await new Promise(r => setTimeout(r, retryDelay(_attempt)));`);
119126
script.push(` }`);
120127
script.push(` }`);
121128
break;

src/lib/plugins/run-code-blocks/directives/server/start.ts

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { Code } from 'mdast';
2+
import { createConnection } from 'net';
23
import { join } from 'path';
34
import { Option, assert } from 'ts-std';
45
import { parseCommand } from '../../commands';
56
import Options from '../../options';
67
import parseArgs, { ToBool, optional } from '../../parse-args';
78
import Servers from '../../servers';
89

10+
const DEFAULT_READY_TIMEOUT = 30000;
11+
const READY_RETRY_INTERVAL = 500;
12+
913
interface Args {
1014
id?: string;
1115
lang?: string;
@@ -17,6 +21,64 @@ interface Args {
1721
captureOutput?: boolean;
1822
}
1923

24+
function extractURL(expect: Option<string>): Option<string> {
25+
if (!expect) {
26+
return null;
27+
}
28+
29+
let match = expect.match(/https?:\/\/[^\s"']+/);
30+
31+
if (match) {
32+
return match[0]!;
33+
} else {
34+
return null;
35+
}
36+
}
37+
38+
async function waitForServerReady(url: string, timeout: number): Promise<void> {
39+
let parsed = new URL(url);
40+
let host = parsed.hostname;
41+
let port = parsed.port ? Number(parsed.port) : (parsed.protocol === 'https:' ? 443 : 80);
42+
let startedAt = Date.now();
43+
let lastError: Option<Error> = null;
44+
45+
while (Date.now() - startedAt < timeout) {
46+
try {
47+
await new Promise<void>((resolve, reject) => {
48+
let socket = createConnection({ host, port });
49+
50+
socket.once('connect', () => {
51+
socket.end();
52+
resolve();
53+
});
54+
55+
socket.once('error', e => {
56+
socket.destroy();
57+
58+
if (e instanceof Error) {
59+
reject(e);
60+
} else {
61+
reject(new Error(String(e)));
62+
}
63+
});
64+
});
65+
66+
return;
67+
} catch (e) {
68+
if (e instanceof Error) {
69+
lastError = e;
70+
} else {
71+
lastError = new Error(String(e));
72+
}
73+
}
74+
75+
await new Promise(resolve => setTimeout(resolve, READY_RETRY_INTERVAL));
76+
}
77+
78+
let details = lastError ? ` Last error: ${lastError.message}` : '';
79+
throw new Error(`Timed out while waiting for ${url} to accept connections after ${timeout}ms.${details}`);
80+
}
81+
2082
export default async function startServer(node: Code, options: Options, servers: Servers): Promise<Option<Code>> {
2183
let args = parseArgs<Args>(node, [
2284
optional('id', String),
@@ -72,7 +134,36 @@ export default async function startServer(node: Code, options: Options, servers:
72134
output.push(`$ ${display}`);
73135
}
74136

75-
let stdout = await server.start(args.expect);
137+
let stdout = await server.start(args.expect, args.timeout);
138+
139+
let readyURL = extractURL(args.expect);
140+
141+
if (readyURL) {
142+
let timeout = args.timeout || DEFAULT_READY_TIMEOUT;
143+
144+
try {
145+
await waitForServerReady(readyURL, timeout);
146+
} catch (e) {
147+
await server.kill();
148+
149+
let message = (e instanceof Error) ? e.message : String(e);
150+
151+
throw new Error(
152+
`${message}
153+
154+
====== STDOUT ======
155+
156+
${server.stdout || '(No output)'}
157+
158+
====== STDERR ======
159+
160+
${server.stderr || '(No output)'}
161+
162+
====================
163+
`
164+
);
165+
}
166+
}
76167

77168
if (args.captureOutput && stdout) {
78169
output.push(stdout);

0 commit comments

Comments
 (0)