diff --git a/extensions/voice-call/src/bounded-child-output.test.ts b/extensions/voice-call/src/bounded-child-output.test.ts new file mode 100644 index 00000000000..234502e67ac --- /dev/null +++ b/extensions/voice-call/src/bounded-child-output.test.ts @@ -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"); + }); +}); diff --git a/extensions/voice-call/src/bounded-child-output.ts b/extensions/voice-call/src/bounded-child-output.ts new file mode 100644 index 00000000000..793b45c5d09 --- /dev/null +++ b/extensions/voice-call/src/bounded-child-output.ts @@ -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; +} diff --git a/extensions/voice-call/src/tunnel.test.ts b/extensions/voice-call/src/tunnel.test.ts index 6893b124252..fc57c2fe2dd 100644 --- a/extensions/voice-call/src/tunnel.test.ts +++ b/extensions/voice-call/src/tunnel.test.ts @@ -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((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" }); diff --git a/extensions/voice-call/src/tunnel.ts b/extensions/voice-call/src/tunnel.ts index e1c6cd86b57..d2e15849baf 100644 --- a/extensions/voice-call/src/tunnel.ts +++ b/extensions/voice-call/src/tunnel.ts @@ -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 { 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)}`)); } });