Skip to content

fix(client): propagate CancellationToken through safe-mode setup (#123) - #124

Merged
joadan merged 1 commit into
Linq2GraphQL:mainfrom
ricred:fix/safe-mode-cancellation
Oct 18, 2026
Merged

joadan merged 1 commit into
Linq2GraphQL:mainfrom
ricred:fix/safe-mode-cancellation

Conversation

@ricred

@ricred ricred commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

Fixes #123

Root cause

With UseSafeMode on, the first query on a GraphQuery/GraphCursorQuery runs a one-time setup (InitQueryAsync): wait on a SemaphoreSlim, then fetch the schema via GraphClient.GetSchemaForSafeModeAsync() unless it is already in IMemoryCache. Neither step took the caller's CancellationToken, so cancelling ExecuteAsync/ExecuteWithResultAsync (directly or through the cursor pager) could still block indefinitely on the setup lock and on the introspection query.

Changes

  • InitQueryAsync(CancellationToken): _lock.WaitAsync(ct) (acquire outside the try, so a cancelled wait cannot release a lock it never took) and GetSchemaForSafeModeAsync(ct). intialized is only set after the whole setup succeeded, so a cancelled init is simply retried by the next call.
  • GraphClient.GetSchemaForSafeModeAsync(CancellationToken): the token flows into the introspection request. Safety of passing a per-caller token into the IMemoryCache.GetOrCreateAsync factory is documented on the method: GetOrCreateAsync is not single-flight (each cache miss runs its own factory) and a factory that throws - including on cancellation - commits nothing, so a cancelled fetch is never cached and other callers are unaffected.
  • New token overloads GraphBaseExecute.GetRequestAsync(CancellationToken), GetRequestAsJsonAsync(CancellationToken) and GraphClient.GetSchemaForSafeModeAsync(CancellationToken); the existing parameterless methods are kept, so this is binary compatible. (Per .NET library guidance, adding a parameter to an existing method is a breaking change, hence overloads.)
  • ExecuteBaseAsync/ExecuteBaseWithResultAsync pass their token to GetRequestAsync(ct); the cursor pager already forwards its token there.
  • CHANGELOG entry under Unreleased > Fixed.

Not changed

  • Linq2GraphQL.Client.Subscriptions: StartAsync()/StartWithErrorsAsync() accept no CancellationToken today, so the parameterless GetRequestAsync() call remains. A StartAsync(CancellationToken) overload would be a natural follow-up.

Testing

  • 9 new tests in GraphBaseExecuteTests (in-process handlers, no live server): cancel while waiting for the setup lock (holder is not disturbed), cancel during introspection, pre-cancelled token sends no request, cancelled schema fetch is not cached and init is retried successfully, plus overload-compat coverage of the parameterless forms.
  • Tests are hardened against the false pass this bug class invites: test clients use HttpClient.Timeout = Timeout.InfiniteTimeSpan (the 100 s default timeout produces a TaskCanceledException that would satisfy an OperationCanceledException assertion) and every bounded wait goes through Task.WaitAsync(10 s).
  • Mutation-checked: dropping the token at each of the three injection points (schema fetch, lock wait, ExecuteBase* call) fails at least two of the new tests.
  • dotnet build Linq2GraphQL.CI.slnf: 0 errors / 0 warnings. dotnet test Linq2GraphQL.CI.slnf: 454/454 passed. dotnet format --verify-no-changes on the touched files: clean.

…q2GraphQL#123)

Safe mode's one-time init (SemaphoreSlim wait + schema introspection) ran
without the caller's token, so cancelling ExecuteAsync/ExecuteWithResultAsync
could still block on the init lock and the introspection query.

- InitQueryAsync: _lock.WaitAsync(ct) and GetSchemaForSafeModeAsync(ct)
- GraphClient.GetSchemaForSafeModeAsync(ct): token flows into the introspection
  request; a cancelled fetch commits nothing to IMemoryCache, so the next call
  retries. If the fetch is ever made single-flight, it must not run on one
  caller's token (documented).
- New token overloads for GetRequestAsync/GetRequestAsJsonAsync/
  GetSchemaForSafeModeAsync; parameterless forms kept (binary compatible).
- Tests: cancel during lock wait, during introspection, pre-cancelled token,
  cancelled fetch not cached / init retryable, waiter cancellation does not
  disturb the lock holder. Mutation-checked: dropping the token fails them.
@ricred

ricred commented Oct 17, 2026

Copy link
Copy Markdown
Contributor Author

@joadan could you take a review? This fixes your #123 (safe-mode setup ignoring the cancellation token).

@joadan
joadan merged commit 49d3e83 into Linq2GraphQL:main Oct 18, 2026
2 checks passed
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.

Safe mode setup ignores the CancellationToken

2 participants