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,
});
});
});