mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-11 01:46:03 +00:00
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 <steipete@gmail.com>
This commit is contained in:
@@ -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("<22>");
|
||||
expect(result.truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user