From bf642ef2878abc559600aab099ce91178934b8a6 Mon Sep 17 00:00:00 2001 From: qingminlong Date: Mon, 13 Jul 2026 04:02:40 +0800 Subject: [PATCH] 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 --- extensions/signal/src/daemon.test.ts | 32 ++++++++++++++++++++++++++++ extensions/signal/src/daemon.ts | 22 ++++++++++++------- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/extensions/signal/src/daemon.test.ts b/extensions/signal/src/daemon.test.ts index c5aca9d66396..f1a82af789ce 100644 --- a/extensions/signal/src/daemon.test.ts +++ b/extensions/signal/src/daemon.test.ts @@ -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", + ]); + }); }); diff --git a/extensions/signal/src/daemon.ts b/extensions/signal/src/daemon.ts index d14d24fd9919..50fffcee48a8 100644 --- a/extensions/signal/src/daemon.ts +++ b/extensions/signal/src/daemon.ts @@ -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,