11// Matrix tests cover deps plugin behavior.
2+ import type { ChildProcessWithoutNullStreams , SpawnOptions } from "node:child_process" ;
23import fs from "node:fs" ;
34import os from "node:os" ;
45import path from "node:path" ;
5- import { describe , expect , it , vi } from "vitest" ;
6+ import { afterEach , describe , expect , it , type MockInstance , vi } from "vitest" ;
67import {
78 ensureMatrixCryptoRuntime ,
89 ensureMatrixSdkInstalled ,
@@ -12,6 +13,72 @@ import {
1213
1314const logStub = vi . fn ( ) ;
1415
16+ type ChildKill = ( signal ?: NodeJS . Signals | number ) => boolean ;
17+
18+ async function importDepsWithSpawnMock (
19+ spawnMock : ReturnType < typeof vi . fn > ,
20+ ) : Promise < typeof import ( "./deps.js" ) > {
21+ vi . resetModules ( ) ;
22+ vi . doMock ( "node:child_process" , async ( ) => {
23+ const actual = await vi . importActual < typeof import ( "node:child_process" ) > ( "node:child_process" ) ;
24+ return {
25+ ...actual ,
26+ spawn : spawnMock ,
27+ } ;
28+ } ) ;
29+ return await import ( "./deps.js" ) ;
30+ }
31+
32+ function waitForChildClose ( proc : ChildProcessWithoutNullStreams ) {
33+ return new Promise < { code : number | null ; signal : NodeJS . Signals | null } > ( ( resolve , reject ) => {
34+ const timer = setTimeout ( ( ) => {
35+ reject ( new Error ( "timed out waiting for matrix command child close" ) ) ;
36+ } , 5_000 ) ;
37+ timer . unref ?.( ) ;
38+ proc . once ( "close" , ( code , signal ) => {
39+ clearTimeout ( timer ) ;
40+ resolve ( { code, signal } ) ;
41+ } ) ;
42+ } ) ;
43+ }
44+
45+ function waitForReadableData ( stream : NodeJS . ReadableStream ) {
46+ return new Promise < void > ( ( resolve , reject ) => {
47+ let cleanup = ( ) => { } ;
48+ const timer = setTimeout ( ( ) => {
49+ cleanup ( ) ;
50+ reject ( new Error ( "timed out waiting for matrix command child output" ) ) ;
51+ } , 5_000 ) ;
52+ timer . unref ?.( ) ;
53+ cleanup = ( ) => {
54+ clearTimeout ( timer ) ;
55+ stream . off ( "data" , onData ) ;
56+ stream . off ( "error" , onError ) ;
57+ } ;
58+ const onData = ( ) => {
59+ cleanup ( ) ;
60+ resolve ( ) ;
61+ } ;
62+ const onError = ( error : Error ) => {
63+ cleanup ( ) ;
64+ reject ( error ) ;
65+ } ;
66+ stream . once ( "data" , onData ) ;
67+ stream . once ( "error" , onError ) ;
68+ } ) ;
69+ }
70+
71+ function waitForReadableErrorDispatch ( ) {
72+ return new Promise < void > ( ( resolve ) => {
73+ setImmediate ( resolve ) ;
74+ } ) ;
75+ }
76+
77+ afterEach ( ( ) => {
78+ vi . useRealTimers ( ) ;
79+ vi . doUnmock ( "node:child_process" ) ;
80+ } ) ;
81+
1582function resolveTestNativeBindingFilename ( ) : string | null {
1683 switch ( process . platform ) {
1784 case "darwin" :
@@ -185,6 +252,80 @@ describe("runFixedCommandWithTimeout", () => {
185252 expect ( result . stdout ) . toBe ( "a" . repeat ( MATRIX_COMMAND_OUTPUT_TAIL_BYTES ) ) ;
186253 expect ( result . stderr ) . toBe ( "b" . repeat ( MATRIX_COMMAND_OUTPUT_TAIL_BYTES ) ) ;
187254 } ) ;
255+
256+ it ( "settles real child stream errors after child close and terminates once" , async ( ) => {
257+ const actual = await vi . importActual < typeof import ( "node:child_process" ) > ( "node:child_process" ) ;
258+
259+ for ( const streamName of [ "stdout" , "stderr" ] as const ) {
260+ let proc : ChildProcessWithoutNullStreams | undefined ;
261+ let killSpy : MockInstance < ChildKill > | undefined ;
262+ try {
263+ const spawnMock = vi . fn (
264+ ( command : string , args : string [ ] | undefined , options : SpawnOptions ) => {
265+ proc = actual . spawn ( command , args ?? [ ] , options ) as ChildProcessWithoutNullStreams ;
266+ return proc ;
267+ } ,
268+ ) ;
269+ const { runFixedCommandWithTimeout : runWithMockedSpawn } =
270+ await importDepsWithSpawnMock ( spawnMock ) ;
271+ const exitListenersBefore = process . listenerCount ( "exit" ) ;
272+
273+ const resultPromise = runWithMockedSpawn ( {
274+ argv : [
275+ process . execPath ,
276+ "-e" ,
277+ [
278+ "process.stdin.resume();" ,
279+ 'process.on("SIGTERM", () => {});' ,
280+ 'process.stdout.write("stdout ready\\n");' ,
281+ 'process.stderr.write("stderr ready\\n");' ,
282+ "setInterval(() => {}, 1000);" ,
283+ ] . join ( "" ) ,
284+ ] ,
285+ cwd : process . cwd ( ) ,
286+ timeoutMs : 10_000 ,
287+ } ) ;
288+ if ( ! proc ) {
289+ throw new Error ( "expected matrix command helper to spawn a child process" ) ;
290+ }
291+ killSpy = vi . spyOn ( proc , "kill" ) ;
292+ const closePromise = waitForChildClose ( proc ) ;
293+ await Promise . all ( [ waitForReadableData ( proc . stdout ) , waitForReadableData ( proc . stderr ) ] ) ;
294+ let settled = false ;
295+ void resultPromise . then ( ( ) => {
296+ settled = true ;
297+ } ) ;
298+ const message = `synthetic parent ${ streamName } read failure` ;
299+
300+ proc [ streamName ] . destroy ( new Error ( message ) ) ;
301+ await waitForReadableErrorDispatch ( ) ;
302+ expect ( settled ) . toBe ( false ) ;
303+ expect ( process . listenerCount ( "exit" ) ) . toBe ( exitListenersBefore + 1 ) ;
304+ expect ( killSpy ) . toHaveBeenCalledTimes ( 1 ) ;
305+ expect ( killSpy ) . toHaveBeenCalledWith ( "SIGTERM" ) ;
306+
307+ const duplicateStreamName = streamName === "stdout" ? "stderr" : "stdout" ;
308+ proc [ duplicateStreamName ] . destroy ( new Error ( "duplicate parent readable failure" ) ) ;
309+ await waitForReadableErrorDispatch ( ) ;
310+ expect ( killSpy ) . toHaveBeenCalledTimes ( 1 ) ;
311+
312+ const result = await resultPromise ;
313+ const close = await closePromise ;
314+
315+ expect ( result . code ) . toBe ( 1 ) ;
316+ expect ( result . stderr ) . toContain ( `${ streamName } stream failed: ${ message } ` ) ;
317+ expect ( result . stderr ) . not . toContain ( "duplicate parent readable failure" ) ;
318+ expect ( close ) . toStrictEqual ( { code : null , signal : "SIGKILL" } ) ;
319+ expect ( killSpy ) . toHaveBeenLastCalledWith ( "SIGKILL" ) ;
320+ expect ( process . listenerCount ( "exit" ) ) . toBe ( exitListenersBefore ) ;
321+ } finally {
322+ killSpy ?. mockRestore ( ) ;
323+ if ( proc && proc . exitCode === null && ! proc . killed ) {
324+ proc . kill ( "SIGKILL" ) ;
325+ }
326+ }
327+ }
328+ } ) ;
188329} ) ;
189330
190331describe ( "ensureMatrixSdkInstalled" , ( ) => {
0 commit comments