@@ -2212,4 +2212,266 @@ describe('SessionManager', () => {
22122212 expect ( ( sm as any ) . _exitListeners ) . toHaveLength ( 2 )
22132213 } )
22142214 } )
2215+
2216+ describe ( 'broadcast() back-pressure' , ( ) => {
2217+ it ( 'drops message when client buffer exceeds 1MB' , ( ) => {
2218+ const s = sm . create ( 'bp-test' , '/tmp' )
2219+ const overloaded = fakeWs ( )
2220+ overloaded . bufferedAmount = 2_000_000
2221+ const normal = fakeWs ( )
2222+ sm . join ( s . id , overloaded )
2223+ sm . join ( s . id , normal )
2224+ sm . broadcast ( sm . get ( s . id ) ! , { type : 'result' } as any )
2225+ expect ( overloaded . send ) . not . toHaveBeenCalled ( )
2226+ expect ( normal . send ) . toHaveBeenCalled ( )
2227+ } )
2228+
2229+ it ( 'skips clients that are not OPEN' , ( ) => {
2230+ const s = sm . create ( 'closed-test' , '/tmp' )
2231+ const closed = fakeWs ( false )
2232+ sm . join ( s . id , closed )
2233+ sm . broadcast ( sm . get ( s . id ) ! , { type : 'result' } as any )
2234+ expect ( closed . send ) . not . toHaveBeenCalled ( )
2235+ } )
2236+ } )
2237+
2238+ describe ( 'restoreRepoApprovalsFromDisk() error handling' , ( ) => {
2239+ it ( 'handles corrupted JSON gracefully' , ( ) => {
2240+ const mockedExistsSync = vi . mocked ( existsSync )
2241+ const mockedReadFileSync = vi . mocked ( readFileSync )
2242+ const consoleSpy = vi . spyOn ( console , 'error' ) . mockImplementation ( ( ) => { } )
2243+
2244+ mockedExistsSync . mockImplementation ( ( p ) => String ( p ) . includes ( 'repo-approvals.json' ) ? true : false )
2245+ mockedReadFileSync . mockImplementation ( ( p ) => {
2246+ if ( String ( p ) . includes ( 'repo-approvals.json' ) ) return '{invalid json'
2247+ return '[]'
2248+ } )
2249+
2250+ // Should not throw - constructor parses the corrupted JSON
2251+ expect ( ( ) => new SessionManager ( ) ) . not . toThrow ( )
2252+ expect ( consoleSpy ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Failed to restore repo approvals' ) , expect . any ( Error ) )
2253+
2254+ consoleSpy . mockRestore ( )
2255+ mockedExistsSync . mockImplementation ( ( p ) => String ( p ) . includes ( 'sessions.json' ) ? false : true )
2256+ mockedReadFileSync . mockReturnValue ( '[]' )
2257+ } )
2258+ } )
2259+
2260+ describe ( 'restoreActiveSessions() continuation message' , ( ) => {
2261+ it ( 'sends continuation message after system_init event' , ( ) => {
2262+ vi . useFakeTimers ( )
2263+ const mockedExistsSync = vi . mocked ( existsSync )
2264+ const mockedReadFileSync = vi . mocked ( readFileSync )
2265+
2266+ const sessionData = [ {
2267+ id : 'continue-test' ,
2268+ name : 'Continue Test' ,
2269+ workingDir : '/tmp' ,
2270+ created : '2025-01-01T00:00:00.000Z' ,
2271+ claudeSessionId : 'claude-continue' ,
2272+ wasActive : true ,
2273+ outputHistory : [ ] ,
2274+ } ]
2275+
2276+ mockedExistsSync . mockImplementation ( ( p ) => String ( p ) . includes ( 'sessions.json' ) ? true : false )
2277+ mockedReadFileSync . mockReturnValue ( JSON . stringify ( sessionData ) )
2278+
2279+ const sm2 = new SessionManager ( )
2280+
2281+ // Create a fake claude process that captures the `once` callback
2282+ const onceCallbacks : Record < string , ( ...args : unknown [ ] ) => void > = { }
2283+ const fakeCp = {
2284+ ...fakeClaudeProcess ( ) ,
2285+ once : vi . fn ( ( event : string , cb : ( ...args : unknown [ ] ) => void ) => { onceCallbacks [ event ] = cb } ) ,
2286+ }
2287+
2288+ vi . spyOn ( sm2 , 'startClaude' ) . mockImplementation ( ( id ) => {
2289+ const session = sm2 . get ( id )
2290+ if ( session ) ( session as any ) . claudeProcess = fakeCp
2291+ return true
2292+ } )
2293+
2294+ sm2 . restoreActiveSessions ( )
2295+ vi . advanceTimersByTime ( 0 )
2296+
2297+ // Verify once was registered for system_init
2298+ expect ( fakeCp . once ) . toHaveBeenCalledWith ( 'system_init' , expect . any ( Function ) )
2299+
2300+ // Fire the system_init callback
2301+ onceCallbacks [ 'system_init' ] ( )
2302+ expect ( fakeCp . sendMessage ) . toHaveBeenCalledWith ( expect . stringContaining ( 'Session restored' ) )
2303+
2304+ mockedExistsSync . mockImplementation ( ( p ) => String ( p ) . includes ( 'sessions.json' ) ? false : true )
2305+ mockedReadFileSync . mockReturnValue ( '[]' )
2306+ vi . useRealTimers ( )
2307+ } )
2308+
2309+ it ( 'skips already-running sessions during restore' , ( ) => {
2310+ vi . useFakeTimers ( )
2311+ const mockedExistsSync = vi . mocked ( existsSync )
2312+ const mockedReadFileSync = vi . mocked ( readFileSync )
2313+
2314+ const sessionData = [ {
2315+ id : 'already-running' ,
2316+ name : 'Already Running' ,
2317+ workingDir : '/tmp' ,
2318+ created : '2025-01-01T00:00:00.000Z' ,
2319+ claudeSessionId : 'claude-running' ,
2320+ wasActive : true ,
2321+ outputHistory : [ ] ,
2322+ } ]
2323+
2324+ mockedExistsSync . mockImplementation ( ( p ) => String ( p ) . includes ( 'sessions.json' ) ? true : false )
2325+ mockedReadFileSync . mockReturnValue ( JSON . stringify ( sessionData ) )
2326+
2327+ const sm2 = new SessionManager ( )
2328+
2329+ // Pre-assign a running ClaudeProcess
2330+ const session = sm2 . get ( 'already-running' ) !
2331+ ; ( session as any ) . claudeProcess = fakeClaudeProcess ( true )
2332+
2333+ const startClaudeSpy = vi . spyOn ( sm2 , 'startClaude' ) . mockReturnValue ( true )
2334+
2335+ sm2 . restoreActiveSessions ( )
2336+ vi . advanceTimersByTime ( 0 )
2337+
2338+ // Should not start Claude since it's already alive
2339+ expect ( startClaudeSpy ) . not . toHaveBeenCalled ( )
2340+
2341+ startClaudeSpy . mockRestore ( )
2342+ mockedExistsSync . mockImplementation ( ( p ) => String ( p ) . includes ( 'sessions.json' ) ? false : true )
2343+ mockedReadFileSync . mockReturnValue ( '[]' )
2344+ vi . useRealTimers ( )
2345+ } )
2346+ } )
2347+
2348+ describe ( 'handleClaudeExit behavior' , ( ) => {
2349+ it ( 'stops auto-restart when _stoppedByUser is set' , ( ) => {
2350+ vi . useFakeTimers ( )
2351+ const s = sm . create ( 'stopped-user' , '/tmp' )
2352+ const session = sm . get ( s . id ) !
2353+
2354+ // Simulate that Claude was running and user stopped it
2355+ ; ( session as any ) . _stoppedByUser = true
2356+ ; ( session as any ) . claudeProcess = fakeClaudeProcess ( )
2357+
2358+ const ws = fakeWs ( )
2359+ sm . join ( s . id , ws )
2360+
2361+ // Trigger exit via the private method by calling it directly
2362+ ; ( sm as any ) . handleClaudeExit ( session , s . id , 1 , null )
2363+
2364+ // Should broadcast exit, not restart
2365+ const messages = ws . send . mock . calls . map ( ( c : any ) => JSON . parse ( c [ 0 ] ) )
2366+ const exitMsg = messages . find ( ( m : any ) => m . type === 'system_message' && m . subtype === 'exit' )
2367+ expect ( exitMsg ) . toBeDefined ( )
2368+ expect ( exitMsg . text ) . toContain ( 'code=1' )
2369+
2370+ // Should NOT broadcast restart message
2371+ const restartMsg = messages . find ( ( m : any ) => m . subtype === 'restart' )
2372+ expect ( restartMsg ) . toBeUndefined ( )
2373+ vi . useRealTimers ( )
2374+ } )
2375+
2376+ it ( 'clears stale claudeSessionId on code=1 first restart' , ( ) => {
2377+ vi . useFakeTimers ( )
2378+ const s = sm . create ( 'stale-id' , '/tmp' )
2379+ const session = sm . get ( s . id ) !
2380+ ; ( session as any ) . claudeSessionId = 'stale-session'
2381+ ; ( session as any ) . restartCount = 0
2382+ ; ( session as any ) . lastRestartAt = null
2383+
2384+ ; ( sm as any ) . handleClaudeExit ( session , s . id , 1 , null )
2385+
2386+ expect ( session . claudeSessionId ) . toBeNull ( )
2387+ vi . useRealTimers ( )
2388+ } )
2389+
2390+ it ( 'resets restart count after cooldown period' , ( ) => {
2391+ vi . useFakeTimers ( )
2392+ const s = sm . create ( 'cooldown-test' , '/tmp' )
2393+ const session = sm . get ( s . id ) !
2394+ ; ( session as any ) . restartCount = 2
2395+ ; ( session as any ) . lastRestartAt = Date . now ( ) - 600_000 // 10 minutes ago (> 5 min cooldown)
2396+
2397+ ; ( sm as any ) . handleClaudeExit ( session , s . id , 0 , null )
2398+
2399+ // restartCount should have been reset to 0 before incrementing to 1
2400+ expect ( ( session as any ) . restartCount ) . toBe ( 1 )
2401+ vi . useRealTimers ( )
2402+ } )
2403+
2404+ it ( 'broadcasts error when all restart attempts exhausted' , ( ) => {
2405+ vi . useFakeTimers ( )
2406+ const s = sm . create ( 'exhausted-test' , '/tmp' )
2407+ const session = sm . get ( s . id ) !
2408+ ; ( session as any ) . restartCount = 5 // MAX_RESTARTS is 5
2409+ ; ( session as any ) . lastRestartAt = Date . now ( )
2410+
2411+ const ws = fakeWs ( )
2412+ sm . join ( s . id , ws )
2413+
2414+ ; ( sm as any ) . handleClaudeExit ( session , s . id , 1 , null )
2415+
2416+ const messages = ws . send . mock . calls . map ( ( c : any ) => JSON . parse ( c [ 0 ] ) )
2417+ const errorMsg = messages . find ( ( m : any ) => m . subtype === 'error' )
2418+ expect ( errorMsg ) . toBeDefined ( )
2419+ expect ( errorMsg . text ) . toContain ( 'Auto-restart disabled' )
2420+ vi . useRealTimers ( )
2421+ } )
2422+
2423+ it ( 'notifies exit listeners with willRestart=true on auto-restart' , ( ) => {
2424+ vi . useFakeTimers ( )
2425+ const listener = vi . fn ( )
2426+ sm . onSessionExit ( listener )
2427+
2428+ const s = sm . create ( 'listener-test' , '/tmp' )
2429+ const session = sm . get ( s . id ) !
2430+ ; ( session as any ) . restartCount = 0
2431+
2432+ ; ( sm as any ) . handleClaudeExit ( session , s . id , 1 , 'SIGTERM' )
2433+
2434+ expect ( listener ) . toHaveBeenCalledWith ( s . id , 1 , 'SIGTERM' , true )
2435+ vi . useRealTimers ( )
2436+ } )
2437+
2438+ it ( 'notifies exit listeners with willRestart=false when stopped by user' , ( ) => {
2439+ const listener = vi . fn ( )
2440+ sm . onSessionExit ( listener )
2441+
2442+ const s = sm . create ( 'listener-stopped' , '/tmp' )
2443+ const session = sm . get ( s . id ) !
2444+ ; ( session as any ) . _stoppedByUser = true
2445+
2446+ ; ( sm as any ) . handleClaudeExit ( session , s . id , 0 , null )
2447+
2448+ expect ( listener ) . toHaveBeenCalledWith ( s . id , 0 , null , false )
2449+ } )
2450+ } )
2451+
2452+ describe ( 'handleClaudeResult API retry' , ( ) => {
2453+ it ( 'broadcasts exhaustion message after MAX_API_RETRIES' , ( ) => {
2454+ vi . useFakeTimers ( )
2455+ const s = sm . create ( 'retry-exhaust' , '/tmp' )
2456+ const session = sm . get ( s . id ) !
2457+ ; ( session as any ) . _apiRetryCount = 3 // MAX_API_RETRIES is 3
2458+ ; ( session as any ) . _lastUserInput = 'test input'
2459+ ; ( session as any ) . claudeProcess = fakeClaudeProcess ( )
2460+
2461+ const ws = fakeWs ( )
2462+ sm . join ( s . id , ws )
2463+
2464+ // Simulate a retryable error result with retries exhausted
2465+ ; ( sm as any ) . handleClaudeResult ( session , s . id , 'API error: overloaded' , true )
2466+
2467+ const messages = ws . send . mock . calls . map ( ( c : any ) => JSON . parse ( c [ 0 ] ) )
2468+ const errorMsg = messages . find ( ( m : any ) => m . subtype === 'error' && m . text ?. includes ( 'retries' ) )
2469+ expect ( errorMsg ) . toBeDefined ( )
2470+ expect ( errorMsg . text ) . toContain ( '3 retries' )
2471+
2472+ // Retry counter should be reset
2473+ expect ( ( session as any ) . _apiRetryCount ) . toBe ( 0 )
2474+ vi . useRealTimers ( )
2475+ } )
2476+ } )
22152477} )
0 commit comments