From 352aaa525b5e7ae5cc0e3411593a56a7cb54a83f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Mon, 28 Sep 2026 13:02:47 +0200 Subject: [PATCH] fix(worker): DeleteTask aus der UI schlug immer mit generischem Serverfehler fehl Der CancellationToken-Parameter auf der nicht-streamenden Hub-Methode liess SignalR jede Invocation vor dem Methodenrumpf abbrechen. Die Tests riefen den Hub direkt auf und sahen das nicht; neuer Reflection-Guard verbietet CancellationToken auf allen nicht-streamenden Hub-Methoden. --- src/ClaudeDo.Worker/Hub/WorkerHub.cs | 5 +++- .../Hub/DeleteTaskHubTests.cs | 24 +++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/ClaudeDo.Worker/Hub/WorkerHub.cs b/src/ClaudeDo.Worker/Hub/WorkerHub.cs index 17667cc4..05b7684b 100644 --- a/src/ClaudeDo.Worker/Hub/WorkerHub.cs +++ b/src/ClaudeDo.Worker/Hub/WorkerHub.cs @@ -277,8 +277,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub // TryAdvanceParentAsync side effect — a direct-repo delete from the details pane // used to skip it, permanently wedging a WaitingForChildren parent whose last // child was deleted from there. - public async Task DeleteTask(string taskId, CancellationToken cancellationToken) + // No CancellationToken parameter: SignalR can't bind one on a non-streaming method and fails + // every invocation with a generic server error before the body runs. + public async Task DeleteTask(string taskId) { + var cancellationToken = CancellationToken.None; await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); var repo = new TaskRepository(ctx); var task = await repo.GetByIdAsync(taskId, cancellationToken) diff --git a/tests/ClaudeDo.Worker.Tests/Hub/DeleteTaskHubTests.cs b/tests/ClaudeDo.Worker.Tests/Hub/DeleteTaskHubTests.cs index de3d4cbd..3806895e 100644 --- a/tests/ClaudeDo.Worker.Tests/Hub/DeleteTaskHubTests.cs +++ b/tests/ClaudeDo.Worker.Tests/Hub/DeleteTaskHubTests.cs @@ -66,7 +66,7 @@ public sealed class DeleteTaskHubTests : IDisposable var (parentId, childId) = await SeedParentWithChildAsync(TaskStatus.Idle); var hub = CreateHub(); - await hub.DeleteTask(childId, default); + await hub.DeleteTask(childId); await using var ctx = _db.CreateContext(); Assert.Null(await ctx.Tasks.FindAsync(childId)); @@ -80,7 +80,7 @@ public sealed class DeleteTaskHubTests : IDisposable var (_, childId) = await SeedParentWithChildAsync(TaskStatus.Running); var hub = CreateHub(); - await Assert.ThrowsAsync(() => hub.DeleteTask(childId, default)); + await Assert.ThrowsAsync(() => hub.DeleteTask(childId)); await using var ctx = _db.CreateContext(); Assert.NotNull(await ctx.Tasks.FindAsync(childId)); @@ -92,7 +92,7 @@ public sealed class DeleteTaskHubTests : IDisposable var (parentId, _) = await SeedParentWithChildAsync(TaskStatus.Idle); var hub = CreateHub(); - var ex = await Assert.ThrowsAsync(() => hub.DeleteTask(parentId, default)); + var ex = await Assert.ThrowsAsync(() => hub.DeleteTask(parentId)); Assert.Contains("child tasks", ex.Message, StringComparison.OrdinalIgnoreCase); } @@ -102,6 +102,22 @@ public sealed class DeleteTaskHubTests : IDisposable { var hub = CreateHub(); - await Assert.ThrowsAsync(() => hub.DeleteTask("does-not-exist", default)); + await Assert.ThrowsAsync(() => hub.DeleteTask("does-not-exist")); + } + + /// A CancellationToken on a non-streaming hub method breaks SignalR binding (every call fails with a generic server error); direct-call tests can't see that. + [Fact] + public void NoNonStreamingHubMethod_TakesCancellationToken() + { + var offenders = typeof(WorkerHub) + .GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly) + .Where(m => !m.IsSpecialName) + .Where(m => !(m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() is var g + && (g == typeof(IAsyncEnumerable<>) || g == typeof(System.Threading.Channels.ChannelReader<>)))) + .Where(m => m.GetParameters().Any(p => p.ParameterType == typeof(CancellationToken))) + .Select(m => m.Name) + .ToList(); + + Assert.Empty(offenders); } }