Skip to content

Commit fafcee6

Browse files
committed
refactor: improve tunnel stability
1 parent dc075ea commit fafcee6

2 files changed

Lines changed: 52 additions & 14 deletions

File tree

src/cloudflared/tunnel.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@
33
*
44
* Check main license for more information
55
*/
6-
import { spawn, ChildProcess } from "node:child_process";
6+
import { spawn, type ChildProcess } from "node:child_process";
7+
import { once } from "node:events";
78
import type { Connection } from "./types.ts";
89
import { cloudflaredBinPath, connRegex, ipRegex, locationRegex, indexRegex } from "./constants.ts";
910

@@ -22,8 +23,8 @@ export function startCloudflaredTunnel(
2223
connections: Promise<Connection>[];
2324
/** Spwaned cloudflared process */
2425
child: ChildProcess;
25-
/** Stop the cloudflared process */
26-
stop: ChildProcess["kill"];
26+
/** Stop the cloudflared process and wait for it to exit */
27+
stop: () => Promise<void>;
2728
} {
2829
const args: string[] = ["tunnel"];
2930
for (const [key, value] of Object.entries(options)) {
@@ -56,6 +57,8 @@ export function startCloudflaredTunnel(
5657
let urlResolver: (value: string | PromiseLike<string>) => void = () => undefined;
5758
let urlRejector: (reason: unknown) => void = () => undefined;
5859
const url = new Promise<string>((...pair) => ([urlResolver, urlRejector] = pair));
60+
// Avoid unhandled-rejection warnings if no consumer awaits before child fails.
61+
url.catch(() => undefined);
5962

6063
const connectionResolvers: ((value: Connection | PromiseLike<Connection>) => void)[] = [];
6164
const connectionRejectors: ((reason: unknown) => void)[] = [];
@@ -90,8 +93,28 @@ export function startCloudflaredTunnel(
9093
};
9194
child.stdout.on("data", parser).on("error", urlRejector);
9295
child.stderr.on("data", parser).on("error", urlRejector);
96+
child.on("error", urlRejector);
97+
child.on("exit", (code, signal) => {
98+
const reason = new Error(
99+
`cloudflared exited (code=${code}, signal=${signal}) before URL was ready`,
100+
);
101+
urlRejector(reason);
102+
for (const reject of connectionRejectors) reject?.(reason);
103+
});
93104

94-
const stop = () => child.kill("SIGINT");
105+
const stop = async (): Promise<void> => {
106+
if (child.exitCode !== null || child.signalCode !== null) return;
107+
const exited = once(child, "exit");
108+
child.kill("SIGINT");
109+
const killTimer = setTimeout(() => {
110+
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
111+
}, 5000);
112+
try {
113+
await exited;
114+
} finally {
115+
clearTimeout(killTimer);
116+
}
117+
};
95118

96119
return { url, connections, child, stop };
97120
}

src/tunnel.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { existsSync } from "node:fs";
2+
import { constants as osConstants } from "node:os";
23
import { log, prompt } from "./log.ts";
34

45
export interface TunnelOptions {
@@ -95,23 +96,37 @@ export async function startTunnel(opts: TunnelOptions): Promise<undefined | Tunn
9596
await installCloudflared();
9697
}
9798

98-
const args = [["--url", url], opts.verifyTLS ? undefined : ["--no-tls-verify", ""]].filter(
99+
const args = [["--url", url], opts.verifyTLS ? undefined : ["--no-tls-verify", null]].filter(
99100
Boolean,
100-
) as [string, string][];
101+
) as [string, string | null][];
101102

102-
const tunnel = await startCloudflaredTunnel(Object.fromEntries(args), opts.extraArgs);
103+
const tunnel = startCloudflaredTunnel(Object.fromEntries(args), opts.extraArgs);
103104

104-
const cleanup = async () => {
105+
let closed = false;
106+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"] as const;
107+
const handlers = new Map<NodeJS.Signals, () => void>();
108+
109+
const cleanup = async (): Promise<void> => {
110+
if (closed) return;
111+
closed = true;
112+
for (const [sig, handler] of handlers) process.off(sig, handler);
113+
handlers.clear();
105114
await tunnel.stop();
106115
};
107-
for (const signal of ["SIGINT", "SIGUSR1", "SIGUSR2"] as const) {
108-
process.once(signal, cleanup);
116+
117+
for (const signal of signals) {
118+
const handler = () => {
119+
cleanup().finally(() => {
120+
const signo = osConstants.signals[signal] ?? 0;
121+
process.exit(128 + signo);
122+
});
123+
};
124+
handlers.set(signal, handler);
125+
process.once(signal, handler);
109126
}
110127

111128
return {
112-
getURL: async () => await tunnel.url,
113-
close: async () => {
114-
await cleanup();
115-
},
129+
getURL: () => tunnel.url,
130+
close: cleanup,
116131
};
117132
}

0 commit comments

Comments
 (0)