Skip to content

Tests: a timing assertion measures the property, not the machine it ran on - #3369

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3358-deterministic-timing-tests
Aug 25, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3358-deterministic-timing-tests

Conversation

@lahma

@lahma lahma commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Across a full day of v5 campaign work, every single CI failure was environmental — not one was a real
regression from the twenty-two pull requests that merged. The cost is not the re-run. It is that a genuine
regression now arrives looking exactly like the noise, and gets waved through.

This is the mechanism behind the repeat offenders. Nothing here deletes an assertion, and nothing here raises
a number until it stops failing. #3301 states the trap explicitly and it applies to all of them: a wait that
ignored its token and ran out its whole ceiling would still throw OperationCanceledException, so deleting
the elapsed check would let a real defect pass. Each row below is instead given a clock it can trust, a
handshake that removes the clock, or a margin expressed as a ratio to the ceiling it discriminates against.

What changed, per test

The stopwatch is started by the thread that releases the wait

Jint.Tests/Runtime/WaitForScheduledWorkTests.cs, Jint.Tests.PublicInterface/HostPumpWaitTests.cs

WaitForScheduledWorkTests.ThrowsOperationCanceledOnTheToken (#3301) missed its discriminator by 11 ms;
HostPumpWaitTests.WaitForScheduledWorkWakesOnACrossThreadPost reported 12 s against a 10 s ceiling it never
actually spent. Both were measuring far more than the wake: the clock started before the releasing thread had
even been scheduled, and ran through its quarter-second settle sleep.

It now starts on that thread, immediately before the release, so the only interval measured is the wake
latency — which is the thing the assertion is about. The property survives exactly: a wait that noticed the
release only when its ceiling ran out still fails, because that interval is measured from the release.

The cancellation row also stops releasing through CancellationTokenSource.CancelAfter. That callback is
delivered by the thread pool, and a saturated pool grows at roughly one worker per 500 ms, so it is the one
release whose latency the test cannot bound — a healthy 250 ms cancellation was measured at 5.011 s against a
5 s midpoint. It is now cancelled from a DedicatedThread the test owns, the same shape the enqueue rows
already used.

Ceiling moves from ten seconds to a minute in both files, with EarlyReturnMargin derived from it as a
half rather than written out again. #3301 asked for exactly this: the ratio is what the assertion is about,
so moving both together widens the margin without weakening it. A healthy run spends none of the ceiling; only
a genuine regression pays the extra fifty seconds before being reported.

The witness that needs no clock is asserted first

Jint.Tests.PublicInterface/HostPromiseTimeoutTests.cs

AnExplicitTimeoutStillWinsOverTheConfiguredOne failed with "Expected elapsed to be less than 5s, but found
1m 3s"
— a 200 ms wait, stalled by a factor of three hundred.

UnwrapIfPromiseCore resolves the effective timeout into one local, hands that local to the drain, and formats
the same local into the rejection message. The message is therefore a witness of which budget the wait
actually ran under
, not a restatement of the configuration: a wait that consulted the wrong budget cannot
name the right one. That is asserted first, by a named helper.

The elapsed check stays as the other half of the claim — that the wait ended on the budget it names rather
than sitting out the longer one it should have ignored — but its bound is now half of whatever the wrong
answer would have spent
instead of a fixed five seconds. For the explicit row the wrong answer is the
configured five minutes, so the midpoint moves to 2 m 30 s and a three-hundred-fold stall no longer reaches it.
For the argument-less row the wrong answer is the ten-second default, which this test cannot change, so its
midpoint stays at five seconds — that is the one residual, and it is now a deliberate, documented consequence
of the rule rather than a magic constant.

The simulated IO ends when the test says so

Jint.Tests/Runtime/PromiseTests.cs

UnwrapIfPromiseAsync_WithIOBoundTask_DoesNotBlockCallerThread asserted that the unwrap task is still pending
while IO is in flight — with the IO being await Task.Delay(100). On a loaded runner the hundred milliseconds
can be gone before the test thread is scheduled again, and a completed unwrap then reads as exactly the defect
the test exists to catch.

It now ends on a TaskCompletionSource the test holds. "In flight" becomes a fact rather than a hope, and the
assertion gets stronger: the unwrap task provably cannot have completed.

Wedge ceilings on budgets nothing asserts

TestBudgets.WedgeCeiling already exists for this, and documents the distinction it turns on: a budget is
either part of what a test asserts, or it is a ceiling that exists only so a wedge is reported instead of a
hang. Five rows were sitting on budgets of the second kind, sized for an idle machine.

  • HostStreamBridgeTests.ReadsAStreamWhoseReadsCompleteOnAnotherThread — every chunk arrives as a thread-pool
    continuation, and the whole copy was failing as PromiseRejectedException: Timeout of 00:00:10 reached. The
    assertion is the text that came out of the stream.
  • AsyncTests.ShouldTaskAwaitCurrentStack (Three fixed 10-second budgets in tests blow on a contended Windows runner, the way the WPT fetch budget did #3358, seen on net472) and its ValueTask twin
    ShouldValueTaskAwaitCurrentStack — about 1.1 s of intended work inside the ten-second default. The
    assertion is the order the three appends landed in.
  • UntrustedCodeProfileTests.OperationScopeCannotEndWhileAsyncWorkOwnsTheEngine — the profile's five-second
    timeout interval and ten-second operation deadline are budgets an embedder sizes for their own work. The
    assertion is that a scope refuses to end while an evaluation is suspended, and ends once it resumes.
  • ConstraintReplacementTests.TheLaterTimeoutIsTheOneEnforced — the discriminator is the one-millisecond
    interval that must no longer be registered, which two hundred thousand iterations pass by four orders of
    magnitude on any machine. The widened interval was never a duration the test is about; thirty seconds was a
    wedge ceiling sized for an idle box.
  • Jint.Tests.CommonScripts — the fixture is [Parallelizable(ParallelScope.All)], so twenty-eight CPU-bound
    real-world workloads share whatever cores the runner has, and a Windows leg was observed taking 7 m 19 s for
    the twenty-eight against ~20 s unloaded, with RegexMatchTimeoutException as the only symptom. Nothing here
    asserts anything about RegexTimeout. It stays finite deliberately — Regex.InfiniteMatchTimeout would turn
    a genuinely catastrophic pattern into a hung run.

Evidence

A clean run on an idle box proves nothing here, so: a 32-core machine saturated with CPU-bound spinner
processes, the affected tests run in a loop on both net10.0 and net472, before and after, same load and
same iteration count each time. "Before" is upstream/main's copy of the nine files, built in the same tree.

load build Jint.Tests net10.0 Jint.Tests net472 PublicInterface net10.0 PublicInterface net472 total
64 spinners (2×), 20 iterations before 20/20 20/20 19/20 20/20 79/80
64 spinners (2×), 20 iterations after 20/20 20/20 20/20 20/20 80/80
128 spinners (4×), 15 iterations before 13/15 15/15 14/15 15/15 57/60
128 spinners (4×), 15 iterations after 15/15 15/15 15/15 15/15 60/60

136/140 before, 140/140 after. All four baseline failures are named in this pull request, and both are
reproductions of the reported symptom rather than something new:

  • PromiseTests.UnwrapIfPromiseAsync_WithIOBoundTask_DoesNotBlockCallerThread — twice, at 128 spinners:
    "Expected unwrapTask.IsCompleted to be False … but found True". This is the Task.Delay(100) race,
    reproduced exactly.
  • UntrustedCodeProfileTests.OperationScopeCannotEndWhileAsyncWorkOwnsTheEngine — once at each load:
    PromiseRejectedException: Timeout of 00:00:00.1000000 reached, the profile's 100 ms promise budget.

The remaining rows did not fire in 140 attempts, which is expected — #3301's own failure was one occurrence
on a shared runner and it took six CI attempts on one pull request to establish the last one of these. Their
change is reasoned from the mechanism rather than from a reproduction, and the reasoning is stated per row
above.

#3358's third item is a different mechanism, and is not here

html/webappapis/timers/negative-settimeout.any.js was listed in #3358 with the issue's own caveat that it
should not be assumed to be a budget until someone looked. It was looked at, and it is neither a budget nor a
timer-ordering defect: the engine's clamp and ordering are deterministic and correct, and the driver simply
has no completion boundary for uncaught-callback reports while draining the event loop twice more after the
file is over. Upstream testharness.js has an explicit guard for exactly this. It is a WPT-driver change with
its own shim-symmetry tests and a worker-lane question to settle, so it is filed separately as #3366 — with
the full trace, upstream's guard quoted at the corpus pin, and the minimal fix — rather than folded in here.

One more flake found on the way, also filed rather than folded in

TypeConverterRegistrationTests.AnEngineWithACustomConverterSharesTheStockAccessorCache fails about one run
in three on the net472 leg under JINT_HOST_CONTRACT_VERIFICATION=1, and never when its class is run alone.
It is not a budget either: JsAccessibleRegistry.Generation is a process-wide counter and
TypeResolver.SyncGeneratedMembers drops the whole accessor cache when it moves, so any of the four
RegisterAll() calls elsewhere in the same two assemblies landing between that test's two engines makes its
calls.Should().Be(0) see a re-resolution. Filed with the mechanism and a fix direction as #3368.

Notes

Fixes #3358.
Fixes #3301.

🤖 Generated with Claude Code

https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

…an on

Every CI failure across a day of v5 campaign work was environmental, and not
one came from the twenty-two pull requests that merged. That is the cost this
addresses: a genuine regression now arrives looking exactly like the noise.

None of the assertions is deleted. Each one is either given a clock it can
trust, a handshake that removes the clock, or a margin expressed as a ratio to
the ceiling it discriminates against rather than as an absolute number a loaded
runner walks past.

WaitForScheduledWorkTests (sebastienros#3301), HostPumpWaitTests: the stopwatch is now
started by the thread that releases the wait, immediately before it releases
it, so what is measured is the wake latency and never the releasing thread's
own scheduling, its settle sleep, or the script that got the waiter there. The
cancellation row no longer releases through CancellationTokenSource.CancelAfter,
whose callback a saturated thread pool delivers whenever it gets round to it,
but from a dedicated thread the test owns. Ceiling moves to a minute with the
margin derived from it as a half, so the ratio the assertion is about is
unchanged and the midpoint is out of reach of a multi-second stall.

HostPromiseTimeoutTests: the rejection message is a witness of which budget the
wait actually ran under - UnwrapIfPromiseCore hands one local to the drain and
formats the same local into the message - so it is asserted first and the
elapsed check becomes the second half of the claim, bounded by half of whatever
the wrong answer would have spent instead of by a fixed five seconds.

PromiseTests.UnwrapIfPromiseAsync_WithIOBoundTask_DoesNotBlockCallerThread: the
simulated IO ended on a 100 ms delay, so "still pending" was a race the test
could lose. It now ends on a gate the test holds, which makes in-flight a fact.

The rest are wedge ceilings on budgets nothing asserts: the off-thread stream
bridge, the two await-current-stack rows on net472, the untrusted-code
operation scope, the replaced execution-time interval, and the regular
expression timeout under twenty-eight parallel SunSpider workloads.

Fixes sebastienros#3358.
Fixes sebastienros#3301.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma force-pushed the fix/3358-deterministic-timing-tests branch from 980fcb3 to f9169ad Compare August 25, 2026 19:39
@lahma
lahma merged commit 0f4bd0b into sebastienros:main Aug 25, 2026
6 checks passed
lahma added a commit that referenced this pull request Aug 26, 2026
…he machine (#3388)

Follows #3369's pattern into Jint.Tests: a deterministic handshake or a fake clock where
one reaches, an exact witness where the exception already carries one, and
TestBudgets.WedgeCeiling for every budget nothing asserts.

Fixes #3379
Fixes #3372
Fixes #3297


Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma added a commit to lahma/jint that referenced this pull request Aug 27, 2026
…ot because the machine was quick enough

WebApiTimerTests.ATimerOutlivingTheUnwrapTimeoutTimesOutByDesign failed once on
windows/net8.0 with "Expected: <PromiseRejectedException> But was: null" - a
200 ms unwrap returning without throwing against a five-second timer. A 25x
margin does not close through runner load, so it looked like a race in the
unwrap-timeout path. It is not. It is the same wall-clock assumption sebastienros#3369 and
sebastienros#3388 spent a campaign removing, wearing a margin big enough to look safe.

The five seconds do not bound the unwrap. They bound the gap between the
setTimeout call inside Evaluate and the unwrap starting on the next line, both
measured against the system clock. Spend five seconds in that gap and the timer
is already due when the unwrap begins - and the first thing DrainEventLoopUntil
does is run the continuations it finds, so the timer fires, the promise
FULFILS, and nothing is thrown. Sleeping 5.1 s between those two statements
reproduces the reported failure exactly, message for message. A runner whose
test bodies all run at ThreadPriority.Lowest (sebastienros#3452) under three concurrent
legs has spent longer than that on less; the same suite had a 200 ms budget
measured at 47 s in sebastienros#3406.

The fix is the one this file already uses four tests above: a ManualClock the
test owns and never advances, so the timer can never be due and the unwrap's
own bound is the only thing that can end the wait. No margin, no clock to lose
a race against. With the 5.1 s widening still in place the test now passes;
twenty consecutive runs of the fixture are green without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit to lahma/jint that referenced this pull request Aug 27, 2026
…an state the budget instead of racing it

HostPromiseTimeoutTests.TheArgumentLessUnwrapTakesTheConfiguredPromiseTimeout
was the one assertion sebastienros#3369 could not convert, and it said why: the wrong
answer it discriminates against is the engine's own ten-second PromiseTimeout
default, so the midpoint has to sit at five seconds and the test cannot move
the thing it is discriminating against. Widening past ten seconds does not make
it robust, it makes it assert nothing. It duly failed an unrelated pull request
at 47 s (sebastienros#3406).

The message half of that claim already worked - UnwrapIfPromiseCore resolves
one local, hands it to the drain and formats the same local into the rejection.
What it cannot state is the other half: that the drain ENDED on the budget it
named. That is a property of the wait, and no message can witness it.

So the wait gets a clock the host already owns. DrainEventLoopUntil armed its
deadline from DateTime.UtcNow; it now arms it from a monotonic timestamp read
through Options.Constraints.TimeProvider. That is not new public API - the
property has existed for LimitExecutionTime since sebastienros#3232, and PromiseTimeout
sits beside it on the same options group - and it fixes something on its own
account: a wall-clock deadline is cut short by an NTP step forwards and
stretched by one backwards, which is exactly why the pump's own ceiling has
always been measured on Stopwatch instead (Engine.Pump.ElapsedSince). The drain
was the one wait that was not.

The assertion is now exact and positive rather than a margin: the wait survives
one tick short of the configured budget and ends on the tick that reaches it,
and no amount of real time on either side changes either answer. The wrong
answer is excluded by construction - two hundred milliseconds of this clock
never reach a ten-second deadline - and both were verified against builds
carrying each regression: hard-coding the ten-second default fails the new row
and the message row, and putting the drain back on DateTime.UtcNow fails the
new row at its clock handshake. The rows the clock cannot reach keep the
witness they had: the message, on every target framework.

Also pinned, and its own line in the migration guide: a TimeProvider that
reports a non-positive TimestampFrequency is now refused when the engine is
built rather than only when a time limit happens to be registered, since the
clock is resolved for every engine.

docs/v5-migration.md 4.46 carries the three host-visible consequences,
including the one a host has to act on - a frozen clock now keeps a blocking
unwrap pending until the host advances it.

Fixes sebastienros#3406.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit to lahma/jint that referenced this pull request Aug 27, 2026
…an state the budget instead of racing it

HostPromiseTimeoutTests.TheArgumentLessUnwrapTakesTheConfiguredPromiseTimeout
was the one assertion sebastienros#3369 could not convert, and it said why: the wrong
answer it discriminates against is the engine's own ten-second PromiseTimeout
default, so the midpoint has to sit at five seconds and the test cannot move
the thing it is discriminating against. Widening past ten seconds does not make
it robust, it makes it assert nothing. It duly failed an unrelated pull request
at 47 s (sebastienros#3406).

The message half of that claim already worked - UnwrapIfPromiseCore resolves
one local, hands it to the drain and formats the same local into the rejection.
What it cannot state is the other half: that the drain ENDED on the budget it
named. That is a property of the wait, and no message can witness it.

So the wait gets a clock the host already owns. DrainEventLoopUntil armed its
deadline from DateTime.UtcNow; it now arms it from a monotonic timestamp read
through Options.Constraints.TimeProvider. That is not new public API - the
property has existed for LimitExecutionTime since sebastienros#3232, and PromiseTimeout
sits beside it on the same options group - and it fixes something on its own
account: a wall-clock deadline is cut short by an NTP step forwards and
stretched by one backwards, which is exactly why the pump's own ceiling has
always been measured on Stopwatch instead (Engine.Pump.ElapsedSince). The drain
was the one wait that was not.

The assertion is now exact and positive rather than a margin: the wait survives
one tick short of the configured budget and ends on the tick that reaches it,
and no amount of real time on either side changes either answer. The wrong
answer is excluded by construction - two hundred milliseconds of this clock
never reach a ten-second deadline - and both were verified against builds
carrying each regression: hard-coding the ten-second default fails the new row
and the message row, and putting the drain back on DateTime.UtcNow fails the
new row at its clock handshake. The rows the clock cannot reach keep the
witness they had: the message, on every target framework.

Also pinned, and its own line in the migration guide: a TimeProvider that
reports a non-positive TimestampFrequency is now refused when the engine is
built rather than only when a time limit happens to be registered, since the
clock is resolved for every engine.

docs/v5-migration.md 4.46 carries the three host-visible consequences,
including the one a host has to act on - a frozen clock now keeps a blocking
unwrap pending until the host advances it.

Fixes sebastienros#3406.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit to lahma/jint that referenced this pull request Aug 27, 2026
…an state the budget instead of racing it

HostPromiseTimeoutTests.TheArgumentLessUnwrapTakesTheConfiguredPromiseTimeout
was the one assertion sebastienros#3369 could not convert, and it said why: the wrong
answer it discriminates against is the engine's own ten-second PromiseTimeout
default, so the midpoint has to sit at five seconds and the test cannot move
the thing it is discriminating against. Widening past ten seconds does not make
it robust, it makes it assert nothing. It duly failed an unrelated pull request
at 47 s (sebastienros#3406).

The message half of that claim already worked - UnwrapIfPromiseCore resolves
one local, hands it to the drain and formats the same local into the rejection.
What it cannot state is the other half: that the drain ENDED on the budget it
named. That is a property of the wait, and no message can witness it.

So the wait gets a clock the host already owns. DrainEventLoopUntil armed its
deadline from DateTime.UtcNow; it now arms it from a monotonic timestamp read
through Options.Constraints.TimeProvider. That is not new public API - the
property has existed for LimitExecutionTime since sebastienros#3232, and PromiseTimeout
sits beside it on the same options group - and it fixes something on its own
account: a wall-clock deadline is cut short by an NTP step forwards and
stretched by one backwards, which is exactly why the pump's own ceiling has
always been measured on Stopwatch instead (Engine.Pump.ElapsedSince). The drain
was the one wait that was not.

The assertion is now exact and positive rather than a margin: the wait survives
one tick short of the configured budget and ends on the tick that reaches it,
and no amount of real time on either side changes either answer. The wrong
answer is excluded by construction - two hundred milliseconds of this clock
never reach a ten-second deadline - and both were verified against builds
carrying each regression: hard-coding the ten-second default fails the new row
and the message row, and putting the drain back on DateTime.UtcNow fails the
new row at its clock handshake. The rows the clock cannot reach keep the
witness they had: the message, on every target framework.

Also pinned, and its own line in the migration guide: a TimeProvider that
reports a non-positive TimestampFrequency is now refused when the engine is
built rather than only when a time limit happens to be registered, since the
clock is resolved for every engine.

docs/v5-migration.md 4.46 carries the three host-visible consequences,
including the one a host has to act on - a frozen clock now keeps a blocking
unwrap pending until the host advances it.

Fixes sebastienros#3406.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit that referenced this pull request Aug 27, 2026
…an state the budget instead of racing it (#3476)

HostPromiseTimeoutTests.TheArgumentLessUnwrapTakesTheConfiguredPromiseTimeout
was the one assertion #3369 could not convert, and it said why: the wrong
answer it discriminates against is the engine's own ten-second PromiseTimeout
default, so the midpoint has to sit at five seconds and the test cannot move
the thing it is discriminating against. Widening past ten seconds does not make
it robust, it makes it assert nothing. It duly failed an unrelated pull request
at 47 s (#3406).

The message half of that claim already worked - UnwrapIfPromiseCore resolves
one local, hands it to the drain and formats the same local into the rejection.
What it cannot state is the other half: that the drain ENDED on the budget it
named. That is a property of the wait, and no message can witness it.

So the wait gets a clock the host already owns. DrainEventLoopUntil armed its
deadline from DateTime.UtcNow; it now arms it from a monotonic timestamp read
through Options.Constraints.TimeProvider. That is not new public API - the
property has existed for LimitExecutionTime since #3232, and PromiseTimeout
sits beside it on the same options group - and it fixes something on its own
account: a wall-clock deadline is cut short by an NTP step forwards and
stretched by one backwards, which is exactly why the pump's own ceiling has
always been measured on Stopwatch instead (Engine.Pump.ElapsedSince). The drain
was the one wait that was not.

The assertion is now exact and positive rather than a margin: the wait survives
one tick short of the configured budget and ends on the tick that reaches it,
and no amount of real time on either side changes either answer. The wrong
answer is excluded by construction - two hundred milliseconds of this clock
never reach a ten-second deadline - and both were verified against builds
carrying each regression: hard-coding the ten-second default fails the new row
and the message row, and putting the drain back on DateTime.UtcNow fails the
new row at its clock handshake. The rows the clock cannot reach keep the
witness they had: the message, on every target framework.

Also pinned, and its own line in the migration guide: a TimeProvider that
reports a non-positive TimestampFrequency is now refused when the engine is
built rather than only when a time limit happens to be registered, since the
clock is resolved for every engine.

docs/v5-migration.md 4.46 carries the three host-visible consequences,
including the one a host has to act on - a frozen clock now keeps a blocking
unwrap pending until the host advances it.

Fixes #3406.


Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma added a commit to lahma/jint that referenced this pull request Aug 27, 2026
…ot because the machine was quick enough

WebApiTimerTests.ATimerOutlivingTheUnwrapTimeoutTimesOutByDesign failed once on
windows/net8.0 with "Expected: <PromiseRejectedException> But was: null" - a
200 ms unwrap returning without throwing against a five-second timer. A 25x
margin does not close through runner load, so it looked like a race in the
unwrap-timeout path. It is not. It is the same wall-clock assumption sebastienros#3369 and
sebastienros#3388 spent a campaign removing, wearing a margin big enough to look safe.

The five seconds do not bound the unwrap. They bound the gap between the
setTimeout call inside Evaluate and the unwrap starting on the next line, both
measured against the system clock. Spend five seconds in that gap and the timer
is already due when the unwrap begins - and the first thing DrainEventLoopUntil
does is run the continuations it finds, so the timer fires, the promise
FULFILS, and nothing is thrown. Sleeping 5.1 s between those two statements
reproduces the reported failure exactly, message for message. A runner whose
test bodies all run at ThreadPriority.Lowest (sebastienros#3452) under three concurrent
legs has spent longer than that on less; the same suite had a 200 ms budget
measured at 47 s in sebastienros#3406.

The fix is the one this file already uses four tests above: a ManualClock the
test owns and never advances, so the timer can never be due and the unwrap's
own bound is the only thing that can end the wait. No margin, no clock to lose
a race against. With the 5.1 s widening still in place the test now passes;
twenty consecutive runs of the fixture are green without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit that referenced this pull request Aug 27, 2026
…ot because the machine was quick enough (#3477)

WebApiTimerTests.ATimerOutlivingTheUnwrapTimeoutTimesOutByDesign failed once on
windows/net8.0 with "Expected: <PromiseRejectedException> But was: null" - a
200 ms unwrap returning without throwing against a five-second timer. A 25x
margin does not close through runner load, so it looked like a race in the
unwrap-timeout path. It is not. It is the same wall-clock assumption #3369 and
#3388 spent a campaign removing, wearing a margin big enough to look safe.

The five seconds do not bound the unwrap. They bound the gap between the
setTimeout call inside Evaluate and the unwrap starting on the next line, both
measured against the system clock. Spend five seconds in that gap and the timer
is already due when the unwrap begins - and the first thing DrainEventLoopUntil
does is run the continuations it finds, so the timer fires, the promise
FULFILS, and nothing is thrown. Sleeping 5.1 s between those two statements
reproduces the reported failure exactly, message for message. A runner whose
test bodies all run at ThreadPriority.Lowest (#3452) under three concurrent
legs has spent longer than that on less; the same suite had a 200 ms budget
measured at 47 s in #3406.

The fix is the one this file already uses four tests above: a ManualClock the
test owns and never advances, so the timer can never be due and the unwrap's
own bound is the only thing that can end the wait. No margin, no clock to lose
a race against. With the 5.1 s widening still in place the test now passes;
twenty consecutive runs of the fixture are green without it.


Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

Co-authored-by: Claude Opus 5 (1M context) <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

1 participant