fix(threading): widen RunWithTimeout<T1,TResult> retry handler to catch TaskCanceledException (#285) - #715
Merged
drmoisan merged 7 commits intoSep 1, 2026
Conversation
Seeds the active bug feature folder for issue 285 and records the research artifact for the RunWithTimeout<T1, TResult> exception-type mismatch in UtilitiesCS/Threading/TimeOutTask.cs. Research reconciles two errors carried in the issue body: the line citations were stale by one, and the claim that the exception propagates unhandled is incorrect. The general handler catches and logs it, rethrowing only under strict, so the shipped behaviour is a silent default(TResult) with no retry. Preparation only. No production or test code is changed here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Replaces the auto-scaffolded draft. Corrects the stale line citations (all were off by one after #nullable enable became line 1), replaces the inaccurate unhandled-propagation description with the true strict-dependent behaviour at the general handler, and supersedes the three seeded fix bullets. Records that the repair is additive rather than a catch-clause swap. Both existing tests that simulate the timeout by throwing directly pass strict true, so a bare replacement would route them to the general handler and rethrow. The widened filter leaves both passing unmodified. Records the determinism seam as the existing timeout-source factory parameter. Both projects target v4.8.1, so the TimeProvider-based CancellationTokenSource constructor is unavailable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
Adds the atomic implementation plan for issue 285, the TimeOutTask RunWithTimeout exception-type mismatch. 5 phases, 51 tasks, all 12 spec acceptance criteria mapped. Cleared the MCP plan validator and two atomic-executor preflight rounds (PREFLIGHT: ALL CLEAR, CONVERGENCE: NO FURTHER ROUNDS EXPECTED). The plan corrects three citations in spec.md that would have blocked execution. Chiefly, the prescribed catch (Exception ex) when (...) does not compile: TimeOutTask.cs imports Microsoft.Office.Interop.Outlook, which declares its own Exception type, so the bare name is CS0104. The plan uses catch (System.Exception e) when (...) instead, matching the ten existing handlers in that file. Named plan.<timestamp>.md rather than plan.md because the enforce-feature-folder-order hook is work-mode-blind and demands user-story.md, which full-bug mode requires to be absent. The timestamped name is the established repository convention. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
The feature-folder-order hook unconditionally requires user-story.md before any plan.md write and never reads the work-mode marker, so it blocks every full-bug item. Records the resolution (the repo's own plan.<timestamp>.md convention) and why creating the stub instead would corrupt work-mode integrity for downstream agents. Also records a git check-ignore false negative: a directory-only glob does not match a directory that does not exist yet, and grepping .gitignore for a literal name misses a glob. Together they produced a blocking preflight finding demanding that three footprint gates be weakened for a path that is in fact ignored. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…sue #285 The private Func<T1, TResult> implementation of TimeOutTask.RunWithTimeout armed its timeout with a CancellationTokenSource and awaited Task.Run(..., combinedToken.Token), but guarded its retry ladder with catch (TimeoutException). Timer-driven cancellation of that Task.Run surfaces as TaskCanceledException, which shares no base with TimeoutException other than Exception, so the retry ladder was unreachable for the exact case it exists to serve. The exception instead reached the general handler, where it was logged as a generic error and either rethrown (strict: true) or swallowed into a default return (strict: false). The handler is now an additive filter: catch (System.Exception e) when (e is TaskCanceledException || e is TimeoutException) TimeoutException is retained because a wrapped delegate may raise it directly and two existing tests depend on that retry. System.Exception is spelled out because Microsoft.Office.Interop.Outlook, imported at line 9 of the file, also declares a type named Exception, so a bare Exception is CS0104-ambiguous. A trailing optional parameter Func<int, CancellationTokenSource>? timeoutSourceFactory = null was added to the public wrapper and the private implementation and threaded through the retry recursion, mirroring the seam already present on the Func<TResult> sibling. It makes the defect testable with no wall-clock wait and is behaviour-preserving when defaulted. BEHAVIOURAL CONSEQUENCE AT THE TWO PRODUCTION CALL SITES This fix makes currently-dead retry logic live. That is the intended outcome, not a regression, but it is a real behavioural change at both call sites, each of which passes maxAttempts: 3 and strict: false: - UtilitiesCS/OutlookObjects/Conversation/ConversationHelper.Formatting.cs line 80 (timeoutMs: 1000, maxAttempts: 3): a genuine timeout on the COM call conversation.GetTable() previously returned null after roughly one second. It now retries up to three more times, so worst-case QuickFiler conversation-dataframe latency on a repeatedly stalled conversation table rises from roughly one second to roughly four seconds, while the failure rate falls. - UtilitiesCS/OneDriveHelpers/OneDriveDownloader.cs line 139: the same retry shape now applies to the file-writer factory. Regression test RunWithTimeout_FuncT1TResult_ShouldRetryAfterTaskCanceledException added to UtilitiesCS.Test/Threading/TimeOutTask_OverloadCoverageTests.cs. It failed before the handler change with an escaping TaskCanceledException and passes after. Verification: csharpier check clean across 1565 files; analyzer build 0 errors with 5 warnings against a baseline of 5; nullable build 0 errors; UtilitiesCS.Test 4771 passed 0 failed (baseline 4770 passed 0 failed); QuickFiler.Test 1272 passed 0 failed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
The approved plan declares the P4-T14 footprint artifact and the P4-T13/P4-T14 plan checkboxes as a known residual written after the P4-T14 porcelain invocation, to be committed by the orchestrator. Also carries one atomic-executor agent-memory update. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
…laim Adds the three feature-review artifacts (policy-audit, code-review, feature-audit) at 2026-09-01T09-10. The review returned zero blocking findings and confirmed all twelve acceptance criteria independently. Also corrects an internal contradiction in spec.md. The Risks section claimed a slow conversation.GetTable() COM call times out after about one second. It does not. The token given to Task.Run is observed only before the work item is dequeued, and the wrapped delegate is a Func<T1, TResult> with no token parameter, so a call that has already begun is never cancelled and raises nothing. The spec Test Design section already stated this correctly, so the two sections disagreed. The retry ladder this change makes live is reached on thread-pool saturation, not on a slow COM call. Covering a running delegate is a separate defect already recorded under Non-Goals. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATYLDoRLKXS5sgAzegW7ZL
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
TimeOutTask.RunWithTimeout<T1, TResult>armed its timeout with aCancellationTokenSourceand awaitedTask.Run(..., combinedToken.Token), but guarded its retry ladder withcatch (TimeoutException). Timer-driven cancellation of thatTask.Runsurfaces asTaskCanceledException, which shares no base withTimeoutExceptionother thanException, so the retry ladder was unreachable for the exact case it exists to serve. The exception instead reached the general handler, where it was logged as a generic error and either rethrown (strict: true) or swallowed into a default return (strict: false).Every one of the four sibling
RunWithTimeoutoverloads already catchesTaskCanceledExceptionfor the identical pattern. This overload was the outlier.Closes #285.
The change
Two source files, 24 added and 6 removed lines in production, 40 added lines in test.
UtilitiesCS/Threading/TimeOutTask.cs— the handler becomes an additive filter:TimeoutExceptionis retained because a wrapped delegate may raise it directly and two existing tests depend on that retry.System.Exceptionis spelled out rather than written bare becauseMicrosoft.Office.Interop.Outlook, imported at line 9 of the file, also declares a type namedException, so a bareExceptionisCS0104-ambiguous. Every one of the ten general handlers already in this file is written the same way.The filter deliberately does not catch
OperationCanceledException. The combined token is never handed to the wrapped delegate, so a bareOperationCanceledExceptioncan only originate from an unrelated caller token and must propagate rather than be retried. Caller-token cancellation already arrives asTaskCanceledExceptionand is re-thrown by the first statement in the clause body,token.ThrowIfCancellationRequested().A trailing optional parameter
Func<int, CancellationTokenSource>? timeoutSourceFactory = nullwas added to the public wrapper and the private implementation and threaded through the retry recursion, mirroring the seam already present on theFunc<TResult>sibling. It makes the defect testable with no wall-clock wait and is behaviour-preserving when defaulted.UtilitiesCS.Test/Threading/TimeOutTask_OverloadCoverageTests.cs— one appended test,RunWithTimeout_FuncT1TResult_ShouldRetryAfterTaskCanceledException. It injects an already-cancelled source for attempt 0 and a live source for attempt 1, so the outcome is fixed before any scheduling decision is taken. NoTask.Delay,Thread.Sleep, orThread.SpinWait; it runs in 55 ms.Behavioural consequence, stated precisely
This makes currently-dead retry logic live. That is the intended outcome, but it is a real behavioural change at the two production call sites, both of which pass
maxAttempts: 3andstrict: false:UtilitiesCS/OutlookObjects/Conversation/ConversationHelper.Formatting.csline 80 (timeoutMs: 1000)UtilitiesCS/OneDriveHelpers/OneDriveDownloader.csline 139Worst-case latency in the affected case rises from roughly one second to roughly four, while the failure rate falls.
The scope of that timeout is narrower than it first appears, and this PR corrects a claim in
spec.mdthat overstated it. The token given toTask.Run(() => function(arg1), combinedToken.Token)is observed only before the work item is dequeued, andfunctionis aFunc<T1, TResult>with no token parameter, so it cannot observe cancellation once running. Aconversation.GetTable()call that has already begun is therefore never cancelled by the timeout and raises no exception at all. The retry ladder this change makes live is reached when the work item is still queued at the deadline — thread-pool saturation — not when the COM call itself is slow. Making the timeout cover a running delegate is a separate defect, already recorded under the spec's Non-Goals.Verification
Fail-before / pass-after evidence is a real failing run, not an exception dossier.
Failed: 1, escapingSystem.Threading.Tasks.TaskCanceledExceptionPassed: 1,Failed: 0dotnet tool run csharpier check ./t:Rebuild,EnableNETAnalyzers,EnforceCodeStyleInBuild)/t:Rebuild,TreatWarningsAsErrors, noNullable=enable)UtilitiesCS.TestQuickFiler.TestBoth Phase 0 baseline-failure sets were empty, so the zero-failure result is literal rather than the product of a subtraction. The changed state machine
UtilitiesCS.TimeOutTask.<RunWithTimeout>d__6<T1, TResult>readsline-rate="1" branch-rate="1"post-change atcomplexity="10", againstcomplexity="4"at baseline, so every branch the change introduced is executed. The changed assemblyUtilitiesCSreads 89.21% line and 83.04% branch.Both at-risk pre-existing tests were verified unmodified by diff against the merge base:
TimeOutTask_AdditionalTests.csproduced no diff output at all, and the diff toTimeOutTask_OverloadCoverageTests.cscontains zero deletion lines.All 12 acceptance criteria in
spec.mdare checked, each against a named evidence artifact.Review outcome
feature-reviewproducedpolicy-audit,code-review, andfeature-auditartifacts at2026-09-01T09-10, all committed under the feature folder. Zero blocking findings, so no remediation cycle was run. One review finding was acted on in this PR: thespec.mdtimeout-scope correction described above.Non-blocking findings deferred to follow-up work, all on the same production file:
timeoutSourceFactoryparameter transfersCancellationTokenSourcedisposal ownership to the callee and this is not documented on the public API.TaskCanceledException.RunWithTimeoutoverloads.UtilitiesCS/Threading/TimeOutTask.csis 1011 lines against the 500-line ceiling. It was already 993 at the merge base; this change adds 18. Splitting it would require paths outside this change's declared scope boundary.Two repository-wide conditions were recorded and are not introduced by this change: the canonical
artifacts/csharp/coverage.xmlis absent, and raw merged repository-wide coverage reads 70.84% line against an 85% threshold. That denominator carries the test assembly, six third-party packages, and six first-party assemblies whose own test assemblies were not part of this run.Scope
git diff --name-onlyagainst the merge base lists onlyUtilitiesCS/Threading/TimeOutTask.cs,UtilitiesCS.Test/Threading/TimeOutTask_OverloadCoverageTests.cs, paths underdocs/features/active/2026-07-09-timeouttask-runwithtimeout-exception-type-mismatch-285/, and four.claude/agent-memory/files. No.csproj,.props, or.targetswas touched, and no generated coverage output was committed.🤖 Generated with Claude Code