Skip to content

fix(csharp): a sliced task wait still runs a queued task inline where its scheduler allows - #11

Merged
IvanMurzak merged 3 commits into
mainfrom
fix/csharp-inline-queued-task
Oct 30, 2026
Merged

IvanMurzak merged 3 commits into
mainfrom
fix/csharp-inline-queued-task

Conversation

@IvanMurzak

Copy link
Copy Markdown
Owner

Regression (affe6ef)

This was measured live in Godot, with the main-thread GodotSynchronizationContext and a 5 s budget:
Task.Factory.StartNew(() => 13, CancellationToken.None, TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()).Result
returned 13 on 2556a2f (the task ran inline) and returns Timeout after the budget on affe6ef.

Cause: under a budget, a wait with no timeout on a task that is still queued became 50 ms slices of Wait(ms). A wait with a timeout never runs the task inline, so a task queued to the blocked thread's own context could never run.

Fix

  • When a task wait has no timeout and no cancelable token (Wait, WaitAll, .Result, GetAwaiter().GetResult(), all in the guard stand-ins), it first offers a still-queued task to its scheduler to run inline. It uses the framework's own path, Task.WrappedTryRunInline, found by reflection.
  • The scheduler decides whether the waiting thread may run the task. A declined task, a task with no body (an async method's promise), a wait on a thread-pool thread, or a runtime without that method all fall back to the sliced wait.
  • All affe6ef protections stay:
    • async F().Result on a main-thread context still returns Timeout promptly;
    • join, wait and sleep slicing is unchanged;
    • a budget overrun is still Timeout.
  • Spec §3.6 is updated.

Tests

These run on a single-threaded test SynchronizationContext (posts are queued until the thread is free):

  • Regression case (Result, GetResult, Wait, WaitAll, Task.Start, and WaitAll of a pool task plus a context task) returns 13. Without the fix it fails: Timeout after 5 s, verified locally.
  • Hang cases still stop at the budget:
    • async methods via Result, GetResult, Wait and WaitAll;
    • a context task waited on from the pool;
    • an inlined endless body.
  • Exceptions: an inlined body that throws keeps its exception and is not reported as Timeout.
  • Local runs: the full suite passes with Roslyn 3.8.0 and 5.9.0 on net8 and net10 (326 tests each), and with 4.8.0 on net48 (313). After the review fixes I re-ran GuardTests on 3.8.0 net8/net10 and 4.8.0 net48.

Stages: simplify: and code review: commits, the review done by a report-only reviewer agent. No version change, no release, no workflow changes. Unity-MCP re-copies dotnet/CodeExec/ExecutionGuard.cs.

🤖 Generated with Claude Code

IvanMurzak and others added 3 commits October 29, 2026 22:27
… its scheduler allows

Regression in affe6ef, measured live in Godot (main-thread
GodotSynchronizationContext, 5 s budget):
Task.Factory.StartNew(() => 13, CancellationToken.None,
TaskCreationOptions.None, TaskScheduler.FromCurrentSynchronizationContext()).Result
returned 13 on 2556a2f and Timeout after the budget on affe6ef: under a
budget, a wait without bound for a still-queued task no longer ran it inline
on the waiting thread, so a task queued to the blocked thread's own context
could never run.

The guard's task waits (Wait / WaitAll / Result / GetAwaiter().GetResult())
without bound and without a cancelable token now first offer a still-queued
task to its scheduler to run inline (Task.WrappedTryRunInline, the
framework's own path, found by reflection; absent -> every wait is sliced).
The scheduler decides whether this thread may run it; a declined task or one
with no body (an async method's promise) is waited for in slices as before,
so `async F().Result` on a main-thread context still stops at the budget.

Tests on a single-threaded test SynchronizationContext: the regression case
(Result, GetResult, Wait, WaitAll, Task.Start) returns 13 (fails without the
fix: Timeout after 5 s); async-method waits, the context's task waited on
from the pool, and an inlined endless body still stop at the budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…not fail, spec forms

From a report-only reviewer agent (high):
- No inline attempt on a thread-pool thread: the pool's scheduler could hand
  the snippet's thread any task from its local queue (not necessarily the
  snippet's); other pool threads run those anyway. Godot/Unity main threads
  are unaffected.
- The WrappedTryRunInline lookup cannot throw in the guard's type
  initializer (that would break every guarded snippet).
- WaitAll offers its tasks in the framework's order (last to first).
- Spec: names the forms that inline (not WaitAny), the pool-thread and
  missing-method cases, and that a non-snippet body run inline is stopped
  only when it returns.
- Tests: a cold-compile-safe time bound, an inlined body that throws keeps
  its exception (not Timeout), WaitAll of a pool task plus a context task.

Not taken: retrying the inline attempt between slices (the framework does
not either).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@IvanMurzak
IvanMurzak merged commit 54d527b into main Oct 30, 2026
13 checks passed
@IvanMurzak IvanMurzak mentioned this pull request Oct 30, 2026
IvanMurzak added a commit that referenced this pull request Oct 30, 2026
* chore: release 0.4.0 (execution guards, sliced waits)

Version 0.3.1 -> 0.4.0 in dotnet/Directory.Build.props, the README snippet and the
Python module; the contract's Unreleased changelog entry becomes v0.4.0 (title v0.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* simplify: no changes

Report-only review: the bump covers every hard-coded version; the blank line before v0.3.1 is pre-existing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* code review: no findings

Report-only reviewer (high): every version reference is 0.4.0, the v0.4.0 changelog matches #7-#11.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant