Skip to content

Commit e7b450e

Browse files
Reduce absolute time dependencies in tests (#1649)
1 parent 6c323e7 commit e7b450e

2 files changed

Lines changed: 123 additions & 80 deletions

File tree

tests/ModelContextProtocol.Tests/Server/McpServerTaskTests.cs

Lines changed: 117 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using ModelContextProtocol.Protocol;
22
using ModelContextProtocol.Server;
33
using Microsoft.Extensions.DependencyInjection;
4-
using System.Collections.Concurrent;
54
using System.Runtime.InteropServices;
65
using System.Text.Json;
76
using System.Text.Json.Nodes;
@@ -179,17 +178,25 @@ public async Task CallToolAsync_AsyncTool_FailedTask_ThrowsMcpException()
179178
await using var client = await CreateMcpClientForServer();
180179
var ct = TestContext.Current.CancellationToken;
181180

182-
_ = Task.Run(async () =>
181+
var failedTask = new TaskCompletionSource<bool>();
182+
183+
// Run failure task once the task from the tool call is created
184+
_taskStore.OnTaskCreated += taskId =>
183185
{
184-
await Task.Delay(100, ct);
185-
var taskId = _taskStore.GetAllTaskIds().Single();
186-
_taskStore.FailTask(taskId, JsonElement.Parse("""{"code":-32000,"message":"something went wrong"}"""));
187-
}, ct);
186+
_ = Task.Run(async () =>
187+
{
188+
await Task.Delay(100, ct);
189+
_taskStore.FailTask(taskId, JsonElement.Parse("""{"code":-32000,"message":"something went wrong"}"""));
190+
failedTask.SetResult(true);
191+
}, ct);
192+
};
188193

189194
await Assert.ThrowsAsync<McpException>(async () =>
190195
await client.CallToolAsync(
191196
new CallToolRequestParams { Name = "async-tool" },
192197
ct));
198+
199+
Assert.True(await failedTask.Task);
193200
}
194201

195202
[Fact]
@@ -198,17 +205,25 @@ public async Task CallToolAsync_AsyncTool_CancelledTask_ThrowsOperationCancelled
198205
await using var client = await CreateMcpClientForServer();
199206
var ct = TestContext.Current.CancellationToken;
200207

201-
_ = Task.Run(async () =>
208+
var cancelledTask = new TaskCompletionSource<bool>();
209+
210+
// Run cancellation task once the task from the tool call is created
211+
_taskStore.OnTaskCreated += taskId =>
202212
{
203-
await Task.Delay(100, ct);
204-
var taskId = _taskStore.GetAllTaskIds().Single();
205-
_taskStore.CancelTask(taskId);
206-
}, ct);
213+
Task.Run(async () =>
214+
{
215+
await Task.Delay(100, ct);
216+
_taskStore.CancelTask(taskId);
217+
cancelledTask.SetResult(true);
218+
}, ct);
219+
};
207220

208221
await Assert.ThrowsAsync<OperationCanceledException>(async () =>
209222
await client.CallToolAsync(
210223
new CallToolRequestParams { Name = "async-tool" },
211224
ct));
225+
226+
Assert.True(await cancelledTask.Task);
212227
}
213228

214229
[Fact]
@@ -538,106 +553,135 @@ public async Task CallToolHandler_CanBeSetToNull_ThenOtherCanBeSet()
538553
/// </summary>
539554
private sealed class InMemoryTaskStore
540555
{
541-
private readonly ConcurrentDictionary<string, TaskEntry> _tasks = new();
556+
private readonly Dictionary<string, TaskEntry> _tasks = new();
557+
558+
internal Action<string>? OnTaskCreated;
542559

543560
public string CreateTask(McpTaskStatus initialStatus = McpTaskStatus.Working)
544561
{
545562
var taskId = Guid.NewGuid().ToString("N");
546-
_tasks[taskId] = new TaskEntry
563+
lock (_tasks)
547564
{
548-
Status = initialStatus,
549-
CreatedAt = DateTimeOffset.UtcNow,
550-
LastUpdatedAt = DateTimeOffset.UtcNow,
551-
};
565+
_tasks[taskId] = new TaskEntry
566+
{
567+
Status = initialStatus,
568+
CreatedAt = DateTimeOffset.UtcNow,
569+
LastUpdatedAt = DateTimeOffset.UtcNow,
570+
};
571+
}
572+
573+
OnTaskCreated?.Invoke(taskId);
574+
552575
return taskId;
553576
}
554577

555-
public IEnumerable<string> GetAllTaskIds() => _tasks.Keys;
556-
557-
public GetTaskResult GetTask(string taskId)
578+
public IEnumerable<string> GetAllTaskIds()
558579
{
559-
if (!_tasks.TryGetValue(taskId, out var entry))
580+
lock (_tasks)
560581
{
561-
throw new McpException($"Unknown task: '{taskId}'");
582+
return _tasks.Keys.ToArray();
562583
}
584+
}
563585

564-
return entry.Status switch
586+
public GetTaskResult GetTask(string taskId)
587+
{
588+
lock (_tasks)
565589
{
566-
McpTaskStatus.Working => new WorkingTaskResult
567-
{
568-
TaskId = taskId,
569-
CreatedAt = entry.CreatedAt,
570-
LastUpdatedAt = entry.LastUpdatedAt,
571-
PollIntervalMs = 50,
572-
},
573-
McpTaskStatus.Completed => new CompletedTaskResult
590+
if (!_tasks.TryGetValue(taskId, out var entry))
574591
{
575-
TaskId = taskId,
576-
CreatedAt = entry.CreatedAt,
577-
LastUpdatedAt = entry.LastUpdatedAt,
578-
Result = JsonSerializer.SerializeToElement(entry.Result, McpJsonUtilities.DefaultOptions),
579-
},
580-
McpTaskStatus.Failed => new FailedTaskResult
581-
{
582-
TaskId = taskId,
583-
CreatedAt = entry.CreatedAt,
584-
LastUpdatedAt = entry.LastUpdatedAt,
585-
Error = entry.Error!.Value,
586-
},
587-
McpTaskStatus.Cancelled => new CancelledTaskResult
588-
{
589-
TaskId = taskId,
590-
CreatedAt = entry.CreatedAt,
591-
LastUpdatedAt = entry.LastUpdatedAt,
592-
},
593-
McpTaskStatus.InputRequired => new InputRequiredTaskResult
592+
throw new McpException($"Unknown task: '{taskId}'");
593+
}
594+
595+
return entry.Status switch
594596
{
595-
TaskId = taskId,
596-
CreatedAt = entry.CreatedAt,
597-
LastUpdatedAt = entry.LastUpdatedAt,
598-
InputRequests = entry.InputRequests ?? new Dictionary<string, InputRequest>(),
599-
},
600-
_ => throw new InvalidOperationException($"Unexpected status: {entry.Status}")
601-
};
597+
McpTaskStatus.Working => new WorkingTaskResult
598+
{
599+
TaskId = taskId,
600+
CreatedAt = entry.CreatedAt,
601+
LastUpdatedAt = entry.LastUpdatedAt,
602+
PollIntervalMs = 50,
603+
},
604+
McpTaskStatus.Completed => new CompletedTaskResult
605+
{
606+
TaskId = taskId,
607+
CreatedAt = entry.CreatedAt,
608+
LastUpdatedAt = entry.LastUpdatedAt,
609+
Result = JsonSerializer.SerializeToElement(entry.Result, McpJsonUtilities.DefaultOptions),
610+
},
611+
McpTaskStatus.Failed => new FailedTaskResult
612+
{
613+
TaskId = taskId,
614+
CreatedAt = entry.CreatedAt,
615+
LastUpdatedAt = entry.LastUpdatedAt,
616+
Error = entry.Error!.Value,
617+
},
618+
McpTaskStatus.Cancelled => new CancelledTaskResult
619+
{
620+
TaskId = taskId,
621+
CreatedAt = entry.CreatedAt,
622+
LastUpdatedAt = entry.LastUpdatedAt,
623+
},
624+
McpTaskStatus.InputRequired => new InputRequiredTaskResult
625+
{
626+
TaskId = taskId,
627+
CreatedAt = entry.CreatedAt,
628+
LastUpdatedAt = entry.LastUpdatedAt,
629+
InputRequests = entry.InputRequests ?? new Dictionary<string, InputRequest>(),
630+
},
631+
_ => throw new InvalidOperationException($"Unexpected status: {entry.Status}")
632+
};
633+
}
602634
}
603635

604636
public void CompleteTask(string taskId, CallToolResult result)
605637
{
606-
if (_tasks.TryGetValue(taskId, out var entry))
638+
lock (_tasks)
607639
{
608-
entry.Status = McpTaskStatus.Completed;
609-
entry.Result = result;
610-
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
640+
if (_tasks.TryGetValue(taskId, out var entry))
641+
{
642+
entry.Result = result;
643+
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
644+
entry.Status = McpTaskStatus.Completed;
645+
}
611646
}
612647
}
613648

614649
public void FailTask(string taskId, JsonElement error)
615650
{
616-
if (_tasks.TryGetValue(taskId, out var entry))
651+
lock (_tasks)
617652
{
618-
entry.Status = McpTaskStatus.Failed;
619-
entry.Error = error;
620-
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
653+
if (_tasks.TryGetValue(taskId, out var entry))
654+
{
655+
entry.Error = error;
656+
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
657+
entry.Status = McpTaskStatus.Failed;
658+
}
621659
}
622660
}
623661

624662
public void CancelTask(string taskId)
625663
{
626-
if (_tasks.TryGetValue(taskId, out var entry))
664+
lock (_tasks)
627665
{
628-
entry.Status = McpTaskStatus.Cancelled;
629-
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
666+
if (_tasks.TryGetValue(taskId, out var entry))
667+
{
668+
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
669+
entry.Status = McpTaskStatus.Cancelled;
670+
}
630671
}
631672
}
632673

633674
public void ProvideInput(string taskId, IDictionary<string, InputResponse> inputResponses)
634675
{
635-
if (_tasks.TryGetValue(taskId, out var entry))
676+
lock (_tasks)
636677
{
637-
entry.InputResponses = inputResponses;
638-
// Transition back to working after receiving input
639-
entry.Status = McpTaskStatus.Working;
640-
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
678+
if (_tasks.TryGetValue(taskId, out var entry))
679+
{
680+
entry.InputResponses = inputResponses;
681+
entry.LastUpdatedAt = DateTimeOffset.UtcNow;
682+
// Transition back to working after receiving input
683+
entry.Status = McpTaskStatus.Working;
684+
}
641685
}
642686
}
643687

tests/ModelContextProtocol.Tests/Server/TaskPollStuckDetectorTests.cs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ namespace ModelContextProtocol.Tests.Server;
1515
/// </summary>
1616
public class TaskPollStuckDetectorTests : ClientServerTestBase
1717
{
18+
private int _pollCount = 0;
19+
1820
public TaskPollStuckDetectorTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper)
1921
{
2022
#if !NET
@@ -48,6 +50,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer
4850
// misbehaving-server condition the stuck-detector exists to break out of.
4951
options.Handlers.GetTaskHandler = (context, cancellationToken) =>
5052
{
53+
Interlocked.Increment(ref _pollCount);
54+
5155
return new ValueTask<GetTaskResult>(new InputRequiredTaskResult
5256
{
5357
TaskId = context.Params!.TaskId,
@@ -77,19 +81,13 @@ public async Task CallToolAsync_TaskStuckInInputRequired_WithoutNewRequests_Thro
7781
await using var client = await CreateMcpClientForServer();
7882
var ct = TestContext.Current.CancellationToken;
7983

80-
var sw = System.Diagnostics.Stopwatch.StartNew();
81-
8284
var ex = await Assert.ThrowsAsync<McpException>(async () =>
8385
await client.CallToolAsync(new CallToolRequestParams { Name = "any-tool" }, ct));
8486

85-
sw.Stop();
86-
8787
Assert.Contains(McpTaskStatus.InputRequired.ToString(), ex.Message);
8888
Assert.Contains("consecutive polls", ex.Message);
8989

90-
// 60 polls × 5ms ≈ 300ms; allow generous slack for CI.
91-
Assert.True(sw.Elapsed < TimeSpan.FromSeconds(10),
92-
$"Stuck-detector should give up promptly but took {sw.Elapsed}.");
90+
Assert.Equal(60, _pollCount);
9391
}
9492

9593
[Fact]
@@ -111,6 +109,7 @@ public async Task CallToolAsync_StuckDetector_HonorsConfiguredThreshold()
111109
// The message embeds the configured threshold, which is the strongest signal that the
112110
// option value (not the 60-default constant) is what governed the loop.
113111
Assert.Contains($"{CustomThreshold} consecutive polls", ex.Message);
112+
Assert.Equal(CustomThreshold, _pollCount);
114113
}
115114

116115
[Theory]

0 commit comments

Comments
 (0)