Skip to content

[RT-E2] refactor: the background service runs on an injected TimeProvider - #429

Merged
Blind-Striker merged 1 commit into
masterfrom
feat/rt-e2-a-svc
Oct 17, 2026
Merged

Blind-Striker merged 1 commit into
masterfrom
feat/rt-e2-a-svc

Conversation

@Blind-Striker

@Blind-Striker Blind-Striker commented Oct 17, 2026 •

Copy link
Copy Markdown
Collaborator

What

The background service's delays and bounds now run on an injected TimeProvider instead of the system clock. This is RT-E2 PR A of three (B: terminals; C: the launcher and the clock ban).

Closes

  • U4-04: IServiceClock covered only UtcNow, so every delay and bound in the unit still ran on real time. IServiceClock and ServiceClock are deleted.

The change

  • Three composition roots. DiscoverAsync, StopAsync and EnsureAsync each choose TimeProvider.System once, with a comment naming the choice. They pass the clock to the info probe, the PTY handoff (ticket and fallback), the stopper and terminator, the election and the contenders. No collaborator has a System fallback. The counted RS0030 pragmas for these roots come with the ban in PR C.
  • No bound moves. UTC reads stay UTC at the same read points: the election's spawn stamps and deadlines, and the handoff's expiresAt. Every former CancelAfter is armed at its old point (probe after headers, ticket after pipeline construction, fallback at entry). The caller, refusal and timeout classification is unchanged.
  • TimedCancellation.Create(TimeSpan, TimeProvider), one internal factory in Internal/Threading. It corrects the approved plan.
    • Why it is needed: for any provider other than TimeProvider.System, Microsoft.Bcl.TimeProvider 10.0.13's CreateCancellationTokenSource creates a separate timer and disposes it only from a cancellation callback. A deadline disposed before it fires therefore leaves its timer armed.
    • Modern targets: the factory returns new CancellationTokenSource(timeout, time).
    • Downlevel: a sealed subclass that owns its timer. A lock and a disposed flag keep the timer callback's cancel and the source's dispose from overlapping, with no catch and no suppression. The cost of that lock is documented in place: cancellation callbacks run under it, so a callback that waits on another thread disposing the same deadline would deadlock. Nothing in the SDK does that.
    • After disposal, both paths promise only that a late callback never throws.
  • ManualTimeProvider (tests) gains one optional start time, so the handoff scenario keeps its fixed clock. Existing callers are unchanged.

Proof

  • Real-time sites in the background slice: 7 non-root sites went to 0, and 3 named composition roots were added. That count covers 3 raw delays, 3 CancelAfter calls and 1 UTC adapter read.
  • New seam proofs on ManualTimeProvider. Each waits for an entry or armed-timer signal before advancing, and each is killed by a compiling product mutant that removes the bound while keeping the signature, on net10.0 and net472:
    • the probe request bound;
    • the ticket bound and the fallback bound, separately;
    • the election poll;
    • the terminator's terminate and kill rungs;
    • the contender backoff.
  • TimedCancellationTests:
    • Early dispose leaves 0 armed timers. This row was red on net472 against the BCL extension: "expected 0, found 1".
    • Exact-timeout cancellation, and not one tick early.
    • A deterministic in-flight callback after dispose never throws (downlevel). A compiling mutant that drops the disposed flag turns it, and the race row, red with ObjectDisposedException.
    • A 1,000-round barrier race of cancel and dispose.
    • A registration that disposes its own deadline from its callback completes without deadlock.
    • The analyzers themselves reject a mutant that drops the timer disposal (CA2213/IDE0052). Built with analyzers off for that mutant only, the dispose row goes red. That run is a runtime demonstration, not an analyzer-clean kill.
  • Compile-forced test edits only: a clock is supplied or the manual clock is advanced, and no assertion or name changed. The files are ServiceEnsurerTests, ServiceStopperTests, ServiceInfoProbeTests, OpenCodeServerDiscoveryLiveTests, ServicePtyHandoffScenario, ServiceContenderSpawnerTests, ServiceContenderSpawnerRequestTests, ServiceContenderTests, EnsureServiceContext, ServiceFixture EnsureMode and DiscoveryMode, and tests/Shared/ContenderLedger.cs. ServiceContenderSpawnerTests' Unix cleanup is reordered (kill, advance, wait for Finished, then disappearance), because the contender is the child's only reaper and its watch now runs on the test clock. The existing clock-racing tests are TST-E2b's and are not rewritten here.
  • Registry: ReservedNamePolicy.InternalTypeNames drops IServiceClock and ServiceClock and adds TimedCancellation.
  • Checks: build 0/0 on every TFM; both format passes; slopwatch 0; verify-clones 0 new (tests 5.66%, against the 6.0% ceiling); the whole OpenCode.Sdk.Tools.Tests project 1998/1998; the BackgroundService namespace 515/515 on net10.0 and net472. PublicApi.verified.txt is unchanged.
  • Local check: GATE PASS on 73eb46b1 (this branch on master b7df1975): slopwatch, build, both format passes, clone-detector, verify-clones, 10,408 tests including net472 with 0 failed, and election. An earlier run caught the stale internal type registry, fixed above.
  • Reviews:
    • Round 1: the Runtime coordinator, plus a fresh read-only Sol review with two blockers. One was the downlevel timer lifetime, fixed with the factory above. The other was the contender cleanup order, fixed by the reorder.
    • Delta review: one should-fix. After dispose, the NET path may still cancel the token while downlevel does not. The resolution was to accept the native semantics and document "never throws" as the only post-dispose promise.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

…-E2 A)

IServiceClock and ServiceClock are gone. Discover, Stop and Ensure each choose
TimeProvider.System once, as a composition root, and pass it to the info probe,
the PTY handoff, the stopper and terminator, the election and the contenders.
UTC reads stay UTC at the same read points, and every former CancelAfter is
armed at its old point, so no bound moves. New manual-clock tests prove each
newly reachable bound (probe, ticket, fallback, election poll, terminator rungs,
contender backoff).
@Blind-Striker
Blind-Striker added this pull request to the merge queue Oct 17, 2026
Merged via the queue into master with commit 62d82f0 Oct 17, 2026
12 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.

1 participant