fix(voice-call): bound ngrok diagnostics

This commit is contained in:
Vincent Koc
2026-05-28 12:16:33 +02:00
parent 1bc32e53ab
commit 629fc2f8f0
4 changed files with 103 additions and 9 deletions

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import {
appendBoundedChildOutput,
emptyBoundedChildOutput,
formatBoundedChildOutput,
} from "./bounded-child-output.js";
describe("bounded child output", () => {
it("keeps a bounded tail and records truncation", () => {
const first = appendBoundedChildOutput(emptyBoundedChildOutput(), "abcdef", 5);
expect(first).toEqual({ text: "bcdef", truncated: true });
const second = appendBoundedChildOutput(first, "ghij", 5);
expect(second).toEqual({ text: "fghij", truncated: true });
expect(formatBoundedChildOutput(second)).toBe("[output truncated]\nfghij");
});
});

View File

@@ -0,0 +1,29 @@
const DEFAULT_MAX_OUTPUT_CHARS = 16_384;
export type BoundedChildOutput = {
text: string;
truncated: boolean;
};
export function emptyBoundedChildOutput(): BoundedChildOutput {
return { text: "", truncated: false };
}
export function appendBoundedChildOutput(
current: BoundedChildOutput,
chunk: string,
maxChars = DEFAULT_MAX_OUTPUT_CHARS,
): BoundedChildOutput {
const appended = current.text + chunk;
if (appended.length <= maxChars) {
return { text: appended, truncated: current.truncated };
}
return {
text: appended.slice(-maxChars),
truncated: true,
};
}
export function formatBoundedChildOutput(output: BoundedChildOutput): string {
return output.truncated ? `[output truncated]\n${output.text}` : output.text;
}

View File

@@ -90,6 +90,27 @@ describe("voice-call tunnels", () => {
);
});
it("parses complete ngrok log lines before bounding the incomplete tail", async () => {
const proc = nextProcess();
const result = startNgrokTunnel({ port: 3334, path: "/voice/webhook" });
proc.stdout.emit(
"data",
Buffer.from(
`${JSON.stringify({ msg: "started tunnel", url: "https://large.ngrok.io" })}\n${"x".repeat(20_000)}`,
),
);
const settled = await Promise.race([
result.then(() => true),
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 20)),
]);
expect(settled).toBe(true);
const tunnel = await result;
expect(tunnel.publicUrl).toBe("https://large.ngrok.io/voice/webhook");
});
it("sets ngrok auth token before starting the tunnel", async () => {
const authProc = nextProcess();
const tunnelProc = nextProcess();
@@ -111,6 +132,22 @@ describe("voice-call tunnels", () => {
});
});
it("bounds ngrok command failure output", async () => {
const authProc = nextProcess();
const result = startNgrokTunnel({
port: 3334,
path: "/hook",
authToken: "token",
});
authProc.stderr.emit("data", Buffer.from(`start-${"x".repeat(20_000)}-end`));
authProc.close(1);
await expect(result).rejects.toThrow("[output truncated]");
await expect(result).rejects.toThrow("-end");
await expect(result).rejects.not.toThrow("start-");
});
it("rejects ngrok startup errors from stderr", async () => {
const proc = nextProcess();
const result = startNgrokTunnel({ port: 3334, path: "/hook" });

View File

@@ -1,6 +1,13 @@
import { spawn } from "node:child_process";
import {
appendBoundedChildOutput,
emptyBoundedChildOutput,
formatBoundedChildOutput,
} from "./bounded-child-output.js";
import { getTailscaleDnsName } from "./webhook/tailscale.js";
const NGROK_LOG_BUFFER_MAX_CHARS = 16_384;
/**
* Tunnel configuration for exposing the webhook server.
*/
@@ -117,9 +124,11 @@ export async function startNgrokTunnel(config: {
};
proc.stdout.on("data", (data: Buffer) => {
outputBuffer += data.toString();
const lines = outputBuffer.split("\n");
const lines = (outputBuffer + data.toString()).split("\n");
outputBuffer = lines.pop() || "";
if (outputBuffer.length > NGROK_LOG_BUFFER_MAX_CHARS) {
outputBuffer = outputBuffer.slice(-NGROK_LOG_BUFFER_MAX_CHARS);
}
for (const line of lines) {
if (line.trim()) {
@@ -135,7 +144,8 @@ export async function startNgrokTunnel(config: {
if (!resolved) {
resolved = true;
clearTimeout(timeout);
reject(new Error(`ngrok error: ${msg}`));
const output = appendBoundedChildOutput(emptyBoundedChildOutput(), msg);
reject(new Error(`ngrok error: ${formatBoundedChildOutput(output)}`));
}
}
});
@@ -167,21 +177,22 @@ async function runNgrokCommand(args: string[]): Promise<string> {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdout = emptyBoundedChildOutput();
let stderr = emptyBoundedChildOutput();
proc.stdout.on("data", (data) => {
stdout += data.toString();
stdout = appendBoundedChildOutput(stdout, data.toString());
});
proc.stderr.on("data", (data) => {
stderr += data.toString();
stderr = appendBoundedChildOutput(stderr, data.toString());
});
proc.on("close", (code) => {
if (code === 0) {
resolve(stdout);
resolve(stdout.text);
} else {
reject(new Error(`ngrok command failed: ${stderr || stdout}`));
const output = stderr.text ? stderr : stdout;
reject(new Error(`ngrok command failed: ${formatBoundedChildOutput(output)}`));
}
});