@@ -8,28 +8,39 @@ namespace ModelContextProtocol.AspNetCore;
8
8
internal sealed partial class IdleTrackingBackgroundService (
9
9
StreamableHttpHandler handler ,
10
10
IOptions < HttpServerTransportOptions > options ,
11
+ IHostApplicationLifetime appLifetime ,
11
12
ILogger < IdleTrackingBackgroundService > logger ) : BackgroundService
12
13
{
13
14
// The compiler will complain about the parameter being unused otherwise despite the source generator.
14
15
private ILogger _logger = logger ;
15
16
16
- // We can make this configurable once we properly harden the MCP server. In the meantime, anyone running
17
- // this should be taking a cattle not pets approach to their servers and be able to launch more processes
18
- // to handle more than 10,000 idle sessions at a time.
19
- private const int MaxIdleSessionCount = 10_000 ;
20
-
21
17
protected override async Task ExecuteAsync ( CancellationToken stoppingToken )
22
18
{
23
- var timeProvider = options . Value . TimeProvider ;
24
- using var timer = new PeriodicTimer ( TimeSpan . FromSeconds ( 5 ) , timeProvider ) ;
19
+ // Still run loop given infinite IdleTimeout to enforce the MaxIdleSessionCount and assist graceful shutdown.
20
+ if ( options . Value . IdleTimeout != Timeout . InfiniteTimeSpan )
21
+ {
22
+ ArgumentOutOfRangeException . ThrowIfLessThan ( options . Value . IdleTimeout , TimeSpan . Zero ) ;
23
+ }
24
+ ArgumentOutOfRangeException . ThrowIfLessThan ( options . Value . MaxIdleSessionCount , 0 ) ;
25
25
26
26
try
27
27
{
28
+ var timeProvider = options . Value . TimeProvider ;
29
+ using var timer = new PeriodicTimer ( TimeSpan . FromSeconds ( 5 ) , timeProvider ) ;
30
+
31
+ var idleTimeoutTicks = options . Value . IdleTimeout . Ticks ;
32
+ var maxIdleSessionCount = options . Value . MaxIdleSessionCount ;
33
+
34
+ var idleSessions = new SortedSet < ( string SessionId , long Timestamp ) > ( SessionTimestampComparer . Instance ) ;
35
+
28
36
while ( ! stoppingToken . IsCancellationRequested && await timer . WaitForNextTickAsync ( stoppingToken ) )
29
37
{
30
- var idleActivityCutoff = timeProvider . GetTimestamp ( ) - options . Value . IdleTimeout . Ticks ;
38
+ var idleActivityCutoff = idleTimeoutTicks switch
39
+ {
40
+ < 0 => long . MinValue ,
41
+ var ticks => timeProvider . GetTimestamp ( ) - ticks ,
42
+ } ;
31
43
32
- var idleCount = 0 ;
33
44
foreach ( var ( _, session ) in handler . Sessions )
34
45
{
35
46
if ( session . IsActive || session . SessionClosed . IsCancellationRequested )
@@ -38,34 +49,40 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
38
49
continue ;
39
50
}
40
51
41
- idleCount ++ ;
42
- if ( idleCount == MaxIdleSessionCount )
43
- {
44
- // Emit critical log at most once every 5 seconds the idle count it exceeded,
45
- //since the IdleTimeout will no longer be respected.
46
- LogMaxSessionIdleCountExceeded ( ) ;
47
- }
48
- else if ( idleCount < MaxIdleSessionCount && session . LastActivityTicks > idleActivityCutoff )
52
+ if ( session . LastActivityTicks < idleActivityCutoff )
49
53
{
54
+ RemoveAndCloseSession ( session . Id ) ;
50
55
continue ;
51
56
}
52
57
53
- if ( handler . Sessions . TryRemove ( session . Id , out var removedSession ) )
58
+ idleSessions . Add ( ( session . Id , session . LastActivityTicks ) ) ;
59
+
60
+ // Emit critical log at most once every 5 seconds the idle count it exceeded,
61
+ // since the IdleTimeout will no longer be respected.
62
+ if ( idleSessions . Count == maxIdleSessionCount + 1 )
54
63
{
55
- LogSessionIdle ( removedSession . Id ) ;
64
+ LogMaxSessionIdleCountExceeded ( maxIdleSessionCount ) ;
65
+ }
66
+ }
56
67
57
- // Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
58
- _ = DisposeSessionAsync ( removedSession ) ;
68
+ if ( idleSessions . Count > maxIdleSessionCount )
69
+ {
70
+ var sessionsToPrune = idleSessions . ToArray ( ) [ ..^ maxIdleSessionCount ] ;
71
+ foreach ( var ( id , _) in sessionsToPrune )
72
+ {
73
+ RemoveAndCloseSession ( id ) ;
59
74
}
60
75
}
76
+
77
+ idleSessions . Clear ( ) ;
61
78
}
62
79
}
63
80
catch ( OperationCanceledException ) when ( stoppingToken . IsCancellationRequested )
64
81
{
65
82
}
66
83
finally
67
84
{
68
- if ( stoppingToken . IsCancellationRequested )
85
+ try
69
86
{
70
87
List < Task > disposeSessionTasks = [ ] ;
71
88
@@ -79,7 +96,29 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
79
96
80
97
await Task . WhenAll ( disposeSessionTasks ) ;
81
98
}
99
+ finally
100
+ {
101
+ if ( ! stoppingToken . IsCancellationRequested )
102
+ {
103
+ // Something went terribly wrong. A very unexpected exception must be bubbling up, but let's ensure we also stop the application,
104
+ // so that it hopefully gets looked at and restarted. This shouldn't really be reachable.
105
+ appLifetime . StopApplication ( ) ;
106
+ IdleTrackingBackgroundServiceStoppedUnexpectedly ( ) ;
107
+ }
108
+ }
109
+ }
110
+ }
111
+
112
+ private void RemoveAndCloseSession ( string sessionId )
113
+ {
114
+ if ( ! handler . Sessions . TryRemove ( sessionId , out var session ) )
115
+ {
116
+ return ;
82
117
}
118
+
119
+ LogSessionIdle ( session . Id ) ;
120
+ // Don't slow down the idle tracking loop. DisposeSessionAsync logs. We only await during graceful shutdown.
121
+ _ = DisposeSessionAsync ( session ) ;
83
122
}
84
123
85
124
private async Task DisposeSessionAsync ( HttpMcpSession < StreamableHttpServerTransport > session )
@@ -94,12 +133,28 @@ private async Task DisposeSessionAsync(HttpMcpSession<StreamableHttpServerTransp
94
133
}
95
134
}
96
135
136
+ private sealed class SessionTimestampComparer : IComparer < ( string SessionId , long Timestamp ) >
137
+ {
138
+ public static SessionTimestampComparer Instance { get ; } = new ( ) ;
139
+
140
+ public int Compare ( ( string SessionId , long Timestamp ) x , ( string SessionId , long Timestamp ) y ) =>
141
+ x . Timestamp . CompareTo ( y . Timestamp ) switch
142
+ {
143
+ // Use a SessionId comparison as tiebreaker to ensure uniqueness in the SortedSet.
144
+ 0 => string . CompareOrdinal ( x . SessionId , y . SessionId ) ,
145
+ var timestampComparison => timestampComparison ,
146
+ } ;
147
+ }
148
+
97
149
[ LoggerMessage ( Level = LogLevel . Information , Message = "Closing idle session {sessionId}." ) ]
98
150
private partial void LogSessionIdle ( string sessionId ) ;
99
151
100
- [ LoggerMessage ( Level = LogLevel . Critical , Message = "Exceeded static maximum of 10,000 idle connections. Now clearing all inactive connections regardless of timeout." ) ]
101
- private partial void LogMaxSessionIdleCountExceeded ( ) ;
102
-
103
- [ LoggerMessage ( Level = LogLevel . Error , Message = "Error disposing the IMcpServer for session {sessionId}." ) ]
152
+ [ LoggerMessage ( Level = LogLevel . Error , Message = "Error disposing session {sessionId}." ) ]
104
153
private partial void LogSessionDisposeError ( string sessionId , Exception ex ) ;
154
+
155
+ [ LoggerMessage ( Level = LogLevel . Critical , Message = "Exceeded maximum of {maxIdleSessionCount} idle sessions. Now closing sessions active more recently than configured IdleTimeout." ) ]
156
+ private partial void LogMaxSessionIdleCountExceeded ( int maxIdleSessionCount ) ;
157
+
158
+ [ LoggerMessage ( Level = LogLevel . Critical , Message = "The IdleTrackingBackgroundService has stopped unexpectedly." ) ]
159
+ private partial void IdleTrackingBackgroundServiceStoppedUnexpectedly ( ) ;
105
160
}
0 commit comments