fix(signal): surface daemon errors split across output chunks (#104152)

* fix(signal): preserve daemon log lines across chunks

* test(signal): cover split utf-8 daemon output

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
qingminlong
2026-07-13 04:02:40 +08:00
committed by GitHub
parent bf81e589ac
commit bf642ef287
2 changed files with 46 additions and 8 deletions

View File

@@ -1,6 +1,8 @@
// Signal tests cover daemon plugin behavior.
import { once } from "node:events";
import os from "node:os";
import path from "node:path";
import { PassThrough } from "node:stream";
import { describe, expect, it } from "vitest";
import { testApi } from "./daemon.js";
@@ -45,4 +47,34 @@ describe("signal daemon log classification", () => {
expect(testApi.classifySignalCliLogLine("ERROR DaemonCommand - startup failed")).toBe("error");
expect(testApi.classifySignalCliLogLine("SEVERE Manager - database exception")).toBe("error");
});
it("preserves log lines and UTF-8 across output chunk boundaries", async () => {
const stream = new PassThrough();
const logs: string[] = [];
const errors: string[] = [];
testApi.bindSignalCliOutput({
stream,
log: (message) => logs.push(message),
error: (message) => errors.push(message),
});
const ended = once(stream, "end");
stream.write(Buffer.from("ER"));
stream.write(
Buffer.from("ROR DaemonCommand - startup failed\r\nWARN Manager - retrying\npartial"),
);
stream.write(" warning\n");
const utf8Line = Buffer.from("INFO Manager - café ready");
const splitCodePointAt = utf8Line.indexOf(0xc3) + 1;
stream.write(utf8Line.subarray(0, splitCodePointAt));
stream.end(utf8Line.subarray(splitCodePointAt));
await ended;
expect(errors).toEqual(["signal-cli: ERROR DaemonCommand - startup failed"]);
expect(logs).toEqual([
"signal-cli: WARN Manager - retrying",
"signal-cli: partial warning",
"signal-cli: INFO Manager - café ready",
]);
});
});

View File

@@ -2,6 +2,7 @@
import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
type SignalDaemonOpts = {
@@ -63,14 +64,18 @@ function bindSignalCliOutput(params: {
log: (message: string) => void;
error: (message: string) => void;
}): void {
params.stream?.on("data", (data) => {
for (const line of data.toString().split(/\r?\n/)) {
const kind = classifySignalCliLogLine(line);
if (kind === "log") {
params.log(`signal-cli: ${line.trim()}`);
} else if (kind === "error") {
params.error(`signal-cli: ${line.trim()}`);
}
if (!params.stream) {
return;
}
// Process pipe chunks can split both log lines and UTF-8 code points. Readline owns that
// framing so classification sees complete text, including the final unterminated line.
const lines = createInterface({ input: params.stream });
lines.on("line", (line) => {
const kind = classifySignalCliLogLine(line);
if (kind === "log") {
params.log(`signal-cli: ${line.trim()}`);
} else if (kind === "error") {
params.error(`signal-cli: ${line.trim()}`);
}
});
}
@@ -175,6 +180,7 @@ export function spawnSignalDaemon(opts: SignalDaemonOpts): SignalDaemonHandle {
}
export const testApi = {
bindSignalCliOutput,
buildDaemonArgs,
classifySignalCliLogLine,
resolveSignalCliConfigPath,