Skip to content

Commit 5cb1213

Browse files
Merge pull request #8 from HarperFast/fix/shutdown-review
fix(browser): address shutdown-drain review (v1.4.2)
2 parents 6f13bea + 7064f95 commit 5cb1213

4 files changed

Lines changed: 88 additions & 54 deletions

File tree

packages/browser/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harperfast/prerender-browser",
3-
"version": "1.4.1",
3+
"version": "1.4.2",
44
"type": "module",
55
"description": "Headless-browser render library for Harper Prerender: claims render jobs from the @harperfast/prerender queue, renders pages in headless Chrome (Puppeteer), and posts the HTML back. Embedded by a render service and configured entirely via startWorker() options.",
66
"keywords": [

packages/browser/src/RenderQueueConsumer.ts

Lines changed: 54 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -27,55 +27,67 @@ interface ProducerState {
2727
export async function* RenderQueueConsumer(signal?: AbortSignal) {
2828
const mqttClient = await connectMqtt();
2929

30-
const producerStates = new Map<string, ProducerState>();
31-
32-
mqttClient.on('message', (_topic, payload) => {
33-
const queueState = JSON.parse(payload.toString());
34-
producerStates.set(queueState.hostname, queueState);
35-
});
36-
37-
await mqttClient.subscribeAsync(Topic.queueState, { qos: 1, rh: 0 });
38-
39-
const pickAvailableQueueHost: () => string | null = () => {
40-
const eligibleHosts: string[] = [];
41-
42-
producerStates.forEach((producer) => {
43-
if (producer.status === ProducerStatus.queued) {
44-
eligibleHosts.push(producer.hostname);
30+
// try wraps ALL setup after connectMqtt (message handler, subscribe, loop) so a failure
31+
// during setup — e.g. subscribeAsync throwing — still closes the MQTT client in `finally`
32+
// instead of leaking the connection.
33+
try {
34+
const producerStates = new Map<string, ProducerState>();
35+
36+
mqttClient.on('message', (_topic, payload) => {
37+
// This runs async in the event loop, so a JSON.parse throw would bypass the
38+
// surrounding try/finally and crash the worker — guard it.
39+
try {
40+
const queueState = JSON.parse(payload.toString());
41+
producerStates.set(queueState.hostname, queueState);
42+
} catch (err) {
43+
logger.error({ err }, 'failed to parse queue_status message');
4544
}
4645
});
4746

48-
if (eligibleHosts.length === 0) return null;
49-
50-
return eligibleHosts[Math.floor(Math.random() * eligibleHosts.length)];
51-
};
52-
53-
const claimJobs = async (host: string, limit: number): Promise<RenderJob[]> => {
54-
try {
55-
const res = await request(`http://${host}:${settings.queuePort}`, {
56-
method: 'POST',
57-
path: '/render_queue/claim',
58-
body: JSON.stringify({
59-
limit,
60-
}),
61-
headers: {
62-
'content-type': 'application/json',
63-
},
47+
await mqttClient.subscribeAsync(Topic.queueState, { qos: 1, rh: 0 });
48+
49+
const pickAvailableQueueHost: () => string | null = () => {
50+
const eligibleHosts: string[] = [];
51+
52+
producerStates.forEach((producer) => {
53+
if (producer.status === ProducerStatus.queued) {
54+
eligibleHosts.push(producer.hostname);
55+
}
6456
});
65-
if (res.statusCode === 200) {
66-
const jobs: any = await res.body.json();
67-
return jobs.map((job: any) => new RenderJob(job));
68-
} else {
69-
logger.error(res.body.json());
57+
58+
if (eligibleHosts.length === 0) return null;
59+
60+
return eligibleHosts[Math.floor(Math.random() * eligibleHosts.length)];
61+
};
62+
63+
const claimJobs = async (host: string, limit: number): Promise<RenderJob[]> => {
64+
try {
65+
const res = await request(`http://${host}:${settings.queuePort}`, {
66+
method: 'POST',
67+
path: '/render_queue/claim',
68+
body: JSON.stringify({
69+
limit,
70+
}),
71+
headers: {
72+
'content-type': 'application/json',
73+
},
74+
});
75+
if (res.statusCode === 200) {
76+
const jobs: any = await res.body.json();
77+
return jobs.map((job: any) => new RenderJob(job));
78+
} else {
79+
// res.body.json() is a Promise (and can reject on a non-JSON error body) — await
80+
// text() so we log the actual response, not a pending Promise / unhandled rejection.
81+
const body = await res.body.text().catch(() => '');
82+
logger.error({ statusCode: res.statusCode, body }, 'failed to claim jobs');
83+
return [];
84+
}
85+
} catch (e) {
86+
logger.error(e);
7087
return [];
7188
}
72-
} catch (e) {
73-
logger.error(e);
74-
return [];
75-
}
76-
};
89+
};
7790

78-
try {
7991
while (!signal?.aborted) {
8092
const host = pickAvailableQueueHost();
8193

packages/browser/src/Worker.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ export default class RenderWorker {
7272
}, 10000);
7373
this.browserCleanupInterval.unref();
7474

75-
process.on('uncaughtException', (err) => {
75+
process.on('uncaughtException', async (err) => {
7676
logger.error({ err }, 'uncaught exception');
77-
this.destroy();
77+
// Await so browsers are actually closed before the event loop dies (otherwise Chrome
78+
// is orphaned). ManagedBrowser.close() has its own SIGKILL fallback if it hangs.
79+
await this.destroy().catch(() => {});
7880
process.exit(1);
7981
});
8082

@@ -175,26 +177,37 @@ export default class RenderWorker {
175177
await Promise.race([Promise.allSettled([...this.inflight]), deadline]);
176178
ac.abort();
177179

178-
this.destroy();
180+
await this.destroy();
179181
logger.info('worker shutdown complete');
180182
}
181183

182-
destroy() {
184+
// Async so callers can AWAIT it before process.exit() — otherwise the event loop dies before
185+
// the browser .close() promises run and Chrome is orphaned (the whole point of closing here).
186+
async destroy() {
183187
clearInterval(this.logStatsInterval);
184188
if (this.browserCleanupInterval !== null) {
185189
clearInterval(this.browserCleanupInterval);
186190
this.browserCleanupInterval = null;
187191
}
188192

189-
// close all browsers
193+
const closing: Promise<void>[] = [];
190194
if (this.browser) {
191-
this.browser.close().catch(noop);
195+
closing.push(this.browser.close().catch(noop));
196+
}
197+
// A browser mid-launch isn't in `this.browser` yet; close it once it resolves so a
198+
// destroy() during launch (shutdown drain deadline, or an uncaught exception) doesn't
199+
// orphan the Chrome process.
200+
if (this.browserPromise) {
201+
closing.push(this.browserPromise.then((b) => b.close().catch(noop)).catch(noop));
192202
}
193203
this.retiredBrowsers.forEach((browser) => {
194-
browser.close().catch(noop);
204+
closing.push(browser.close().catch(noop));
195205
});
206+
196207
this.browser = null;
197208
this.retiredBrowsers.clear();
209+
210+
await Promise.all(closing);
198211
}
199212

200213
closeRetiredBrowsers() {

packages/browser/src/errorHandler.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,20 +53,29 @@ export class ErrorHandler {
5353
}
5454

5555
private async handleTermination(signal: string) {
56-
if (this.terminating) return; // ignore a second SIGTERM/SIGINT while already draining
56+
if (this.terminating) {
57+
// A second signal (e.g. an impatient Ctrl+C) forces an immediate, unclean exit
58+
// instead of waiting out the drain.
59+
logger.warn({ signal }, 'Second termination signal — forcing exit');
60+
process.exit(1);
61+
}
5762
this.terminating = true;
5863
logger.info({ signal }, 'Termination signal received');
5964

60-
// Hard backstop: if the drain hangs, still exit before the supervisor SIGKILLs us.
61-
const backstop = setTimeout(() => process.exit(0), this.shutdownDeadlineMs);
65+
// Hard backstop: if the drain hangs, exit before the supervisor SIGKILLs us — with a
66+
// NON-zero code so the orchestrator sees an unclean/timed-out shutdown, not a clean one.
67+
const backstop = setTimeout(() => process.exit(1), this.shutdownDeadlineMs);
6268
backstop.unref();
69+
let exitCode = 0;
6370
try {
6471
await this.onTerminate?.();
6572
} catch (err) {
73+
// The drain failed/was incomplete — signal an unclean shutdown to the orchestrator.
6674
logger.error({ err }, 'error during graceful shutdown');
75+
exitCode = 1;
6776
}
6877
clearTimeout(backstop);
69-
process.exit(0);
78+
process.exit(exitCode);
7079
}
7180

7281
private gracefulShutdown(exitCode: number) {

0 commit comments

Comments
 (0)