Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 154 additions & 33 deletions src/backend/cloudflared/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,37 @@ import { promisify } from "node:util";

const execFileAsync = promisify(execFile);
const URL_REGEX = /https:\/\/[a-z0-9.-]+\.trycloudflare\.com/i;
const DEFAULT_STARTUP_TIMEOUT_MS = 30_000;
const DEFAULT_MAX_ATTEMPTS = 3;
const DEFAULT_RETRY_DELAY_MS = 1_000;

type ExecFileImpl = (file: string, args: string[]) => Promise<unknown>;

type SpawnedProcess = {
stdout?: NodeJS.ReadableStream | null;
stderr?: NodeJS.ReadableStream | null;
killed?: boolean;
kill(signal?: NodeJS.Signals | number): boolean;
on(event: "error", listener: (error: Error) => void): SpawnedProcess;
on(event: "exit", listener: (code: number | null) => void): SpawnedProcess;
};

type SpawnProcess = (
command: string,
args: string[],
options: {
stdio: ["ignore", "pipe", "pipe"];
env: NodeJS.ProcessEnv;
}
) => SpawnedProcess;

export interface CloudflaredManagerOptions {
execFileImpl?: ExecFileImpl;
spawnProcess?: SpawnProcess;
startupTimeoutMs?: number;
maxAttempts?: number;
retryDelayMs?: number;
}

export interface CloudflaredResult {
publicUrl: string;
Expand All @@ -21,9 +52,34 @@ const architectureToRelease = (arch: NodeJS.Architecture): string => {
}
};

const summarizeOutput = (text: string): string => {
const normalized = text.replace(/\s+/g, " ").trim();
if (normalized.length <= 400) {
return normalized;
}

return `${normalized.slice(0, 397)}...`;
};

export class CloudflaredManager {
private process?: ReturnType<typeof spawn>;
private process?: SpawnedProcess;
private executable = "cloudflared";
private readonly execFileImpl: ExecFileImpl;
private readonly spawnProcess: SpawnProcess;
private readonly startupTimeoutMs: number;
private readonly maxAttempts: number;
private readonly retryDelayMs: number;

public constructor(options: CloudflaredManagerOptions = {}) {
this.execFileImpl = options.execFileImpl ?? ((file, args) => execFileAsync(file, args));
this.spawnProcess =
options.spawnProcess ??
((command, args, spawnOptions) =>
spawn(command, args, spawnOptions) as unknown as SpawnedProcess);
this.startupTimeoutMs = options.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS;
this.maxAttempts = options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
}

public async ensureInstalled(): Promise<void> {
const userBinary = path.join(os.homedir(), ".local", "bin", "cloudflared");
Expand Down Expand Up @@ -55,58 +111,115 @@ export class CloudflaredManager {

public async start(port: number): Promise<CloudflaredResult> {
await this.ensureInstalled();
const args = ["tunnel", "--url", `http://localhost:${port}`];
this.process = spawn(this.executable, args, {
let lastError: Error | undefined;

for (let attempt = 1; attempt <= this.maxAttempts; attempt += 1) {
try {
return await this.startOnce(port);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
this.stop();
if (attempt < this.maxAttempts) {
await this.delay(this.retryDelayMs);
}
}
}

throw new Error(
`cloudflared failed after ${this.maxAttempts} attempts: ${lastError?.message ?? "unknown error"}`
);
}

public stop(): void {
if (this.process && !this.process.killed) {
this.process.kill("SIGTERM");
this.process = undefined;
}
}

private async startOnce(port: number): Promise<CloudflaredResult> {
const child = this.spawnProcess(this.executable, ["tunnel", "--url", `http://localhost:${port}`], {
stdio: ["ignore", "pipe", "pipe"],
env: process.env
});
this.process = child;

return new Promise<CloudflaredResult>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timed out waiting for cloudflared URL"));
}, 30_000);
const stderrChunks: string[] = [];
let settled = false;
let timeout: NodeJS.Timeout | undefined;

const finish = (handler: () => void): void => {
if (settled) {
return;
}

settled = true;
if (timeout) {
clearTimeout(timeout);
}
handler();
};

const readText = (chunk: Buffer | string): string =>
typeof chunk === "string" ? chunk : chunk.toString("utf8");

const buildError = (message: string): Error => {
const stderrText = summarizeOutput(stderrChunks.join(""));
return stderrText ? new Error(`${message}: ${stderrText}`) : new Error(message);
};

const onData = (chunk: Buffer): void => {
const text = chunk.toString("utf8");
const onData = (chunk: Buffer | string): void => {
const text = readText(chunk);
const match = text.match(URL_REGEX);
if (!match) {
return;
}

clearTimeout(timeout);
resolve({ publicUrl: match[0] });
finish(() => resolve({ publicUrl: match[0] }));
};

const onError = (chunk: Buffer): void => {
const text = chunk.toString("utf8");
if (text.toLowerCase().includes("error")) {
clearTimeout(timeout);
reject(new Error(`cloudflared error: ${text.trim()}`));
}
const onStderr = (chunk: Buffer | string): void => {
stderrChunks.push(readText(chunk));
onData(chunk);
};

this.process?.stdout?.on("data", onData);
this.process?.stderr?.on("data", onData);
this.process?.stderr?.on("data", onError);
this.process?.on("exit", (code) => {
clearTimeout(timeout);
if (code !== 0) {
reject(new Error(`cloudflared exited before URL was emitted (${code ?? -1})`));
child.stdout?.on("data", onData);
child.stderr?.on("data", onStderr);
child.on("error", (error) => {
if (this.process === child) {
this.process = undefined;
}
finish(() => reject(buildError(`cloudflared failed to start: ${error.message}`)));
});
});
}
child.on("exit", (code) => {
if (this.process === child) {
this.process = undefined;
}
if (settled) {
return;
}

public stop(): void {
if (this.process && !this.process.killed) {
this.process.kill("SIGTERM");
this.process = undefined;
}
const exitCode = code ?? -1;
const message =
code === 0
? "cloudflared exited before URL was emitted"
: `cloudflared exited before URL was emitted (${exitCode})`;
finish(() => reject(buildError(message)));
});

timeout = setTimeout(() => {
if (this.process === child) {
this.process = undefined;
}
finish(() => reject(buildError("Timed out waiting for cloudflared URL")));
}, this.startupTimeoutMs);
Comment on lines +211 to +216
Comment on lines +211 to +216

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Resource leak: child process not killed on timeout.

When the timeout fires, this.process is cleared before rejecting, but the actual child process is never killed. When the retry loop catches the error and calls this.stop(), this.process is already undefined, so the orphaned process continues running.

🐛 Proposed fix: kill the child before clearing the reference
       timeout = setTimeout(() => {
+        if (!child.killed) {
+          child.kill("SIGTERM");
+        }
         if (this.process === child) {
           this.process = undefined;
         }
         finish(() => reject(buildError("Timed out waiting for cloudflared URL")));
       }, this.startupTimeoutMs);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
timeout = setTimeout(() => {
if (this.process === child) {
this.process = undefined;
}
finish(() => reject(buildError("Timed out waiting for cloudflared URL")));
}, this.startupTimeoutMs);
timeout = setTimeout(() => {
if (!child.killed) {
child.kill("SIGTERM");
}
if (this.process === child) {
this.process = undefined;
}
finish(() => reject(buildError("Timed out waiting for cloudflared URL")));
}, this.startupTimeoutMs);

});
}

private async isExecutableAvailable(binary: string): Promise<boolean> {
try {
await execFileAsync(binary, ["--version"]);
await this.execFileImpl(binary, ["--version"]);
return true;
} catch {
return false;
Expand All @@ -116,14 +229,14 @@ export class CloudflaredManager {
private async tryInstall(): Promise<void> {
switch (process.platform) {
case "darwin": {
await execFileAsync("brew", ["install", "cloudflared"]);
await this.execFileImpl("brew", ["install", "cloudflared"]);
return;
}
case "linux": {
const installDir = path.join(os.homedir(), ".local", "bin");
const releaseArch = architectureToRelease(process.arch);
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${releaseArch}`;
await execFileAsync("bash", [
await this.execFileImpl("bash", [
"-lc",
`mkdir -p "${installDir}" && curl -fsSL "${downloadUrl}" -o "${installDir}/cloudflared" && chmod +x "${installDir}/cloudflared"`
]);
Expand All @@ -133,4 +246,12 @@ export class CloudflaredManager {
throw new Error(`cloudflared auto-install unsupported on ${process.platform}`);
}
}

private async delay(ms: number): Promise<void> {
if (ms <= 0) {
return;
}

await new Promise((resolve) => setTimeout(resolve, ms));
}
}
77 changes: 77 additions & 0 deletions tests/backend/cloudflared-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { EventEmitter } from "node:events";
import { PassThrough } from "node:stream";
import { describe, expect, test, vi } from "vitest";
import { CloudflaredManager } from "../../src/backend/cloudflared/manager.js";

class FakeCloudflaredProcess extends EventEmitter {
public readonly stdout = new PassThrough();
public readonly stderr = new PassThrough();
public killed = false;

public kill = vi.fn((_signal?: NodeJS.Signals | number) => {
this.killed = true;
this.emit("exit", 0);
return true;
});
}

describe("cloudflared manager", () => {
test("does not fail on early stderr error text if a tunnel URL is emitted", async () => {
const process = new FakeCloudflaredProcess();
const execFileImpl = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
const spawnProcess = vi.fn(() => {
queueMicrotask(() => {
process.stderr.write("2026-03-15T21:15:11Z ERR Error unmarshaling QuickTunnel response, retrying\n");
process.stdout.write("https://resilient-example.trycloudflare.com\n");
});
return process;
});

const manager = new CloudflaredManager({
execFileImpl,
spawnProcess,
maxAttempts: 1,
retryDelayMs: 0,
startupTimeoutMs: 100
});

await expect(manager.start(8767)).resolves.toEqual({
publicUrl: "https://resilient-example.trycloudflare.com"
});
expect(spawnProcess).toHaveBeenCalledTimes(1);
});

test("retries quick tunnel startup after a transient failure", async () => {
const firstProcess = new FakeCloudflaredProcess();
const secondProcess = new FakeCloudflaredProcess();
const execFileImpl = vi.fn().mockResolvedValue({ stdout: "", stderr: "" });
const spawnProcess = vi
.fn()
.mockImplementationOnce(() => {
queueMicrotask(() => {
firstProcess.stderr.write("2026-03-15T21:15:11Z ERR Error unmarshaling QuickTunnel response\n");
firstProcess.emit("exit", 1);
});
return firstProcess;
})
.mockImplementationOnce(() => {
queueMicrotask(() => {
secondProcess.stdout.write("https://steady-example.trycloudflare.com\n");
});
return secondProcess;
});

const manager = new CloudflaredManager({
execFileImpl,
spawnProcess,
maxAttempts: 2,
retryDelayMs: 0,
startupTimeoutMs: 100
});

await expect(manager.start(8767)).resolves.toEqual({
publicUrl: "https://steady-example.trycloudflare.com"
});
expect(spawnProcess).toHaveBeenCalledTimes(2);
});
});
Loading