@@ -49,6 +49,11 @@ public class DebugEngine : IDebugEngine
4949 /// </summary>
5050 private readonly ConcurrentDictionary < string , DateTime > m_SessionCreationTimes = new ( ) ;
5151
52+ /// <summary>
53+ /// Periodic timer that checks for idle sessions and closes them when they exceed the configured timeout.
54+ /// </summary>
55+ private readonly System . Threading . Timer m_SessionCleanupTimer ;
56+
5257 /// <summary>
5358 /// Indicates whether this instance has been disposed.
5459 /// </summary>
@@ -81,6 +86,9 @@ internal DebugEngine(IFileSystem fileSystem, IProcessManager processManager)
8186
8287 m_Logger = LogManager . GetCurrentClassLogger ( ) ;
8388 m_Logger . Info ( "DebugEngine initialized with max {MaxSessions} concurrent sessions" , Settings . Instance . Get ( ) . McpNexus . SessionManagement . MaxConcurrentSessions ) ;
89+
90+ var cleanupInterval = Settings . Instance . Get ( ) . McpNexus . SessionManagement . GetCleanupInterval ( ) ;
91+ m_SessionCleanupTimer = new System . Threading . Timer ( _ => CleanupIdleSessions ( ) , null , cleanupInterval , cleanupInterval ) ;
8492 }
8593
8694 /// <summary>
@@ -159,9 +167,10 @@ public async Task<string> CreateSessionAsync(string dumpFilePath, string? symbol
159167 /// Closes a debug session and cleans up resources.
160168 /// </summary>
161169 /// <param name="sessionId">The session ID to close.</param>
170+ /// <param name="closeReason">Optional reason for session closure (e.g., "IdleTimeout", "UserRequest").</param>
162171 /// <returns>A task that represents the asynchronous operation.</returns>
163172 /// <exception cref="ArgumentException">Thrown when sessionId is null or empty.</exception>
164- public async Task CloseSessionAsync ( string sessionId )
173+ public async Task CloseSessionAsync ( string sessionId , string ? closeReason = null )
165174 {
166175 ThrowIfDisposed ( ) ;
167176 ValidateSessionId ( sessionId , nameof ( sessionId ) ) ;
@@ -237,7 +246,8 @@ public async Task CloseSessionAsync(string sessionId)
237246 cancelledCount ,
238247 timedOutCount ,
239248 allCommands ,
240- commandIdToBatchId ) ;
249+ commandIdToBatchId ,
250+ closeReason ) ;
241251 }
242252 catch ( Exception ex )
243253 {
@@ -257,7 +267,7 @@ public async Task CloseSessionAsync(string sessionId)
257267 // Remove session creation time tracking
258268 _ = m_SessionCreationTimes . TryRemove ( sessionId , out _ ) ;
259269
260- m_Logger . Info ( "Debug session {SessionId} closed successfully" , sessionId ) ;
270+ m_Logger . Info ( "Debug session {SessionId} closed successfully{ReasonSuffix} " , sessionId , string . IsNullOrWhiteSpace ( closeReason ) ? string . Empty : $ " (Reason: { closeReason } )" ) ;
261271 }
262272 catch ( Exception ex )
263273 {
@@ -353,6 +363,12 @@ public async Task<string> EnqueueExtensionScriptAsync(string sessionId, string e
353363 Command = $ "Extension: { extensionName } "
354364 } ) ;
355365
366+ // Register session activity for extension command enqueue
367+ if ( m_Sessions . TryGetValue ( sessionId , out var extSession ) )
368+ {
369+ extSession . RegisterActivity ( ) ;
370+ }
371+
356372 return commandId ;
357373 }
358374
@@ -525,6 +541,9 @@ public void Dispose()
525541
526542 m_Logger . Info ( "Disposing DebugEngine with {SessionCount} active sessions" , m_Sessions . Count ) ;
527543
544+ // Stop cleanup timer
545+ m_SessionCleanupTimer ? . Dispose ( ) ;
546+
528547 using var cts = new CancellationTokenSource ( TimeSpan . FromSeconds ( 30 ) ) ;
529548 var failedSessions = new List < string > ( ) ;
530549
@@ -593,6 +612,65 @@ protected void OnSessionStateChanged(object? sender, SessionStateChangedEventArg
593612 }
594613
595614
615+ /// <summary>
616+ /// Scans active sessions and automatically closes those that have been idle beyond the configured timeout.
617+ /// Sessions with queued or executing commands are never closed by this cleanup.
618+ /// </summary>
619+ protected void CleanupIdleSessions ( )
620+ {
621+ if ( m_Disposed )
622+ {
623+ return ;
624+ }
625+
626+ var now = DateTime . Now ;
627+ var sessionTimeout = TimeSpan . FromMinutes ( Settings . Instance . Get ( ) . McpNexus . SessionManagement . SessionTimeoutMinutes ) ;
628+
629+ foreach ( var kvp in m_Sessions )
630+ {
631+ var sessionId = kvp . Key ;
632+ var session = kvp . Value ;
633+
634+ try
635+ {
636+ var lastActivity = session . LastActivityTime ;
637+
638+ // If within timeout, skip
639+ if ( now - lastActivity < sessionTimeout )
640+ {
641+ continue ;
642+ }
643+
644+ // Skip if there are any active (queued/executing) commands
645+ var anyActive = session
646+ . GetAllCommandInfos ( )
647+ . Values
648+ . Any ( ci => ci . State == CommandState . Queued || ci . State == CommandState . Executing ) ;
649+
650+ if ( anyActive )
651+ {
652+ continue ;
653+ }
654+
655+ // Close idle session synchronously for determinism
656+ try
657+ {
658+ m_Logger . Info ( "Auto-closing idle session {SessionId} after {Minutes} minutes of inactivity" , sessionId , sessionTimeout . TotalMinutes ) ;
659+ CloseSessionAsync ( sessionId , "IdleTimeout" ) . GetAwaiter ( ) . GetResult ( ) ;
660+ }
661+ catch ( Exception ex )
662+ {
663+ m_Logger . Error ( ex , "Error auto-closing idle session {SessionId}" , sessionId ) ;
664+ }
665+ }
666+ catch ( Exception ex )
667+ {
668+ m_Logger . Warn ( ex , "Cleanup check failed for session {SessionId}" , sessionId ) ;
669+ }
670+ }
671+ }
672+
673+
596674 /// <summary>
597675 /// Validates that a session ID is not null or empty.
598676 /// </summary>
0 commit comments