Skip to content

test: de-flake TestFutureWaitingAnotherFuture cancel-while-loading#42

Merged
IvanMurzak merged 3 commits into
mainfrom
fix/flaky-tests
May 29, 2026
Merged

test: de-flake TestFutureWaitingAnotherFuture cancel-while-loading#42
IvanMurzak merged 3 commits into
mainfrom
fix/flaky-tests

Conversation

@IvanMurzak

Copy link
Copy Markdown
Owner

Problem

Release 7.0.4 (the issue #39 NRE fix + a follow-up warning-log tweak) was committed to main but never published: the release matrix keeps going red at random on a pre-existing flaky test in the TestFutureWaitingAnotherFuture family — mostly on windows-mono legs:

System.Exception : Expected event LoadingFromSource, but it was not found.
Got: LoadedFromMemoryCache, Loaded, Completed

These are test-harness races. No runtime/library code is touched (everything is under Tests/).

Root causes (two of them)

1. LoadingFromSource race (the reported flake).
LoadFrom_Source_AndCancel -> LoadAndCancel(url, Source) starts an outer future loading the URL, then attaches an inner future that is supposed to observe LoadingFromSource and be cancelled mid-load. The in-process mock server (TestHttpServer) answered instantly, so the outer load often completed and populated the memory cache before the cancel — the inner future then observed LoadedFromMemoryCache instead of LoadingFromSource. This was both the flake and a coverage gap (it sometimes cancelled after completion, never exercising the mid-load cancel it exists to test).

2. Connection-reset race (found while reproducing locally).
TestHttpServer wrote the response and let using dispose the socket immediately. On Windows, closing a socket while the peer's request bytes are still unread makes the OS send a TCP RST instead of FIN, so curl intermittently failed with Curl error 56: Recv failure: Connection was reset, surfacing as Failed to receive data across many tests in the family. This was actually the dominant local failure on 2019.4.40f1.

Fix (determinism, not timing)

  • TestHttpServer: new deterministically held route /hold/{id} gated by a per-id ManualResetEventSlim, with HoldImage / ReleaseHeld / ReleaseAllHeld. A held request reaches the server and parks there, so the client-side Future is guaranteed to stay in LoadingFromSource until the test releases it. Held workers are unblocked on Stop() so no thread leaks.
  • TestHttpServer.WriteAndClose: replaces the abrupt close with a graceful lingering close (drain request -> half-close send -> wait bounded for peer FIN), eliminating the RST/"Connection was reset" flake for every route.
  • MockWebRequestProvider: RegisterHeld / UnregisterHeld route chosen URLs to /hold/{id}, keeping the existing fast/slow routing for everything else.
  • TestUtils: BeginHold(url) / ReleaseHeld(url) helpers; EnableMockProvider releases any stale gate as a safety net.
  • TestFutureWaitingAnotherFuture.Load.AndCancel (Editor + Runtime, kept in sync): the source cancel test now holds the load in-flight across the whole LoadAndCancel sequence, then releases it so the outer load completes and cleans up. Assertions are unchanged — the LoadingFromSource -> Canceled -> Completed sequence is still asserted; the mid-load cancel is now guaranteed instead of accidental.

Local evidence

2019.4.40f1 EditMode, filtered to TestFutureWaitingAnotherFuture:

  • Baseline (before fix): reproduced 9/20 failures in a single run (both the LoadingFromSource flake and the Connection was reset flake).
  • After fix: 10/10 consecutive runs, 20/20 green each.

🤖 Generated with Claude Code

IvanMurzak and others added 2 commits May 28, 2026 18:03
Two pre-existing flaky tests in the TestFutureWaitingAnotherFuture family
were failing at random across the release matrix (mostly windows-mono legs),
blocking the 7.0.4 publish. Both are test-harness races; runtime code is
unchanged.

1) LoadingFromSource race (the reported flake)
   LoadFrom_Source_AndCancel -> LoadAndCancel(url, Source) starts an outer
   future loading the URL, then attaches an inner future that must observe
   LoadingFromSource and be cancelled mid-load. Because the in-process mock
   server answered instantly, the outer load often completed and populated the
   memory cache before the cancel, so the inner future observed
   LoadedFromMemoryCache instead -> "Expected event LoadingFromSource, but it
   was not found. Got: LoadedFromMemoryCache, Loaded, Completed". This was both
   the flake and a coverage gap (it sometimes cancelled after completion).

   Fix: add a deterministically held route to TestHttpServer (/hold/{id}
   gated by a per-id ManualResetEventSlim) plus HoldImage/ReleaseHeld/
   ReleaseAllHeld, routed via MockWebRequestProvider.RegisterHeld and exposed
   to tests as TestUtils.BeginHold/ReleaseHeld. The cancel test now holds the
   source load in-flight across the whole LoadAndCancel sequence, so the inner
   future is guaranteed to be in LoadingFromSource when cancelled, then the
   hold is released so the outer load completes and cleans up. No timing
   guesses; assertions are unchanged.

2) Connection-reset race (uncovered while reproducing)
   TestHttpServer wrote the response and let `using` dispose the socket
   immediately. On Windows, closing with the peer's request bytes still unread
   makes the OS emit a TCP RST instead of FIN, so curl intermittently failed
   with "Curl error 56: Recv failure: Connection was reset", surfacing as
   "Failed to receive data" across many tests in the family. Fix: WriteAndClose
   now drains the request, half-closes the send side, and waits (bounded) for
   the peer FIN before disposing. Held workers are also unblocked on Stop() so
   no thread leaks.

Local evidence: 2019.4.40f1 EditMode, filtered to
TestFutureWaitingAnotherFuture. Baseline reproduced 9/20 failures; after the
fix 10/10 consecutive runs were 20/20 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CI surfaced a second pre-existing flake (TestFuture.LoadFrom_DiskCache_ThenCancel
with usePlaceholder, on the fast 2019.4.40f1 playmode leg):

  Expected 6 events, but got 5.
  Expected: LoadingFromDiskCache, Consumed, LoadedFromDiskCache, Loaded, Consumed, Completed
  Got:      LoadingFromDiskCache,           LoadedFromDiskCache, Loaded, Consumed, Completed

The first "Consumed" (the loading-state placeholder being applied) was missing.

Root cause: the shared Load/LoadThenCancel/LoadAndCancel helpers did
ImageLoader.LoadSprite(url) and only THEN called SetPlaceholder. LoadSprite
starts the load synchronously inside the call, so on a fast disk/memory-cache
read the future can reach LoadedFromDiskCache before the test registers the
placeholders. When the LoadingFromDiskCache placeholder is not yet registered
at the moment the loading event fires (and Status has already advanced when
SetPlaceholder runs), the loading placeholder is never consumed and the
expected first Consumed event never happens.

Fix: register the placeholders BEFORE the load can advance. New helper
StartLoadSpriteWithPlaceholders creates the future un-started (new FutureSprite),
attaches the listener, registers the four placeholders, then calls
StartLoading() — so the loading-state placeholder is guaranteed present when
the loading event fires and is consumed deterministically. Behaviour for the
non-placeholder path is unchanged (equivalent to ImageLoader.LoadSprite).

Local evidence: 2019.4.40f1 EditMode, TestFuture.LoadFrom_DiskCache_ThenCancel
10/10 green after the fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@IvanMurzak

Copy link
Copy Markdown
Owner Author

Update: second pre-existing flake found & fixed

The first matrix run on this PR went 84/85, with all TestFutureWaitingAnotherFuture legs (including every windows-mono leg, and the 2019.4.40f1 editmode/standalone/playmode-windows-mono legs that used to flake) green. The single failure was a different pre-existing flake on 2019.4.40f1 playmode base:

TestFuture.LoadFrom_DiskCache_ThenCancel (usePlaceholder)
Expected 6 events, but got 5.
Expected: LoadingFromDiskCache, Consumed, LoadedFromDiskCache, Loaded, Consumed, Completed
Got:      LoadingFromDiskCache,           LoadedFromDiskCache, Loaded, Consumed, Completed

Root cause: the shared Load/LoadThenCancel/LoadAndCancel helpers did ImageLoader.LoadSprite(url) and only then called SetPlaceholder. LoadSprite starts the load synchronously, so on a fast disk/memory-cache read the future reaches LoadedFromDiskCache before the test registers the placeholders — the loading-state placeholder is never consumed, so the first Consumed event is missing. Same "fast load wins the race" class as the original flake, in the placeholder path.

Fix (commit a0e2b2b): new helper StartLoadSpriteWithPlaceholders creates the future un-started (new FutureSprite), attaches the listener, registers the four placeholders, then calls StartLoading(). The loading-state placeholder is now guaranteed present when the loading event fires, so its consume is deterministic. Assertions unchanged; non-placeholder path is behaviourally identical to ImageLoader.LoadSprite.

Local evidence (2019.4.40f1 EditMode):

  • TestFuture.LoadFrom_DiskCache_ThenCancel: 10/10 green after the fix.
  • Regression: TestFuture (68 tests) 3/3 green, TestFutureWaitingAnotherFuture (20 tests) 3/3 green — the placeholder change breaks nothing.

🤖 Generated with Claude Code

The previous PR run surfaced a third pre-existing flake on a fast windows-mono
standalone leg:

  TestFuture.LoadFrom_DiskCache_AndCancel
  Expected: LoadingFromDiskCache, [Consumed,] Canceled, [Consumed,] Completed
  Got:      LoadingFromDiskCache, [Consumed,] LoadedFromDiskCache, Loaded, [Consumed,] Completed

Root cause: the disk-cache read is a local File.ReadAllBytes dispatched to a
TaskFactory. On a fast machine that Task can finish and the awaiting
continuation resume inline, so the future runs all the way to
LoadedFromDiskCache *synchronously inside StartLoading()* — before the test's
post-StartLoading Cancel() executes. The cancel then no-ops on an
already-loaded future and the test sees a successful load instead of the cancel.
Source loads never hit this (UnityWebRequest is genuinely async) and memory-cache
is not a cancel-while-loading scenario, so only the disk-cache path is affected.

Fix: for the disk-cache LoadAndCancel case, raise the Cancel() at the loading
transition instead of after StartLoading(), via StartLoadSpriteWithPlaceholders'
new cancelAtLoadingFrom hook. The hook is attached to the last synchronous
loading-state callback so the event order is preserved exactly:
  - usePlaceholder == false: cancel from the LoadingFromDiskCache event
    -> LoadingFromDiskCache, Canceled, Completed
  - usePlaceholder == true: cancel from the loading-placeholder Consume (fires
    after the listener records the loading event and its consume, still in the
    loading state) -> LoadingFromDiskCache, Consumed, Canceled, Consumed, Completed
The runtime re-checks IsCancelled after the disk read, so the cancel reliably
wins. The explicit Cancel() later in LoadAndCancel becomes a harmless no-op.
Assertions are unchanged.

Local evidence: 2019.4.40f1 EditMode, TestFuture (68) and
TestFutureWaitingAnotherFuture (20) full suites 3x each, all green; the
disk-cancel tests now take the deterministic cancel path on every run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@IvanMurzak

Copy link
Copy Markdown
Owner Author

Update: third pre-existing flake found & fixed (disk-cache cancel race)

The previous CI run was 84/85 — the disk-cache placeholder flake on 2019.4.40f1 playmode base is now fixed, but a distinct rare race surfaced on 6000.0.37f1 standalone windows-mono:

TestFuture.LoadFrom_DiskCache_AndCancel
Expected: LoadingFromDiskCache, [Consumed,] Canceled, [Consumed,] Completed
Got:      LoadingFromDiskCache, [Consumed,] LoadedFromDiskCache, Loaded, [Consumed,] Completed

Root cause: the disk-cache read is a local File.ReadAllBytes dispatched to a TaskFactory. On a fast machine the Task finishes and the awaiting continuation resumes inline, so the future reaches LoadedFromDiskCache synchronously inside StartLoading() — before the test's post-StartLoading Cancel() runs. The cancel no-ops and the test sees a load instead of the cancel. (Source never hits this — UnityWebRequest is genuinely async; memory-cache isn't a cancel-while-loading scenario.)

Fix (commit 347d133): for the disk-cache LoadAndCancel, raise Cancel() at the loading transition via a new cancelAtLoadingFrom hook, attached to the last synchronous loading-state callback so the event order is preserved exactly:

  • placeholder=false → cancel from the LoadingFromDiskCache event → LoadingFromDiskCache, Canceled, Completed
  • placeholder=true → cancel from the loading-placeholder Consume (after the listener records the loading event + consume, still in the loading state) → LoadingFromDiskCache, Consumed, Canceled, Consumed, Completed

The runtime re-checks IsCancelled after the disk read, so the cancel reliably wins. The explicit Cancel() later becomes a no-op. Assertions unchanged.

Local evidence (2019.4.40f1 EditMode): TestFuture (68) and TestFutureWaitingAnotherFuture (20) full suites 3x each, all green; the disk-cancel tests now take the deterministic cancel path every run.

🤖 Generated with Claude Code

@IvanMurzak
IvanMurzak merged commit 5137a6a into main May 29, 2026
127 of 128 checks passed
@IvanMurzak
IvanMurzak deleted the fix/flaky-tests branch May 29, 2026 02:13
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