@@ -8,10 +8,12 @@ import { configStore } from './config-store.js'
88import type { ClaudeSessionManager } from './claude-session.js'
99import type { ClaudeEvent } from './claude-stream-types.js'
1010import type { SessionRepairService } from './session-scanner/service.js'
11+ import type { SessionScanResult , SessionRepairResult } from './session-scanner/types.js'
1112
1213const MAX_CONNECTIONS = Number ( process . env . MAX_CONNECTIONS || 10 )
1314const HELLO_TIMEOUT_MS = Number ( process . env . HELLO_TIMEOUT_MS || 5_000 )
1415const PING_INTERVAL_MS = Number ( process . env . PING_INTERVAL_MS || 30_000 )
16+ const MAX_WS_BUFFERED_AMOUNT = Number ( process . env . MAX_WS_BUFFERED_AMOUNT || 2 * 1024 * 1024 )
1517
1618// Extended WebSocket with liveness tracking for keepalive
1719interface LiveWebSocket extends WebSocket {
@@ -142,6 +144,7 @@ type ClientState = {
142144 attachedTerminalIds : Set < string >
143145 createdByRequestId : Map < string , string >
144146 claudeSessions : Set < string >
147+ claudeSubscriptions : Map < string , ( ) => void >
145148 interestedSessions : Set < string >
146149 helloTimer ?: NodeJS . Timeout
147150}
@@ -152,6 +155,10 @@ export class WsHandler {
152155 private clientStates = new Map < LiveWebSocket , ClientState > ( )
153156 private pingInterval : NodeJS . Timeout | null = null
154157 private sessionRepairService ?: SessionRepairService
158+ private sessionRepairListeners ?: {
159+ scanned : ( result : SessionScanResult ) => void
160+ repaired : ( result : SessionRepairResult ) => void
161+ }
155162
156163 constructor (
157164 server : http . Server ,
@@ -182,24 +189,28 @@ export class WsHandler {
182189
183190 // Subscribe to session repair events
184191 if ( this . sessionRepairService ) {
185- this . sessionRepairService . on ( 'scanned' , ( result ) => {
192+ const onScanned = ( result : SessionScanResult ) => {
186193 this . broadcastSessionStatus ( result . sessionId , {
187194 type : 'session.status' ,
188195 sessionId : result . sessionId ,
189196 status : result . status === 'healthy' ? 'healthy' : 'corrupted' ,
190197 chainDepth : result . chainDepth ,
191198 } )
192- } )
199+ }
193200
194- this . sessionRepairService . on ( 'repaired' , ( result ) => {
201+ const onRepaired = ( result : SessionRepairResult ) => {
195202 this . broadcastSessionStatus ( result . sessionId , {
196203 type : 'session.status' ,
197204 sessionId : result . sessionId ,
198205 status : 'repaired' ,
199206 chainDepth : result . newChainDepth ,
200207 orphansFixed : result . orphansFixed ,
201208 } )
202- } )
209+ }
210+
211+ this . sessionRepairListeners = { scanned : onScanned , repaired : onRepaired }
212+ this . sessionRepairService . on ( 'scanned' , onScanned )
213+ this . sessionRepairService . on ( 'repaired' , onRepaired )
203214 }
204215 }
205216
@@ -259,6 +270,7 @@ export class WsHandler {
259270 attachedTerminalIds : new Set ( ) ,
260271 createdByRequestId : new Map ( ) ,
261272 claudeSessions : new Set ( ) ,
273+ claudeSubscriptions : new Map ( ) ,
262274 interestedSessions : new Set ( ) ,
263275 }
264276 this . clientStates . set ( ws , state )
@@ -291,10 +303,29 @@ export class WsHandler {
291303 this . registry . detach ( terminalId , ws )
292304 }
293305 state . attachedTerminalIds . clear ( )
306+ for ( const off of state . claudeSubscriptions . values ( ) ) {
307+ off ( )
308+ }
309+ state . claudeSubscriptions . clear ( )
310+ }
311+
312+ private removeClaudeSubscription ( state : ClientState , sessionId : string ) {
313+ const off = state . claudeSubscriptions . get ( sessionId )
314+ if ( off ) {
315+ off ( )
316+ state . claudeSubscriptions . delete ( sessionId )
317+ }
294318 }
295319
296320 private send ( ws : LiveWebSocket , msg : unknown ) {
297321 try {
322+ // Backpressure guard.
323+ // @ts -ignore
324+ const buffered = ws . bufferedAmount as number | undefined
325+ if ( typeof buffered === 'number' && buffered > MAX_WS_BUFFERED_AMOUNT ) {
326+ ws . close ( CLOSE_CODES . BACKPRESSURE , 'Backpressure' )
327+ return
328+ }
298329 ws . send ( JSON . stringify ( msg ) )
299330 } catch {
300331 // ignore
@@ -307,12 +338,16 @@ export class WsHandler {
307338 }
308339 }
309340
310- private sendError ( ws : LiveWebSocket , params : { code : z . infer < typeof ErrorCode > ; message : string ; requestId ?: string } ) {
341+ private sendError (
342+ ws : LiveWebSocket ,
343+ params : { code : z . infer < typeof ErrorCode > ; message : string ; requestId ?: string ; terminalId ?: string }
344+ ) {
311345 this . send ( ws , {
312346 type : 'error' ,
313347 code : params . code ,
314348 message : params . message ,
315349 requestId : params . requestId ,
350+ terminalId : params . terminalId ,
316351 timestamp : nowIso ( ) ,
317352 } )
318353 }
@@ -445,7 +480,7 @@ export class WsHandler {
445480 case 'terminal.attach' : {
446481 const rec = this . registry . attach ( m . terminalId , ws )
447482 if ( ! rec ) {
448- this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Unknown terminalId' } )
483+ this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Unknown terminalId' , terminalId : m . terminalId } )
449484 return
450485 }
451486 state . attachedTerminalIds . add ( m . terminalId )
@@ -458,7 +493,7 @@ export class WsHandler {
458493 const ok = this . registry . detach ( m . terminalId , ws )
459494 state . attachedTerminalIds . delete ( m . terminalId )
460495 if ( ! ok ) {
461- this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Unknown terminalId' } )
496+ this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Unknown terminalId' , terminalId : m . terminalId } )
462497 return
463498 }
464499 this . send ( ws , { type : 'terminal.detached' , terminalId : m . terminalId } )
@@ -469,23 +504,23 @@ export class WsHandler {
469504 case 'terminal.input' : {
470505 const ok = this . registry . input ( m . terminalId , m . data )
471506 if ( ! ok ) {
472- this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Terminal not running' } )
507+ this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Terminal not running' , terminalId : m . terminalId } )
473508 }
474509 return
475510 }
476511
477512 case 'terminal.resize' : {
478513 const ok = this . registry . resize ( m . terminalId , m . cols , m . rows )
479514 if ( ! ok ) {
480- this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Terminal not running' } )
515+ this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Terminal not running' , terminalId : m . terminalId } )
481516 }
482517 return
483518 }
484519
485520 case 'terminal.kill' : {
486521 const ok = this . registry . kill ( m . terminalId )
487522 if ( ! ok ) {
488- this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Unknown terminalId' } )
523+ this . sendError ( ws , { code : 'INVALID_TERMINAL_ID' , message : 'Unknown terminalId' , terminalId : m . terminalId } )
489524 return
490525 }
491526 this . broadcast ( { type : 'terminal.list.updated' } )
@@ -531,29 +566,40 @@ export class WsHandler {
531566 // Track this client's session
532567 state . claudeSessions . add ( session . id )
533568
534- // Stream events to client
535- session . on ( 'event' , ( event : ClaudeEvent ) => {
569+ // Stream events to client with detachable listeners
570+ const onEvent = ( event : ClaudeEvent ) => {
536571 this . safeSend ( ws , {
537572 type : 'claude.event' ,
538573 sessionId : session . id ,
539574 event,
540575 } )
541- } )
576+ }
542577
543- session . on ( 'exit' , ( code : number ) => {
578+ const onExit = ( code : number ) => {
544579 this . safeSend ( ws , {
545580 type : 'claude.exit' ,
546581 sessionId : session . id ,
547582 exitCode : code ,
548583 } )
549- } )
584+ this . removeClaudeSubscription ( state , session . id )
585+ }
550586
551- session . on ( 'stderr' , ( text : string ) => {
587+ const onStderr = ( text : string ) => {
552588 this . safeSend ( ws , {
553589 type : 'claude.stderr' ,
554590 sessionId : session . id ,
555591 text,
556592 } )
593+ }
594+
595+ session . on ( 'event' , onEvent )
596+ session . on ( 'exit' , onExit )
597+ session . on ( 'stderr' , onStderr )
598+
599+ state . claudeSubscriptions . set ( session . id , ( ) => {
600+ session . off ( 'event' , onEvent )
601+ session . off ( 'exit' , onExit )
602+ session . off ( 'stderr' , onStderr )
557603 } )
558604
559605 this . send ( ws , {
@@ -596,6 +642,7 @@ export class WsHandler {
596642
597643 const removed = this . claudeManager . remove ( m . sessionId )
598644 state . claudeSessions . delete ( m . sessionId )
645+ this . removeClaudeSubscription ( state , m . sessionId )
599646 this . send ( ws , {
600647 type : 'claude.killed' ,
601648 sessionId : m . sessionId ,
@@ -622,6 +669,12 @@ export class WsHandler {
622669 * Gracefully close all WebSocket connections and the server.
623670 */
624671 close ( ) : void {
672+ if ( this . sessionRepairService && this . sessionRepairListeners ) {
673+ this . sessionRepairService . off ( 'scanned' , this . sessionRepairListeners . scanned )
674+ this . sessionRepairService . off ( 'repaired' , this . sessionRepairListeners . repaired )
675+ this . sessionRepairListeners = undefined
676+ }
677+
625678 // Stop keepalive ping interval
626679 if ( this . pingInterval ) {
627680 clearInterval ( this . pingInterval )
0 commit comments