Fix session timeout due to timestamp frequency mismatch and activity tracking (#1106)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> Co-authored-by: eiriktsarpalis <2813363+eiriktsarpalis@users.noreply.github.com>
This commit is contained in:
parent
1b846cb76c
commit
aaffd71c69
3 changed files with 39 additions and 1 deletions
|
|
@ -18,7 +18,7 @@ internal sealed partial class StatefulSessionManager(
|
||||||
|
|
||||||
private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider;
|
private readonly TimeProvider _timeProvider = httpServerTransportOptions.Value.TimeProvider;
|
||||||
private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout;
|
private readonly TimeSpan _idleTimeout = httpServerTransportOptions.Value.IdleTimeout;
|
||||||
private readonly long _idleTimeoutTicks = httpServerTransportOptions.Value.IdleTimeout.Ticks;
|
private readonly long _idleTimeoutTicks = GetIdleTimeoutInTimestampTicks(httpServerTransportOptions.Value.IdleTimeout, httpServerTransportOptions.Value.TimeProvider);
|
||||||
private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount;
|
private readonly int _maxIdleSessionCount = httpServerTransportOptions.Value.MaxIdleSessionCount;
|
||||||
|
|
||||||
private readonly object _idlePruningLock = new();
|
private readonly object _idlePruningLock = new();
|
||||||
|
|
@ -229,6 +229,15 @@ internal sealed partial class StatefulSessionManager(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static long GetIdleTimeoutInTimestampTicks(TimeSpan idleTimeout, TimeProvider timeProvider)
|
||||||
|
{
|
||||||
|
// Convert TimeSpan.Ticks (100-nanosecond intervals) to timestamp ticks based on TimeProvider.TimestampFrequency.
|
||||||
|
// TimeSpan.Ticks uses a fixed frequency of 10,000,000 ticks per second (100ns intervals).
|
||||||
|
// TimeProvider.GetTimestamp() returns ticks based on TimeProvider.TimestampFrequency, which varies by platform
|
||||||
|
// (e.g., ~1,000,000,000 on macOS using nanoseconds, ~10,000,000 on Windows using 100ns intervals).
|
||||||
|
return (long)(idleTimeout.Ticks * timeProvider.TimestampFrequency / (double)TimeSpan.TicksPerSecond);
|
||||||
|
}
|
||||||
|
|
||||||
[LoggerMessage(Level = LogLevel.Information, Message = "IdleTimeout of {IdleTimeout} exceeded. Closing idle session {SessionId}.")]
|
[LoggerMessage(Level = LogLevel.Information, Message = "IdleTimeout of {IdleTimeout} exceeded. Closing idle session {SessionId}.")]
|
||||||
private partial void LogIdleSessionTimeout(string sessionId, TimeSpan idleTimeout);
|
private partial void LogIdleSessionTimeout(string sessionId, TimeSpan idleTimeout);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,8 @@ internal sealed class StreamableHttpSession(
|
||||||
{
|
{
|
||||||
sessionManager.DecrementIdleSessionCount();
|
sessionManager.DecrementIdleSessionCount();
|
||||||
}
|
}
|
||||||
|
// Update LastActivityTicks when acquiring reference in Started state to prevent timeout during active usage
|
||||||
|
LastActivityTicks = sessionManager.TimeProvider.GetTimestamp();
|
||||||
break;
|
break;
|
||||||
case SessionState.Disposed:
|
case SessionState.Disposed:
|
||||||
throw new ObjectDisposedException(nameof(StreamableHttpSession));
|
throw new ObjectDisposedException(nameof(StreamableHttpSession));
|
||||||
|
|
|
||||||
|
|
@ -521,6 +521,33 @@ public class StreamableHttpServerConformanceTests(ITestOutputHelper outputHelper
|
||||||
Assert.StartsWith("MaxIdleSessionCount of 2 exceeded. Closing idle session", idleLimitLogMessage.Message);
|
Assert.StartsWith("MaxIdleSessionCount of 2 exceeded. Closing idle session", idleLimitLogMessage.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ActiveSession_WithPeriodicRequests_DoesNotTimeout()
|
||||||
|
{
|
||||||
|
var fakeTimeProvider = new FakeTimeProvider();
|
||||||
|
Builder.Services.AddMcpServer().WithHttpTransport(options =>
|
||||||
|
{
|
||||||
|
options.IdleTimeout = TimeSpan.FromHours(2);
|
||||||
|
options.TimeProvider = fakeTimeProvider;
|
||||||
|
});
|
||||||
|
|
||||||
|
await StartAsync();
|
||||||
|
await CallInitializeAndValidateAsync();
|
||||||
|
|
||||||
|
// Simulate multiple POST requests over a period longer than IdleTimeout
|
||||||
|
// Each request should update LastActivityTicks, preventing timeout
|
||||||
|
for (int i = 0; i < 5; i++)
|
||||||
|
{
|
||||||
|
// Advance time by 1 hour between requests
|
||||||
|
fakeTimeProvider.Advance(TimeSpan.FromHours(1));
|
||||||
|
await CallEchoAndValidateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total time elapsed: 5 hours (> 2 hour IdleTimeout)
|
||||||
|
// But session should still be alive because of periodic activity
|
||||||
|
await CallEchoAndValidateAsync();
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task McpServer_UsedOutOfScope_CanSendNotifications()
|
public async Task McpServer_UsedOutOfScope_CanSendNotifications()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue