From afcc93bfc73eaa2f5794466f2a941a4d3c14fa0f Mon Sep 17 00:00:00 2001 From: "Ben.Li" Date: Tue, 7 Jul 2026 02:47:22 +0800 Subject: [PATCH] fix(cli): keep UTF-8 log tails valid (#101029) * fix(cli): keep UTF-8 log tails valid * fix(cli): share UTF-8-safe byte tails --------- Co-authored-by: Peter Steinberger --- src/cli/logs-cli.runtime.test.ts | 55 ++++++++++++++++++---- src/cli/logs-cli.runtime.ts | 81 +++++++++++++++++--------------- 2 files changed, 90 insertions(+), 46 deletions(-) diff --git a/src/cli/logs-cli.runtime.test.ts b/src/cli/logs-cli.runtime.test.ts index 9cb7228eeeb2..e45004b26305 100644 --- a/src/cli/logs-cli.runtime.test.ts +++ b/src/cli/logs-cli.runtime.test.ts @@ -13,6 +13,15 @@ vi.mock("node:child_process", async () => { }; }); +function mockSpawnedChild() { + const stdout = new EventEmitter(); + const stderr = new EventEmitter(); + const kill = vi.fn(() => true); + const child = Object.assign(new EventEmitter(), { kill, stderr, stdout }); + spawnMock.mockReturnValue(child as unknown as ChildProcess); + return { child, kill, stderr, stdout }; +} + describe("execFileUtf8Tail", () => { beforeEach(() => { spawnMock.mockReset(); @@ -21,11 +30,7 @@ describe("execFileUtf8Tail", () => { it.each(["stdout", "stderr"] as const)( "terminates the child when %s emits an error", async (streamName) => { - const stdout = new EventEmitter(); - const stderr = new EventEmitter(); - const kill = vi.fn(() => true); - const child = Object.assign(new EventEmitter(), { kill, stderr, stdout }); - spawnMock.mockReturnValue(child as unknown as ChildProcess); + const { kill, stderr, stdout } = mockSpawnedChild(); const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 }); stdout.emit("data", Buffer.from("partial output")); @@ -43,10 +48,7 @@ describe("execFileUtf8Tail", () => { ); it("does not kill the child when spawning fails", async () => { - const child = new EventEmitter(); - const kill = vi.fn(() => true); - Object.assign(child, { kill, stderr: new EventEmitter(), stdout: new EventEmitter() }); - spawnMock.mockReturnValue(child as unknown as ChildProcess); + const { child, kill } = mockSpawnedChild(); const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 }); child.emit("error", new Error("spawn failed")); @@ -54,4 +56,39 @@ describe("execFileUtf8Tail", () => { await expect(resultPromise).resolves.toMatchObject({ code: 1, stderr: "spawn failed" }); expect(kill).not.toHaveBeenCalled(); }); + + it.each([ + { label: "two-byte", text: "¢z", maxBytes: 2, expected: "z" }, + { label: "three-byte", text: "€z", maxBytes: 3, expected: "z" }, + { label: "four-byte", text: "😀z", maxBytes: 4, expected: "z" }, + { label: "complete", text: "a¢z", maxBytes: 3, expected: "¢z" }, + ])("decodes a $label character at the stdout tail boundary", async (testCase) => { + const { child, stdout } = mockSpawnedChild(); + const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { + maxBytes: testCase.maxBytes, + }); + + stdout.emit("data", Buffer.from(testCase.text, "utf8")); + child.emit("close", 0); + + await expect(resultPromise).resolves.toEqual({ + code: 0, + stderr: "", + stdout: testCase.expected, + truncated: true, + }); + }); + + it("decodes a truncated stderr tail at a UTF-8 boundary", async () => { + const { child, stderr } = mockSpawnedChild(); + const resultPromise = execFileUtf8Tail("journalctl", ["--no-pager"], { maxBytes: 1024 }); + + stderr.emit("data", Buffer.concat([Buffer.from("😀"), Buffer.alloc(64 * 1024 - 3, "x")])); + child.emit("close", 1); + + const result = await resultPromise; + expect(result.stderr).toBe("x".repeat(64 * 1024 - 3)); + expect(result.stderr).not.toContain("�"); + expect(result.truncated).toBe(false); + }); }); diff --git a/src/cli/logs-cli.runtime.ts b/src/cli/logs-cli.runtime.ts index 7d335acca9ce..722adbb5f19c 100644 --- a/src/cli/logs-cli.runtime.ts +++ b/src/cli/logs-cli.runtime.ts @@ -7,6 +7,41 @@ export { readSystemdServiceRuntime } from "../daemon/systemd.js"; type ExecFileTailResult = { stdout: string; stderr: string; code: number; truncated: boolean }; +type ByteTail = { chunks: Buffer[]; bytes: number; truncated: boolean }; + +const STDERR_MAX_BYTES = 64 * 1024; + +function appendByteTail(tail: ByteTail, chunk: Buffer, maxBytes: number): void { + tail.chunks.push(chunk); + tail.bytes += chunk.length; + while (tail.bytes > maxBytes && tail.chunks.length > 0) { + const first = tail.chunks[0]; + const overflow = tail.bytes - maxBytes; + if (first.length <= overflow) { + tail.chunks.shift(); + tail.bytes -= first.length; + } else { + tail.chunks[0] = first.subarray(overflow); + tail.bytes -= overflow; + } + tail.truncated = true; + } +} + +function decodeUtf8Tail(tail: ByteTail): string { + const buffer = Buffer.concat(tail.chunks, tail.bytes); + if (!tail.truncated || buffer.length === 0) { + return buffer.toString("utf8"); + } + // A byte cap can cut the leading code point. Skip only its continuation + // bytes so decoding cannot invent a replacement character at the boundary. + let offset = 0; + while (offset < buffer.length && (buffer[offset] & 0xc0) === 0x80) { + offset += 1; + } + return buffer.subarray(offset).toString("utf8"); +} + export async function execFileUtf8Tail( command: string, args: string[], @@ -18,43 +53,15 @@ export async function execFileUtf8Tail( env: options.env, stdio: ["ignore", "pipe", "pipe"], }); - const stdoutChunks: Buffer[] = []; - const stderrChunks: Buffer[] = []; - let stdoutBytes = 0; - let stderrBytes = 0; - let truncated = false; + const stdoutTail: ByteTail = { chunks: [], bytes: 0, truncated: false }; + const stderrTail: ByteTail = { chunks: [], bytes: 0, truncated: false }; let settled = false; child.stdout?.on("data", (chunk: Buffer) => { - stdoutChunks.push(chunk); - stdoutBytes += chunk.length; - while (stdoutBytes > options.maxBytes && stdoutChunks.length > 0) { - const first = stdoutChunks[0]; - const overflow = stdoutBytes - options.maxBytes; - if (first.length <= overflow) { - stdoutChunks.shift(); - stdoutBytes -= first.length; - } else { - stdoutChunks[0] = first.subarray(overflow); - stdoutBytes -= overflow; - } - truncated = true; - } + appendByteTail(stdoutTail, chunk, options.maxBytes); }); child.stderr?.on("data", (chunk: Buffer) => { - stderrChunks.push(chunk); - stderrBytes += chunk.length; - while (stderrBytes > 64 * 1024 && stderrChunks.length > 0) { - const first = stderrChunks[0]; - const overflow = stderrBytes - 64 * 1024; - if (first.length <= overflow) { - stderrChunks.shift(); - stderrBytes -= first.length; - } else { - stderrChunks[0] = first.subarray(overflow); - stderrBytes -= overflow; - } - } + appendByteTail(stderrTail, chunk, STDERR_MAX_BYTES); }); const resolveWithError = (error: unknown, terminateChild = false) => { @@ -68,10 +75,10 @@ export async function execFileUtf8Tail( child.kill(); } resolve({ - stdout: Buffer.concat(stdoutChunks).toString("utf8"), + stdout: decodeUtf8Tail(stdoutTail), stderr: error instanceof Error ? error.message : String(error), code: 1, - truncated, + truncated: stdoutTail.truncated, }); }; @@ -84,10 +91,10 @@ export async function execFileUtf8Tail( } settled = true; resolve({ - stdout: Buffer.concat(stdoutChunks).toString("utf8"), - stderr: Buffer.concat(stderrChunks).toString("utf8"), + stdout: decodeUtf8Tail(stdoutTail), + stderr: decodeUtf8Tail(stderrTail), code: typeof code === "number" ? code : 1, - truncated, + truncated: stdoutTail.truncated, }); }); });