Skip to content

Commit aaffd71

Browse files
Copilotstephentoubeiriktsarpalis
authored
Fix session timeout due to timestamp frequency mismatch and activity tracking (#1106)
Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: stephentoub <[email protected]> Co-authored-by: eiriktsarpalis <[email protected]>
1 parent 1b846cb commit aaffd71

File tree

3 files changed

+39
-1
lines changed

3 files changed

+39
-1
lines changed

src/ModelContextProtocol.AspNetCore/StatefulSessionManager.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ internal sealed partial class StatefulSessionManager(
1818

1919
private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider;
2020
private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout;
21-
private readonly long _idleTimeoutTicks = httpServerTransportOptions.Value.IdleTimeout.Ticks;
21+
private readonly long _idleTimeoutTicks = GetIdleTimeoutInTimestampTicks(httpServerTransportOptions.Value.IdleTimeout, httpServerTransportOptions.Value.TimeProvider);
2222
private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount;
2323

2424
private readonly object _idlePruningLock = new();
@@ -229,6 +229,15 @@ private async Task DisposeSessionAsync(StreamableHttpSession session)
229229
}
230230
}
231231

232+
private static long GetIdleTimeoutInTimestampTicks(TimeSpan idleTimeout, TimeProvider timeProvider)
233+
{
234+
// Convert TimeSpan.Ticks (100-nanosecond intervals) to timestamp ticks based on TimeProvider.TimestampFrequency.
235+
// TimeSpan.Ticks uses a fixed frequency of 10,000,000 ticks per second (100ns intervals).
236+
// TimeProvider.GetTimestamp() returns ticks based on TimeProvider.TimestampFrequency, which varies by platform
237+
// (e.g., ~1,000,000,000 on macOS using nanoseconds, ~10,000,000 on Windows using 100ns intervals).
238+
return (long)(idleTimeout.Ticks * timeProvider.TimestampFrequency / (double)TimeSpan.TicksPerSecond);
239+
}
240+
232241
[LoggerMessage(Level = LogLevel.Information, Message = "IdleTimeout of {IdleTimeout} exceeded. Closing idle session {SessionId}.")]
233242
private partial void LogIdleSessionTimeout(string sessionId, TimeSpan idleTimeout);
234243

src/ModelContextProtocol.AspNetCore/StreamableHttpSession.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ public async ValueTask<IAsyncDisposable> AcquireReferenceAsync(CancellationToken
5858
{
5959
sessionManager.DecrementIdleSessionCount();
6060
}
61+
// Update LastActivityTicks when acquiring reference in Started state to prevent timeout during active usage
62+
LastActivityTicks = sessionManager.TimeProvider.GetTimestamp();
6163
break;
6264
case SessionState.Disposed:
6365
throw new ObjectDisposedException(nameof(StreamableHttpSession));

tests/ModelContextProtocol.AspNetCore.Tests/StreamableHttpServerConformanceTests.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,33 @@ public async Task IdleSessionsPastMaxIdleSessionCount_ArePruned_LongestIdleFirst
521521
Assert.StartsWith("MaxIdleSessionCount of 2 exceeded. Closing idle session", idleLimitLogMessage.Message);
522522
}
523523

524+
[Fact]
525+
public async Task ActiveSession_WithPeriodicRequests_DoesNotTimeout()
526+
{
527+
var fakeTimeProvider = new FakeTimeProvider();
528+
Builder.Services.AddMcpServer().WithHttpTransport(options =>
529+
{
530+
options.IdleTimeout = TimeSpan.FromHours(2);
531+
options.TimeProvider = fakeTimeProvider;
532+
});
533+
534+
await StartAsync();
535+
await CallInitializeAndValidateAsync();
536+
537+
// Simulate multiple POST requests over a period longer than IdleTimeout
538+
// Each request should update LastActivityTicks, preventing timeout
539+
for (int i = 0; i < 5; i++)
540+
{
541+
// Advance time by 1 hour between requests
542+
fakeTimeProvider.Advance(TimeSpan.FromHours(1));
543+
await CallEchoAndValidateAsync();
544+
}
545+
546+
// Total time elapsed: 5 hours (> 2 hour IdleTimeout)
547+
// But session should still be alive because of periodic activity
548+
await CallEchoAndValidateAsync();
549+
}
550+
524551
[Fact]
525552
public async Task McpServer_UsedOutOfScope_CanSendNotifications()
526553
{

0 commit comments

Comments
 (0)