Skip to content

Make SSE idle-timeout tests deterministic with FakeTimeProvider - #122

Merged
joadan merged 3 commits into
mainfrom
fix/sse-idle-timeout-fake-clock
Oct 19, 2026
Merged

joadan merged 3 commits into
mainfrom
fix/sse-idle-timeout-fake-clock

Conversation

@joadan

@joadan joadan commented Oct 17, 2026

Copy link
Copy Markdown
Contributor

SseClientTests.KeepAliveComments_PreventIdleTimeout flaked in CI (PR run 39017126921, "Condition was not met before the timeout") because it relied on real wall-clock margins: a 1 s idle timeout kept alive by : ka comments pushed every 100 ms. When the thread pool is busy, a gap can stretch past 1 s.

Change

  • IdleTimeoutStream takes an optional TimeProvider (default TimeProvider.System); its CTS is created with that provider, so CancelAfter uses the provider's timers. SSEClient exposes it as an internal init property. No public API or runtime behaviour change.
  • Tests use FakeTimeProvider (new test-only package Microsoft.Extensions.TimeProvider.Testing 10.10.0; lock file refreshed).
  • ScriptableSseStream.ParkedReads counts reads parked on an empty queue. Tests advance the clock only after the reader has consumed a push and issued its next read, so the timer has provably re-armed and scheduling delays can't fire it.
  • Converted: KeepAliveComments_PreventIdleTimeout (15 × 900 ms fake time with a 1 s timeout, plus a control step checking that silence past the timeout does fail the stream), IdleTimeoutSet_StalledServer_FailsTheStream, and IdleTimeoutStreamTests.SlowButRegularData_DoesNotTimeOut, which had the same real-clock margin pattern.

Verification

  • Mutation check: with the per-read re-arm commented out, both keep-alive tests fail.
  • SSE + IdleTimeoutStream tests passed 40 times in a row; full Linq2GraphQL.CI.slnf suite passed (446 tests).
  • The WS tests' Task.Delay(100) calls only sequence a server abort and don't assert a timing margin, so they're unchanged.

KeepAliveComments_PreventIdleTimeout used real wall-clock margins
(1 s timeout, 100 ms keep-alive gaps) and failed in CI when thread-pool
load delayed the gaps past the timeout.

IdleTimeoutStream now takes an optional TimeProvider (default System),
exposed internally on SSEClient. The tests advance a FakeTimeProvider
only after the reader has provably consumed each push and re-armed, so
scheduling cannot fire the timer. KeepAliveComments also asserts the
fake clock does fire the timeout once data stops, and
SlowButRegularData_DoesNotTimeOut and IdleTimeoutSet_StalledServer use
the same clock. No public API or runtime behaviour change.

@ricred ricred left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice change — the ParkedReads handshake is sound: a read only parks after IdleTimeoutStream has re-armed, so the fake clock can't fire the timer early, and the control step in KeepAliveComments_PreventIdleTimeout is a good addition.

Verified locally at dd1279e:

  • restore --locked-mode + build: 0 errors, 1 new warning (see inline comment).
  • SSE + IdleTimeoutStream tests (28): 15 consecutive green runs.
  • Mutation check reproduced: with the per-read idleCts.CancelAfter(idleTimeout) removed, SlowButRegularData_DoesNotTimeOut and KeepAliveComments_PreventIdleTimeout fail.
  • Full CI suite not re-run locally (CI is green).

internal IdleTimeoutStream(Stream inner, TimeSpan idleTimeout)
/// <param name="timeProvider">Clock driving the idle timer; <see cref="TimeProvider.System" /> when null.
/// Tests pass a fake provider so the timer only advances when they say so.</param>
internal IdleTimeoutStream(Stream inner, TimeSpan idleTimeout, TimeProvider? timeProvider = null)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should Fix — TimeProvider? timeProvider = null introduces a new build warning:

CS8632: The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

The Subscriptions project has no <Nullable> setting or #nullable directive, and main builds it warning-free. Dropping the annotation removes the warning without changing behaviour:

internal IdleTimeoutStream(Stream inner, TimeSpan idleTimeout, TimeProvider timeProvider = null)

(Alternatively #nullable enable for this file, but dropping the ? matches the rest of the project.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 51ce6e1, thanks: dropped the ? to match the rest of the project. A clean build of Linq2GraphQL.Client.Subscriptions is warning-free again.

// One byte every 500ms (fake clock) for 10s with a 750ms idle timeout: the whole read
// outlasts the timeout, so it only passes if the timer re-arms per read. The clock only
// moves inside each read, so scheduling delays cannot fire the timer.
var time = new FakeTimeProvider();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional — NoDataWithinTimeout_FailsWithIOException (line 12) and NonCancelableToken_StillSubjectToIdleTimeout (line 90) still use the real clock with a 100 ms timeout. They only assert that the timer does fire and have a 5 s outer budget, so they can't flake the same way. Was leaving them on the real clock intentional? If you'd like all idle-timeout tests on the same clock, Advance would also make them instant. Fine to ignore.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that was intentional. I'd rather keep them on the real clock, for two reasons:

  • They can't flake under load. They only assert that the timer does fire, with a 5 s budget for a 100 ms timeout. A busy machine can only delay the timeout, not stop it. The flaky test was the opposite case: it had to prove the timer didn't fire.
  • They're the only coverage of the default clock. In production nobody passes a TimeProvider, so IdleTimeoutStream uses TimeProvider.System. If every test used FakeTimeProvider, nothing would check that the default timeout actually works.

So the split is: fake clock where scheduling could break the test, real clock where it can't.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to hear! Everything fine then! Waiting for you to resolve the conflicts with main to get the fix out!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Conflicts with main are resolved in c5a9d0e. Your per-read timer from ae08e55 is kept as is. The only change on top is that each read's timer is created from the optional TimeProvider, so the fake-clock tests drive it. Since a linked token source can't take a TimeProvider, each read now creates a timer source bound to the provider and links it with the caller's token.

While re-checking after the merge, I found the SSE fake-clock tests still passed with the fake clock disconnected, because their 1 s timeout also ran out on the real clock while the test was waiting. They now use a 30 s timeout, so only the fake clock can fire the timer, and disconnecting it makes both tests fail.

Full CI suite green locally (489 tests); the SSE and idle-timeout tests passed 30 runs in a row.

The Subscriptions project is not in a nullable context, so the '?' raised
a warning that main does not have.
…fake-clock

# Conflicts:
#	src/Linq2GraphQL.Client.Subscriptions/IdleTimeoutStream.cs
@joadan
joadan merged commit 7f37a06 into main Oct 19, 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.

2 participants