test: de-flake TestFutureWaitingAnotherFuture cancel-while-loading#42
Conversation
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>
Update: second pre-existing flake found & fixedThe first matrix run on this PR went 84/85, with all Root cause: the shared Fix (commit Local evidence (2019.4.40f1 EditMode):
🤖 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>
Update: third pre-existing flake found & fixed (disk-cache cancel race)The previous CI run was 84/85 — the disk-cache placeholder flake on Root cause: the disk-cache read is a local Fix (commit
The runtime re-checks Local evidence (2019.4.40f1 EditMode): 🤖 Generated with Claude Code |
Problem
Release
7.0.4(the issue #39 NRE fix + a follow-up warning-log tweak) was committed tomainbut never published: the release matrix keeps going red at random on a pre-existing flaky test in theTestFutureWaitingAnotherFuturefamily — mostly onwindows-monolegs:These are test-harness races. No runtime/library code is touched (everything is under
Tests/).Root causes (two of them)
1.
LoadingFromSourcerace (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 observeLoadingFromSourceand 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 observedLoadedFromMemoryCacheinstead ofLoadingFromSource. 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).
TestHttpServerwrote the response and letusingdispose 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 withCurl error 56: Recv failure: Connection was reset, surfacing asFailed to receive dataacross many tests in the family. This was actually the dominant local failure on2019.4.40f1.Fix (determinism, not timing)
TestHttpServer: new deterministically held route/hold/{id}gated by a per-idManualResetEventSlim, withHoldImage/ReleaseHeld/ReleaseAllHeld. A held request reaches the server and parks there, so the client-side Future is guaranteed to stay inLoadingFromSourceuntil the test releases it. Held workers are unblocked onStop()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/UnregisterHeldroute chosen URLs to/hold/{id}, keeping the existing fast/slow routing for everything else.TestUtils:BeginHold(url)/ReleaseHeld(url)helpers;EnableMockProviderreleases 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 wholeLoadAndCancelsequence, then releases it so the outer load completes and cleans up. Assertions are unchanged — theLoadingFromSource -> Canceled -> Completedsequence is still asserted; the mid-load cancel is now guaranteed instead of accidental.Local evidence
2019.4.40f1EditMode, filtered toTestFutureWaitingAnotherFuture:LoadingFromSourceflake and theConnection was resetflake).🤖 Generated with Claude Code