11import { expect , test } from "bun:test" ;
22import { createHash } from "node:crypto" ;
3- import { readFileSync } from "node:fs" ;
3+ import { mkdirSync , mkdtempSync , readFileSync , realpathSync , rmSync , symlinkSync , writeFileSync } from "node:fs" ;
4+ import { tmpdir } from "node:os" ;
5+ import { isAbsolute , join , relative } from "node:path" ;
46import { repoPath } from "../helpers/repo-root" ;
7+ import { collectOverflowChild , finishOverflowScratch , overflowScratchBase , type OverflowChild } from "../helpers/messages-overflow-child" ;
8+ import { TEST_TEMP_OWNER_FILE } from "../../scripts/test-temp" ;
9+ import { trackOverflowAsyncSpawns } from "../helpers/messages-overflow-spawn-tracker" ;
10+ import { setWindowsOwnerAclAsyncRunnerForTests , verifyWindowsEntriesAclAsync } from "../../src/lib/windows-owner-acl" ;
511
612const digest = ( text : string | Uint8Array ) => createHash ( "sha256" ) . update ( text ) . digest ( "hex" ) ;
713test ( "#8128 shared overflow predicate changes only in the documented 5xx and conditional cases" , ( ) => {
@@ -22,3 +28,161 @@ test("#8128 behavior suite retains origin/dev source", () => {
2228 expect ( source . length ) . toBeGreaterThan ( 0 ) ;
2329 expect ( digest ( source ) ) . toBe ( "d5d0573f377c5d7894bff343a0bd9cebec46df5bc405b112238862479a3e7328" ) ;
2430} ) ;
31+
32+ test ( "retained overflow scratch is outside nested test sandboxes and junction aliases" , ( ) => {
33+ const root = mkdtempSync ( join ( tmpdir ( ) , "ocx-overflow-location-" ) ) ;
34+ try {
35+ const nested = join ( root , "nested" ) ;
36+ const inner = join ( nested , "tmp" ) ;
37+ mkdirSync ( inner , { recursive : true } ) ;
38+ writeFileSync ( join ( root , TEST_TEMP_OWNER_FILE ) , "{}" ) ;
39+ writeFileSync ( join ( nested , TEST_TEMP_OWNER_FILE ) , "{}" ) ;
40+ const alias = join ( root , "alias" ) ;
41+ symlinkSync ( inner , alias , process . platform === "win32" ? "junction" : "dir" ) ;
42+ const base = overflowScratchBase ( alias ) ;
43+ const rel = relative ( realpathSync . native ( root ) , base ) ;
44+ expect ( rel === ".." || rel . startsWith ( `..${ process . platform === "win32" ? "\\" : "/" } ` ) || isAbsolute ( rel ) ) . toBe ( true ) ;
45+ expect ( base ) . toBe ( overflowScratchBase ( inner ) ) ;
46+ } finally { rmSync ( root , { recursive : true , force : true } ) ; }
47+ } ) ;
48+
49+ test ( "overflow child drains both streams before returning its exit" , async ( ) => {
50+ const child = Bun . spawn ( [ process . execPath , "-e" , "process.stdout.write('out'); process.stderr.write('err');" ] , { stdout : "pipe" , stderr : "pipe" } ) ;
51+ const result = await collectOverflowChild ( child , performance . now ( ) + 25_000 ) ;
52+ expect ( result ) . toEqual ( { exit : 0 , stdout : "out" , stderr : "err" } ) ;
53+ expect ( child . exitCode ) . toBe ( 0 ) ;
54+ } , 30_000 ) ;
55+
56+ test ( "overflow deadline kills and joins the owned real child" , async ( ) => {
57+ const child = Bun . spawn ( [ process . execPath , "-e" , "process.stdout.write('out'); process.stderr.write('err'); setInterval(() => {}, 1000);" ] , { stdout : "pipe" , stderr : "pipe" } ) ;
58+ let joined = false ;
59+ try {
60+ await expect ( collectOverflowChild ( child , performance . now ( ) + 500 ) ) . rejects . toThrow ( "internal deadline" ) ;
61+ const exit = await child . exited ;
62+ joined = true ;
63+ expect ( exit ) . not . toBe ( 0 ) ;
64+ } finally {
65+ if ( ! joined ) { try { child . kill ( ) ; } finally { await child . exited ; } }
66+ }
67+ } , 30_000 ) ;
68+
69+ test ( "a pipe failure kills and joins the still-live owned child" , async ( ) => {
70+ let code : number | null = null ;
71+ let resolveExit ! : ( value : number ) => void ;
72+ let kills = 0 ;
73+ const child : OverflowChild = {
74+ pid : 42 ,
75+ get exitCode ( ) { return code ; } ,
76+ exited : new Promise ( resolve => { resolveExit = resolve ; } ) ,
77+ stdout : new ReadableStream ( { start ( controller ) { controller . error ( new Error ( "fixture pipe failure" ) ) ; } } ) ,
78+ stderr : new ReadableStream ( { start ( controller ) { controller . close ( ) ; } } ) ,
79+ kill ( ) { kills ++ ; code = 143 ; resolveExit ( 143 ) ; } ,
80+ } ;
81+ await expect ( collectOverflowChild ( child , performance . now ( ) + 2_000 ) ) . rejects . toThrow ( "cleanup incomplete" ) ;
82+ expect ( kills ) . toBe ( 1 ) ;
83+ expect ( await child . exited ) . toBe ( 143 ) ;
84+ } ) ;
85+
86+ test ( "expired deadline is not accepted when a delayed timer loses the race" , async ( ) => {
87+ const closed = ( ) => new ReadableStream < Uint8Array > ( { start ( controller ) { controller . close ( ) ; } } ) ;
88+ const child : OverflowChild = { pid : 42 , exitCode : 0 , exited : Promise . resolve ( 0 ) , stdout : closed ( ) , stderr : closed ( ) , kill ( ) { throw new Error ( "already exited" ) ; } } ;
89+ await expect ( collectOverflowChild ( child , performance . now ( ) - 1 ) ) . rejects . toThrow ( "internal deadline" ) ;
90+ } ) ;
91+
92+ test ( "unconfirmed termination and a held pipe are reported without waiting forever" , async ( ) => {
93+ let kills = 0 ;
94+ const child : OverflowChild = {
95+ pid : 42 , exitCode : null , exited : new Promise ( ( ) => { } ) ,
96+ stdout : new ReadableStream ( { start ( controller ) { controller . close ( ) ; } } ) ,
97+ stderr : new ReadableStream ( { } ) , kill ( ) { kills ++ ; } ,
98+ } ;
99+ await expect ( collectOverflowChild ( child , performance . now ( ) + 10 , 30 ) ) . rejects . toThrow ( "cleanup incomplete" ) ;
100+ expect ( kills ) . toBe ( 1 ) ;
101+ } ) ;
102+
103+ test ( "overflow child preserves a pipe failure and its cleanup failures" , async ( ) => {
104+ const child : OverflowChild = {
105+ pid : 42 , exitCode : null , exited : new Promise ( ( ) => { } ) ,
106+ stdout : new ReadableStream ( { start ( controller ) { controller . error ( new Error ( "fixture pipe failure" ) ) ; } } ) ,
107+ stderr : new ReadableStream ( { } ) , kill ( ) { throw new Error ( "fixture kill failure" ) ; } ,
108+ } ;
109+ let caught : unknown ;
110+ try { await collectOverflowChild ( child , performance . now ( ) + 1_000 , 30 ) ; } catch ( error ) { caught = error ; }
111+ expect ( caught ) . toBeInstanceOf ( AggregateError ) ;
112+ const details = ( caught as AggregateError ) . errors . map ( String ) . join ( "\n" ) ;
113+ expect ( details ) . toContain ( "fixture pipe failure" ) ;
114+ expect ( details ) . toContain ( "fixture kill failure" ) ;
115+ expect ( details ) . toContain ( "exit or output drain was not confirmed" ) ;
116+ } ) ;
117+
118+ test ( "overflow cleanup retains unverified scratch and preserves a primary failure" , ( ) => {
119+ const primary = new Error ( "primary fixture failure" ) ;
120+ let removed = false ;
121+ expect ( ( ) => finishOverflowScratch ( "fixture-scratch" , false , { error : primary } , ( ) => { removed = true ; } ) ) . toThrow ( "primary fixture failure" ) ;
122+ expect ( removed ) . toBe ( false ) ;
123+ } ) ;
124+
125+ test ( "overflow cleanup reports both the primary and removal failures" , ( ) => {
126+ const primary = new Error ( "primary fixture failure" ) ;
127+ const cleanup = new Error ( "fixture removal failure" ) ;
128+ let caught : unknown ;
129+ try { finishOverflowScratch ( "fixture-scratch" , true , { error : primary } , ( ) => { throw cleanup ; } ) ; } catch ( error ) { caught = error ; }
130+ expect ( caught ) . toBeInstanceOf ( AggregateError ) ;
131+ expect ( ( caught as AggregateError ) . errors ) . toEqual ( [ primary , cleanup ] ) ;
132+ } ) ;
133+
134+ test ( "overflow subprocess tracker waits for a real child after its caller returns" , async ( ) => {
135+ const tracker = trackOverflowAsyncSpawns ( ) ;
136+ let child : ReturnType < typeof Bun . spawn < { stdin : "pipe" ; stdout : "pipe" ; stderr : "pipe" } > > | undefined ;
137+ let exited = false ;
138+ let watchdog : ReturnType < typeof setTimeout > | undefined ;
139+ try {
140+ child = Bun . spawn ( [ process . execPath , "-e" , "await Bun.stdin.text();" ] , { stdin : "pipe" , stdout : "pipe" , stderr : "pipe" } ) ;
141+ const owned = child ;
142+ watchdog = setTimeout ( ( ) => { if ( ! exited ) owned . kill ( ) ; } , 5_000 ) ;
143+ let drained = false ;
144+ const draining = tracker . drain ( ) . then ( ( ) => { drained = true ; } ) ;
145+ await Promise . resolve ( ) ;
146+ expect ( drained ) . toBe ( false ) ;
147+ owned . stdin . end ( ) ;
148+ await draining ;
149+ const exit = await owned . exited ;
150+ exited = true ;
151+ expect ( exit ) . toBe ( 0 ) ;
152+ expect ( drained ) . toBe ( true ) ;
153+ } finally {
154+ clearTimeout ( watchdog ) ;
155+ if ( child && ! exited ) { try { child . kill ( ) ; } catch { /* already exited */ } await child . exited ; }
156+ tracker . restore ( ) ;
157+ }
158+ } , 30_000 ) ;
159+
160+ test ( "overflow tracker joins an ACL verifier child after the verifier reports timeout" , async ( ) => {
161+ const tracker = trackOverflowAsyncSpawns ( ) ;
162+ let child : ReturnType < typeof Bun . spawn < { stdin : "pipe" ; stdout : "pipe" ; stderr : "pipe" } > > | undefined ;
163+ let joined = false ;
164+ let watchdog : ReturnType < typeof setTimeout > | undefined ;
165+ setWindowsOwnerAclAsyncRunnerForTests ( async ( ) => {
166+ child = Bun . spawn ( [ process . execPath , "-e" , "await Bun.stdin.text();" ] , { stdin : "pipe" , stdout : "pipe" , stderr : "pipe" } ) ;
167+ const owned = child ;
168+ watchdog = setTimeout ( ( ) => { if ( ! joined ) owned . kill ( ) ; } , 5_000 ) ;
169+ return { success : false , timedOut : true , stdout : new Uint8Array ( ) } ;
170+ } ) ;
171+ try {
172+ expect ( await verifyWindowsEntriesAclAsync ( [ { path : "fixture" , directory : true } ] , 1_000 ) ) . toBe ( false ) ;
173+ if ( ! child ) throw new Error ( "ACL fixture did not spawn its child" ) ;
174+ let drained = false ;
175+ const draining = tracker . drain ( ) . then ( ( ) => { drained = true ; } ) ;
176+ await Promise . resolve ( ) ;
177+ expect ( drained ) . toBe ( false ) ;
178+ child . stdin . end ( ) ;
179+ await draining ;
180+ expect ( await child . exited ) . toBe ( 0 ) ;
181+ joined = true ;
182+ } finally {
183+ clearTimeout ( watchdog ) ;
184+ setWindowsOwnerAclAsyncRunnerForTests ( null ) ;
185+ if ( child && ! joined ) { try { child . kill ( ) ; } catch { /* already exited */ } await child . exited ; }
186+ tracker . restore ( ) ;
187+ }
188+ } , 30_000 ) ;
0 commit comments