From c62a2dcbf11e05d50f820470262b89889ec24dfa Mon Sep 17 00:00:00 2001 From: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Date: Fri, 5 Jun 2026 21:35:05 -0700 Subject: [PATCH] test(imessage): cover stale-child stdout detach after stop (#89830) Graft the detach-guard regression from #90159: a not-yet-exited imsg child that emits a complete notification after stop() must be dropped by the `this.child !== child` guard before reaching handleStdoutChunk / onNotification. Credits @MoerAI. --- extensions/imessage/src/status.test.ts | 41 ++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/extensions/imessage/src/status.test.ts b/extensions/imessage/src/status.test.ts index ebbfa35ceb05..330e9977c1c6 100644 --- a/extensions/imessage/src/status.test.ts +++ b/extensions/imessage/src/status.test.ts @@ -1,4 +1,6 @@ // Imessage tests cover status plugin behavior. +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime"; import * as processRuntime from "openclaw/plugin-sdk/process-runtime"; import * as setupRuntime from "openclaw/plugin-sdk/setup"; @@ -20,6 +22,26 @@ const getIMessageSetupStatus = createPluginSetupWizardStatus({ const spawnMock = vi.hoisted(() => vi.fn()); +function createMockChildProcess() { + const child = new EventEmitter() as EventEmitter & { + stdin: PassThrough; + stdout: PassThrough; + stderr: PassThrough; + killed: boolean; + kill: (signal?: string) => boolean; + }; + child.stdin = new PassThrough(); + child.stdout = new PassThrough(); + child.stderr = new PassThrough(); + child.killed = false; + child.kill = (signal?: string) => { + child.killed = true; + child.emit("close", 0, signal ?? null); + return true; + }; + return child; +} + vi.mock("node:child_process", async () => { const actual = await vi.importActual("node:child_process"); return { @@ -138,6 +160,25 @@ describe("createIMessageRpcClient", () => { await expect(first).resolves.toEqual({ ok: "first" }); await expect(second).resolves.toEqual({ ok: "second" }); }); + + it("ignores stdout from a stale child after stop so late notifications cannot leak (#89830)", async () => { + vi.stubEnv("VITEST", ""); + vi.stubEnv("NODE_ENV", ""); + const child = createMockChildProcess(); + spawnMock.mockReturnValue(child); + const onNotification = vi.fn(); + const { IMessageRpcClient } = await import("./client.js"); + const client = new IMessageRpcClient({ onNotification }); + + await client.start(); + await client.stop(); + + // A not-yet-exited imsg child emits a complete notification after stop(). + // The `this.child !== child` guard must drop it before handleStdoutChunk. + child.stdout.write('{"jsonrpc":"2.0","method":"messages.changed","params":{}}\n'); + + expect(onNotification).not.toHaveBeenCalled(); + }); }); describe("imessage setup status", () => {