Skip to content

fix: don't block waiting callers while IAsyncInitializer runs its synchronous part (thomhurst/TUnit#6904) - #1

Open
Sing303 wants to merge 7 commits into
mainfrom
claude/festive-hypatia-w79bcn
Open

Sing303 wants to merge 7 commits into
mainfrom
claude/festive-hypatia-w79bcn

Conversation

@Sing303

@Sing303 Sing303 commented Sep 28, 2026 •

Copy link
Copy Markdown
Owner

This fork PR was the staging copy of thomhurst#6906, which is where review happens now. The branch is the same, so both PRs show the same commits. See thomhurst#6906 for the current description, measurements and discussion.

Current state (92c54e2): ObjectInitializer follows the maintainer's suggested shape. One helper runs InitializeAsync and completes a plain TaskCompletionSource<bool>, and every caller waits on the published task. InitializeCoreAsync stays async, so an OperationCanceledException from the initializer still completes callers' tasks as Canceled, as on main. The tests are trimmed to the five review cases (9 test cases).

🤖 Generated with Claude Code

https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4

…chronous part

ObjectInitializer deduplicated InitializeAsync with Lazy<Task> in
ExecutionAndPublication mode, which runs the factory - InitializeAsync
itself - under a lock. Every other caller for the same shared object
blocked a thread-pool thread in Monitor.Enter until the synchronous part
of InitializeAsync finished, starving the pool when that part did
sync-over-async (e.g. Testcontainers' Docker probe) (thomhurst#6904).

Publish a TaskCompletionSource (RunContinuationsAsynchronously) before
any user code runs instead. The caller that publishes it runs
InitializeAsync inline, as before, with no lock held and awaits it
directly, so it takes no extra thread-pool hop; other callers await the
published task and resume on their own pool threads. InitializeAsync
still runs once per object, failures stay cached with the original
exception object (thomhurst#4715), and a caller's cancellation only stops that
caller waiting. Same pattern as ObjectLifecycleService.EnsureInitializedAsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4
…er tests

- Cancelling the caller that runs InitializeAsync only stops that caller:
  waiters still get the result, or the original exception object.
- Assert the precondition of the inline-continuation test (InitializeAsync
  itself completed on the completing thread) and cover both a cancellable
  and a non-cancellable wait.
- Bound every await with a timeout, release the blocking prefix in a
  finally, and cover a synchronously thrown OperationCanceledException.
- Publish the result after the try/catch and make the comments precise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4
The previous version awaited a helper async method that ran
InitializeAsync and published its outcome. BenchmarkDotNet showed that
extra async frame cost ~0.15-0.7 us and ~86 B per first initialization of
a per-test fixture whose InitializeAsync really awaits.

The caller that publishes the task now awaits InitializeAsync itself and
completes the TaskCompletionSource afterwards; only if it stops waiting
(cancellation) or the initialization fails does a helper publish the
initialization's own outcome, so one caller's cancellation is never
cached as the result. First initialization now costs the same as the
Lazy<Task> code and allocates less; repeat calls are unchanged.

Also: a null task from InitializeAsync fails every caller with one
InvalidOperationException naming the type; the published failure copy is
marked observed; publishing doesn't wait for the initializing caller's
context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4
If cancellation regressed, these awaits would hang the suite instead of
failing, because the fixture is only released after them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4
@Sing303
Sing303 deployed to Pull Requests September 28, 2026 11:14 — with GitHub Actions Active
@Sing303
Sing303 deployed to Pull Requests September 28, 2026 11:14 — with GitHub Actions Active
@Sing303
Sing303 deployed to Pull Requests September 28, 2026 11:14 — with GitHub Actions Active
Following the review on thomhurst#6906: a helper runs InitializeAsync
and completes a plain TaskCompletionSource; every caller, including the one
that started it, waits the same way on the published task.

- Drop RunContinuationsAsynchronously (a separate change); without it the
  initializing caller pays no extra thread-pool hop, so it no longer needs
  its own completion path, StartInitializer or PublishOutcomeAsync.
- Drop the custom InvalidOperationException for a null task and the
  observed-exception read.
- Keep InitializeCoreAsync async, so an OperationCanceledException thrown
  by InitializeAsync still completes callers' tasks as Canceled, as before.
- Trim the tests to the five cases from the review; the failure case covers
  synchronous/asynchronous and ordinary/OperationCanceledException failures.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SZ1LwaBqCw5AJXeAU1vVo4
@Sing303
Sing303 deployed to Pull Requests September 28, 2026 18:20 — with GitHub Actions Active
@Sing303
Sing303 deployed to Pull Requests September 28, 2026 18:20 — with GitHub Actions Active
@Sing303
Sing303 deployed to Pull Requests September 28, 2026 18:20 — with GitHub Actions Active

This branch was successfully deployed

1 active deployment
Pull Requests — f35b875f Deployed Sep 28, 2026 by Sing303 via modularpipeline (ubuntu-latest) #6
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.

2 participants