Skip to content

CI: Randomize Windows test order to guard against order-dependent bugs - #683

Open
echoix wants to merge 11 commits into
mainfrom
claude/windows-test-isolation-suv8k1
Open

echoix wants to merge 11 commits into
mainfrom
claude/windows-test-isolation-suv8k1

Conversation

@echoix

@echoix echoix commented Sep 4, 2026 •

Copy link
Copy Markdown
Owner

Description

Investigated a hypothesis: on Windows, the first call to a C-based GRASS
tool/library within a given process can fail while later calls in the same
process succeed, and a fixed test order can hide this indefinitely because
whichever test happens to run first never changes.

Two real fixes, plus infrastructure to guard against this bug class going
forward:

  • Fixed r.kappa's test_file testsuite test: it held a NamedTemporaryFile
    handle open while passing its path to r.kappa as the output file, which
    Windows rejects (a second process can't open a file the creator still holds
    open; POSIX allows it). Fixed the same way as r.mfilter's testsuite fix in
    r.mfilter: Avoid reopening NamedTemporaryFile on Windows in testsuite OSGeo/grass#7878 (grass.script.core.tempfile() instead), and removed the
    now-unneeded @xfail_windows marker.
  • Windows CI (osgeo4w.yml) ran the pytest suite single-worker with a fixed
    order, unlike Linux CI's pytest-xdist + needs_solo_run split. Added
    pytest-xdist and pytest-randomly, mirroring Linux CI's structure.
  • Added an opt-in GRASS_GUNITTEST_RANDOM_ORDER environment variable to
    grass.gunittest.loader.discover_modules() (it always sorted directories
    and files alphabetically before), and enabled it by default for the
    Windows thorough-test run. test_thorough.sh/.bat run the entire
    gunittest testsuite tree as a single long-lived process in a fixed
    traversal order — a more precise structural match for "fixed order hides
    an order-dependent bug" than xdist workers, since no parallelism is even
    needed for the mechanism to apply.

Investigation result: across roughly 56 Windows CI job runs (30 with
randomized pytest-xdist order, 15 with randomized gunittest discovery order,
plus verification runs), results were byte-for-byte identical regardless of
order — the same 8 pre-existing gunittest failures every time, all of which
match a set already catalogued by #682 as pre-existing, non-order-related
issues (network dependency, missing OpenGL on a headless runner, a numpy
doctest version difference, an md5sum mismatch, etc.), not something this
investigation should chase further here. No order-dependent failure was
found. That's a real (negative) result, not a dead end: the pytest-randomly
and GRASS_GUNITTEST_RANDOM_ORDER additions stay as permanent, validated
(15 real CI runs, no new noise introduced) protection against this bug class
recurring, even though it didn't reproduce today.

One genuine bug surfaced along the way —
python/grass/pygrass/raster/testsuite/test_raster_region.py fails with a
self-contained FERROR: Input window changed while maps are open for read
inside raster2numpy() handling, unrelated to test order — will be filed and
fixed as a separate, independent PR to keep it out of this one.

Motivation and context

doc/development/testing.md documents that xdist workers are long-lived
processes handling many test files sequentially ("several [tests] per
process"), so state and initialization order can leak between unrelated
tests sharing a worker. Windows CI wasn't exercising randomized order at all
in either test runner, so this class of bug had no way to surface, whether
or not one is currently present.

How has this been tested?

  • The r.kappa fix mirrors an already-merged, already-verified fix
    (r.mfilter, r.mfilter: Avoid reopening NamedTemporaryFile on Windows in testsuite OSGeo/grass#7878) for the identical anti-pattern.
  • The CI workflow changes and the grass.gunittest.loader randomization were
    validated across dozens of real Windows CI runs during the investigation
    (see above) before being kept as permanent defaults; the final state on
    this PR has also been run through normal Linux (pytest.yml) and Windows
    (osgeo4w.yml) CI.

Windows CI timing: xdist vs. single worker

Compared Windows CI (osgeo4w.yml) job data with pytest-xdist enabled (55
job records from 13 runs across this investigation's branches) against
single-worker pytest (118 job records spanning 11 months of main history,
2025-07-25 to 2026-06-21).

pytest step timing. xdist splits the old single step into two — a
parallel step plus a new needs_solo_run serial step, each paying its own
test-collection overhead — compared here against the combined total:

n mean median min max
Single worker (main, before) 5* 15.1 min 13.3 min 10.9 min 19.5 min
xdist parallel step alone 53 10.7 min 10.9 min 6.7 min 13.7 min
xdist parallel + solo combined 8** 12.0 min 12.0 min 9.6 min 13.8 min

* GitHub's job-log/step retention (roughly 3-4 months) limits step-level
"before" data to the 5 most recent main runs; treat as indicative, not
definitive.
** Only the 8 full-scope, PR-triggered jobs ran both steps; 45
workflow_dispatch fishing-attempt jobs used earlier in this investigation
intentionally skipped the solo/gunittest/thorough steps to isolate the
parallel step, so they only contribute to the parallel-step-alone row.

Net: the pytest step is about 20% faster with xdist, even after paying for
a second collection pass.

Whole-job duration (compile + all test steps; not expected to move,
since pytest is a small slice of it):

n mean median
Single worker (main, before) 114 84.5 min 84.4 min
xdist, full-scope PR jobs 5 84.0 min 90.3 min

No meaningful difference — the job is dominated by MSYS2 setup, compiling
GRASS, and the (unchanged) single-worker gunittest thorough-test step.

Timeouts / hangs. 0 GitHub Actions timed_out conclusions in either
group (0/53 xdist, 0/118 single-worker). A handful of cancelled jobs in
both groups (3/55 xdist, 4/118 single-worker) were checked individually and
are routine cancel-in-progress supersession from newer pushes to the same
branch, not hangs or timeouts.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist

  • PR title provides summary of the changes and starts with one of the
    pre-defined prefixes
  • My code follows the code style
    of this project.
  • My change requires a change to the documentation.
  • I have updated the documentation accordingly.
  • I have added tests to cover my changes.

Developed with the help of an AI coding agent (Claude Code), under human
review and direction throughout, including a live back-and-forth on how to
interpret the CI results and where to take the investigation next.

🤖 Generated with Claude Code

echoix and others added 2 commits September 4, 2026 09:42
test_file kept a NamedTemporaryFile handle open while passing its path
to r.kappa as the output file. Windows rejects a second process
opening a file while the creating process still holds the handle;
POSIX allows it. Use grass.script.core.tempfile() to get an unopened
path instead, the same fix already applied to r.mfilter's testsuite in
OSGeo#7878, and drop the now-unneeded @xfail_windows marker.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Windows CI ran pytest single-worker with a fixed test order, unlike
Linux CI's pytest-xdist + needs_solo_run split. Under a fixed order,
whichever test happens to run first in a given worker never changes,
so an order-dependent bug that only breaks the first call to a
C-based GRASS tool/library in a fresh worker process can stay hidden
indefinitely. Install pytest-xdist and pytest-randomly and mirror
Linux CI's parallel / needs_solo_run split so this class of bug
surfaces instead of hiding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
echoix and others added 5 commits September 4, 2026 09:45
Do not merge. Adds a workflow_dispatch-only job that runs the same
compile + pytest-xdist step as the permanent osgeo4w.yml change, as a
15-way parallel matrix, to fish for order-dependent Windows failures
across many independent pytest-randomly seeds at once. Delete this
file once the investigation concludes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Do not merge. Adds workflow_dispatch and a matrix "attempt" dimension
(1 on normal push/PR events, 1-15 on manual dispatch) to fish for
Windows order-dependent failures across many independent
pytest-randomly seeds at once, reusing the existing compile job
instead of a new (undispatchable) workflow file. Skips the gunittest
run, the thorough test pass, and the Codecov upload on dispatched
attempts, since only the randomized xdist step is relevant here.
Revert this commit once the investigation concludes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Do not merge. The CI job manually prepends GISBASE's lib/bin dirs to
PATH before invoking pytest, which real local usage never does (that
priming exists only in this CI step, not in how a user actually runs
pytest, nor in how grass.py's own startup works). Confirmed with the
user that their local repro of the hypothesis runs pytest without any
such priming, relying only on gs.setup.init() per test. Skip the
priming on the workflow_dispatch fishing attempts so they match that
setup instead of pre-empting the race being investigated. Revert
before merging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
discover_modules() always sorts directories and files alphabetically
during discovery, so python3 -m grass.gunittest.main (invoked once as
a single process for the whole testsuite tree, e.g. by
test_thorough.sh) always runs tests in the same fixed order. If some
test's success secretly depends on state left behind by whichever test
happens to run first in a directory (a plausible source of hidden,
order-dependent failures, particularly on Windows where native library
search state does not always propagate the way an environment variable
change would suggest), a fixed order means that dependency never gets
exercised differently and the bug stays invisible.

Add an opt-in GRASS_GUNITTEST_RANDOM_ORDER environment variable that
shuffles discovery order instead of sorting it, left off by default
until validated, mirroring how pytest-randomly is an opt-in plugin
rather than a default behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Do not merge. Re-enables the "Run tests" (test_thorough.bat) step on
the fishing dispatch attempts, setting GRASS_GUNITTEST_RANDOM_ORDER=1
only for those attempts, since this is where pygrass's direct ctypes
calls into compiled GRASS libraries are actually exercised (the
pytest-xdist suite mostly calls tools via subprocess, which the code
already handles correctly across process boundaries). Gives each
attempt's HTML report artifact a distinct name so the 15 parallel
uploads don't collide. Revert before merging.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reverts the temporary workflow_dispatch trigger, the "attempt" matrix
dimension, and the various skip conditions used to fish for
order-dependent Windows failures. Across 15 parallel attempts with
genuinely different pytest-randomly seeds and grass.gunittest
discovery orders, results were byte-for-byte identical every time (the
same 8 pre-existing, order-independent gunittest failures, already
catalogued elsewhere) — no order-dependent failure was found.

Keeps the permanent improvements this investigation validated as safe:
pytest-xdist and pytest-randomly for the pytest suite, and (matching
the same "run tests in random order" goal, now for the mechanism that
actually turned out to fit it) GRASS_GUNITTEST_RANDOM_ORDER=1 enabled
by default for the gunittest thorough-test run, so this class of bug
cannot hide behind a fixed test order going forward even though this
particular investigation didn't surface one today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@echoix echoix changed the title CI: Find and fix hidden Windows test-order-dependency bugs CI: Randomize Windows test order to guard against order-dependent bugs Sep 4, 2026
@echoix
echoix marked this pull request as ready for review September 4, 2026 17:37

echoix commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Split the r.kappa testsuite fix into its own PR: #694. Reverted it here (commit 5756390) so it doesn't land twice; this PR now only carries the Windows CI randomization work.


Generated by Claude Code

The NamedTemporaryFile fix for test_file was bundled into this PR
together with unrelated Windows CI changes. Split it into its own PR
(#694) so it can be reviewed and merged independently; revert it here
to avoid landing it twice.

This reverts commit 124fe83.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@echoix
echoix force-pushed the claude/windows-test-isolation-suv8k1 branch from 5756390 to aef4a43 Compare September 6, 2026 12:53

@echoix echoix left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Is there a cleaner way to have the random order in gunittest? Like a real option, with the possibility of giving the same seed again? Also, if tests are run not in order, can the generated test report be kept in order though, so it can be compared more easily between runs?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant