Skip to content

Commit 012a120

Browse files
Auto close sessions on IDLE timeouts
1 parent e841aa9 commit 012a120

File tree

11 files changed

+198
-15
lines changed

11 files changed

+198
-15
lines changed

Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
<PropertyGroup Label="Default Versioning">
3737
<!-- Default version for all projects (can be overridden per project) -->
3838
<VersionPrefix>1.1.0</VersionPrefix>
39-
<VersionSuffix>61</VersionSuffix>
39+
<VersionSuffix>62</VersionSuffix>
4040

4141
<!-- Calculated version -->
4242
<Version Condition="'$(Version)' == ''">$(VersionPrefix).$(VersionSuffix)</Version>

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,7 +224,8 @@ MCP Nexus intelligently batches commands for improved throughput:
224224
"SessionManagement": {
225225
"MaxConcurrentSessions": 10,
226226
"SessionTimeoutMinutes": 30,
227-
"DefaultCommandTimeoutSeconds": 300
227+
"CleanupIntervalSeconds": 300,
228+
"DefaultCommandTimeoutMinutes": 10
228229
}
229230
}
230231
}

nexus_config/Models/SharedConfiguration.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,17 +246,17 @@ public class SessionManagementSettings
246246
public int SessionTimeoutMinutes { get; set; } = 30;
247247

248248
/// <summary>
249-
/// Gets or sets the cleanup interval in minutes.
249+
/// Gets or sets the cleanup interval in seconds.
250250
/// </summary>
251-
public int CleanupIntervalMinutes { get; set; } = 5;
251+
public int CleanupIntervalSeconds { get; set; } = 5;
252252

253253
/// <summary>
254254
/// Gets the cleanup interval as a TimeSpan.
255255
/// </summary>
256256
/// <returns>The cleanup interval.</returns>
257257
public TimeSpan GetCleanupInterval()
258258
{
259-
return TimeSpan.FromMinutes(CleanupIntervalMinutes);
259+
return TimeSpan.FromSeconds(CleanupIntervalSeconds);
260260
}
261261

262262
/// <summary>

nexus_config/appsettings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,8 @@
3737
},
3838
"SessionManagement": {
3939
"MaxConcurrentSessions": 1000,
40-
"SessionTimeoutMinutes": 30,
41-
"CleanupIntervalMinutes": 5,
40+
"SessionTimeoutMinutes": 10,
41+
"CleanupIntervalSeconds": 30,
4242
"DisposalTimeoutSeconds": 30,
4343
"DefaultCommandTimeoutMinutes": 10,
4444
"MemoryCleanupThresholdMB": 1024

nexus_engine/nexus_engine/DebugEngine.cs

Lines changed: 81 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -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>

nexus_engine/nexus_engine/Internal/DebugSession.cs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ internal class DebugSession : IDisposable
2121
private readonly ReaderWriterLockSlim m_StateLock = new();
2222
private SessionState m_State = SessionState.Initializing;
2323
private volatile bool m_Disposed = false;
24+
25+
/// <summary>
26+
/// Stores the last activity timestamp for this session as ticks, updated lock-free for performance.
27+
/// </summary>
28+
private long m_LastActivityTicks;
2429

2530
/// <summary>
2631
/// Gets the session identifier.
@@ -96,6 +101,9 @@ public DebugSession(
96101

97102
// Subscribe to command queue events
98103
m_CommandQueue.CommandStateChanged += OnCommandStateChanged;
104+
105+
// Seed last activity to now
106+
m_LastActivityTicks = DateTime.Now.Ticks;
99107
}
100108

101109
/// <summary>
@@ -117,6 +125,9 @@ public async Task InitializeAsync(CancellationToken cancellationToken = default)
117125
await m_CommandQueue.StartAsync(m_CdbSession, cancellationToken);
118126

119127
SetState(SessionState.Active);
128+
129+
UpdateLastActivity();
130+
120131
m_Logger.Info("Debug session {SessionId} initialized successfully", SessionId);
121132
}
122133
catch (Exception ex)
@@ -136,6 +147,8 @@ public string EnqueueCommand(string command)
136147
{
137148
ThrowIfDisposed();
138149
ThrowIfNotActive();
150+
151+
UpdateLastActivity();
139152

140153
return m_CommandQueue.EnqueueCommand(command);
141154
}
@@ -269,6 +282,9 @@ public void Dispose()
269282
/// <param name="e">The event arguments.</param>
270283
protected void OnCommandStateChanged(object? sender, CommandStateChangedEventArgs e)
271284
{
285+
// Update last activity whenever a command state changes
286+
UpdateLastActivity();
287+
272288
// Forward the event with session context
273289
var args = new CommandStateChangedEventArgs
274290
{
@@ -315,6 +331,33 @@ protected void SetState(SessionState newState)
315331
}
316332
}
317333

334+
/// <summary>
335+
/// Gets the last activity time for this session.
336+
/// </summary>
337+
public DateTime LastActivityTime
338+
{
339+
get
340+
{
341+
return new DateTime(Volatile.Read(ref m_LastActivityTicks));
342+
}
343+
}
344+
345+
/// <summary>
346+
/// Registers an activity on this session by updating the last activity timestamp.
347+
/// </summary>
348+
internal void RegisterActivity()
349+
{
350+
UpdateLastActivity();
351+
}
352+
353+
/// <summary>
354+
/// Updates the last activity timestamp to the current local time in a lock-free manner.
355+
/// </summary>
356+
protected void UpdateLastActivity()
357+
{
358+
_ = Interlocked.Exchange(ref m_LastActivityTicks, DateTime.Now.Ticks);
359+
}
360+
318361
/// <summary>
319362
/// Throws an exception if the session has been disposed.
320363
/// </summary>

nexus_engine/nexus_engine_share/IDebugEngine.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,10 @@ public interface IDebugEngine : IDisposable
2424
/// Closes a debug session and cleans up resources.
2525
/// </summary>
2626
/// <param name="sessionId">The session ID to close.</param>
27+
/// <param name="closeReason">Optional reason for session closure (e.g., "IdleTimeout", "UserRequest").</param>
2728
/// <returns>A task that represents the asynchronous operation.</returns>
2829
/// <exception cref="ArgumentException">Thrown when sessionId is null or empty.</exception>
29-
Task CloseSessionAsync(string sessionId);
30+
Task CloseSessionAsync(string sessionId, string? closeReason = null);
3031

3132
/// <summary>
3233
/// Checks if a session is currently active.

nexus_engine/nexus_engine_share/Statistics.cs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ public static void EmitCommandStats(
114114
/// <param name="timedOutCommands">Number of timed out commands.</param>
115115
/// <param name="commands">Collection of all commands in the session.</param>
116116
/// <param name="commandIdToBatchId">Optional mapping of individual command IDs to batch command IDs for summary metrics.</param>
117+
/// <param name="closeReason">Optional reason for session closure (e.g., IdleTimeout, UserRequest).</param>
117118
public static void EmitSessionStats(
118119
Logger logger,
119120
string sessionId,
@@ -126,7 +127,8 @@ public static void EmitSessionStats(
126127
int cancelledCommands,
127128
int timedOutCommands,
128129
IEnumerable<CommandInfo> commands,
129-
IDictionary<string, string?>? commandIdToBatchId = null)
130+
IDictionary<string, string?>? commandIdToBatchId = null,
131+
string? closeReason = null)
130132
{
131133
var sb = new StringBuilder();
132134
_ = sb.AppendLine();
@@ -135,6 +137,10 @@ public static void EmitSessionStats(
135137
_ = sb.AppendLine($" ║ OpenedAt: {openedAt:yyyy-MM-dd HH:mm:ss.fff}");
136138
_ = sb.AppendLine($" ║ ClosedAt: {closedAt:yyyy-MM-dd HH:mm:ss.fff}");
137139
_ = sb.AppendLine($" ║ TotalDuration: {totalDuration}");
140+
if (!string.IsNullOrWhiteSpace(closeReason))
141+
{
142+
_ = sb.AppendLine($" ║ CloseReason: {closeReason}");
143+
}
138144
_ = sb.AppendLine(" ║ ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────");
139145
_ = sb.AppendLine($" ║ TotalCommands: {totalCommands}");
140146
_ = sb.AppendLine($" ║ CompletedCommands: {completedCommands}");

unittests/nexus_config_unittests/Models/SharedConfigurationTests.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ public void SessionManagementSettings_DefaultValues_ShouldBeCorrect()
228228
// Assert
229229
_ = settings.MaxConcurrentSessions.Should().Be(1000);
230230
_ = settings.SessionTimeoutMinutes.Should().Be(30);
231-
_ = settings.CleanupIntervalMinutes.Should().Be(5);
231+
_ = settings.CleanupIntervalSeconds.Should().Be(5);
232232
_ = settings.DisposalTimeoutSeconds.Should().Be(30);
233233
_ = settings.DefaultCommandTimeoutMinutes.Should().Be(10);
234234
_ = settings.MemoryCleanupThresholdMB.Should().Be(1024);
@@ -395,7 +395,7 @@ public void SessionManagementSettings_CanBeModified()
395395
{
396396
MaxConcurrentSessions = 50,
397397
SessionTimeoutMinutes = 60,
398-
CleanupIntervalMinutes = 10,
398+
CleanupIntervalSeconds = 10,
399399
DisposalTimeoutSeconds = 60,
400400
DefaultCommandTimeoutMinutes = 20,
401401
MemoryCleanupThresholdMB = 2048
@@ -404,7 +404,7 @@ public void SessionManagementSettings_CanBeModified()
404404
// Assert
405405
_ = settings.MaxConcurrentSessions.Should().Be(50);
406406
_ = settings.SessionTimeoutMinutes.Should().Be(60);
407-
_ = settings.CleanupIntervalMinutes.Should().Be(10);
407+
_ = settings.CleanupIntervalSeconds.Should().Be(10);
408408
_ = settings.DisposalTimeoutSeconds.Should().Be(60);
409409
_ = settings.DefaultCommandTimeoutMinutes.Should().Be(20);
410410
_ = settings.MemoryCleanupThresholdMB.Should().Be(2048);

unittests/nexus_engine/nexus_engine_unittests/DebugEngineTestAccessor.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,13 @@ public DebugEngineTestAccessor(IFileSystem fileSystem, IProcessManager processMa
5757
{
5858
base.ThrowIfDisposed();
5959
}
60+
61+
/// <summary>
62+
/// Invokes the protected idle session cleanup for testing.
63+
/// </summary>
64+
public void InvokeCleanupIdleSessions()
65+
{
66+
base.CleanupIdleSessions();
67+
}
6068
}
6169

0 commit comments

Comments
 (0)