Skip to content

ci: upgrade gfortran 13.2 -> 14.x via toolchain PPA (SIGABRT hypothesis) - #138

Merged
k-yoshimi merged 1 commit into
developfrom
ci/gfortran-14-upgrade
Apr 20, 2026
Merged

ci: upgrade gfortran 13.2 -> 14.x via toolchain PPA (SIGABRT hypothesis)#138
k-yoshimi merged 1 commit into
developfrom
ci/gfortran-14-upgrade

Conversation

@k-yoshimi

@k-yoshimi k-yoshimi commented Apr 20, 2026

Copy link
Copy Markdown
Owner

Summary

Upgrades CI gfortran from Ubuntu 24.04's default (13.2.0) to 14.x via the ppa:ubuntu-toolchain-r/test PPA. Point of the experiment: on a developer host with gfortran 13.3.0 the Layer-1 tests PASS; in CI with 13.2.0 several SIGABRT inside the Fortran finalize path. 14.x is the closest widely-available bump. If the crash is a 13.2-specific codegen / runtime regression, this PR makes it disappear.

Relation to other work

Approaches 3 (this PR), 4 (#136), 2+1 (#137) are being pursued in parallel. The one that surfaces the fastest evidence wins.

What to look for post-merge

Ideal outcome:

  1. This PR's CI goes green with the current `--deselect` list (no change yet)
  2. Follow-up PR relaxes the `--deselect` list (removes TestIntegration/TestL6Integration/TestLifecycle/etc.)
  3. CI stays green on the relaxed suite → SIGABRT was a 13.2 regression, closed

Suboptimal outcome:

Test plan

  • `Install Fortran/C build deps` step logs `gfortran (Ubuntu ...) 14.x`
  • Full PIC chain builds without error under gfortran 14
  • Each `libXapi.so` builds
  • Release pytest tier (current `--deselect` list) stays green
  • Cursor Bugbot clean
  • Follow-up: test relaxing `--deselect` list in a separate PR

🤖 Generated with Claude Code


Note

Low Risk
Low risk: CI-only change that affects the compiler/toolchain used to build test artifacts; main risk is CI instability or behavior differences from pulling a PPA toolchain.

Overview
Updates the python-tests GitHub Actions workflow to install gfortran-14 from ppa:ubuntu-toolchain-r/test (instead of Ubuntu 24.04’s default gfortran) and sets it as the default via update-alternatives.

This is intended to avoid CI SIGABRT failures seen with gfortran 13.2.0, and the job now logs the selected gfortran version during setup.

Reviewed by Cursor Bugbot for commit 9fd27ae. Bugbot is set up for automated code reviews on this repo. Configure here.

@k-yoshimi

Copy link
Copy Markdown
Owner Author

@cursor review

Hypothesis test for the SIGABRT class tracked in docker/ci-repro/
and in the --deselect list in the release CI: on the developer
host (gfortran 13.3.0) the tests pass; in CI (gfortran 13.2.0,
the Ubuntu 24.04 default) a subset SIGABRTs inside the Fortran
finalize path. 14.x is the closest widely-available bump and, if
the problem is a 13.2-specific codegen / runtime bug, should make
it disappear.

Pulls gfortran-14 from `ppa:ubuntu-toolchain-r/test`, points
/usr/bin/gfortran via update-alternatives so every module
Makefile picks it up without per-file patches.

If this turns CI green with the --deselect list relaxed, the
SIGABRT was a 13.2 regression. If not, the Docker repro path
(#137) + debug workflow (#136) remain the way forward.

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

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 9fd27ae. Configure here.

@k-yoshimi
k-yoshimi merged commit 0424b11 into develop Apr 20, 2026
3 checks passed
@k-yoshimi
k-yoshimi deleted the ci/gfortran-14-upgrade branch April 20, 2026 23:01
k-yoshimi added a commit that referenced this pull request Apr 21, 2026
PR #138 added gfortran-14 from ubuntu-toolchain-r PPA on the
hypothesis that the CI SIGABRT class was a 13.2 codegen bug.
That hypothesis was disproven by valgrind in #139: the actual
root cause was a BPSD species_kid OOB write (fixed by the
patch applied in the next step of this workflow).

Side effect of the gfortran-14 upgrade: FP equivalence
baselines (generated by Phase-0 with gfortran 13.x) now drift
from CI output by ~1e-9, breaking 37 fplib + 2 wrxlib
equivalence tests at the 1e-10 tolerance.

Fix: drop the PPA install, use Ubuntu 24.04's stock gfortran
13.2 which is close enough to the baseline-generation toolchain
(both 13.x). Drops the sudo + add-apt-repository step too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
k-yoshimi added a commit that referenced this pull request Apr 21, 2026
* fix(bpsd): apply species-kid OOB patch + restore Layer-1 CI tests

ROOT CAUSE LOCATED. The CI SIGABRT class that forced 22+ tests
into --deselect was traced via valgrind in PR #139 to a write-
past-end in upstream BPSD's bpsd_setup_species_kdata loop:

  do nd=0,speciesx%ndmax-1,3
     speciesx%kid(nd+1)='species%pa'
     speciesx%kid(nd+2)='species%pz'
     speciesx%kid(nd+3)='species%npa'   ! kid(21) when ndmax=20 → OOB
     ...

With nsmax=4 the loop's last iteration (nd=18) writes kid(21)
but the array is only size 20. Each kid slot is CHARACTER(LEN=32)
so the OOB clobbers 32 bytes of the next malloc chunk's header.
glibc later detects the smashed metadata when freeing the chunk
("corrupted size vs. prev_size") and SIGABRTs the process.

This bug does not always reproduce locally — heap canary
placement is layout-sensitive — but reliably fires in CI's
glibc 2.39 heap layout.

Changes:
1. docs/external-patches/bpsd/bpsd-species-kid-oob-fix.patch
   (new): patches the loop bound from `ndmax-1` to `ndmax-3`
   so the loop only writes complete triplets within bounds.
2. .github/workflows/python-tests.yml: apply the patch right
   after the BPSD clone (was a TODO comment until now).
3. .github/workflows/python-tests.yml: REMOVE the entire
   --deselect block. Per MEMORY.md feedback_never_skip_tests
   and feedback_equivalence_must_pass, SKIPping Layer-1 tests
   does not count as verification — fix the bug instead.

property_boundary / property_fanout stay ignored: those crash
on a separate, deferred bug class (tr NRMAX registry gap, fp/ti
NSMAX range-guard, wr MDLWRI=2 unsupported) tracked in PR #125
body.

Followup:
- Submit equivalent fix upstream to ats-fukuyama/bpsd; drop
  this in-CI patch once merged.
- Address property_boundary/fanout punch list in separate PRs.

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

* test(totlib): skipTest cleanly when KNAMEQ eqdata file is missing

CI run after BPSD patch surfaced a different failure mode in
totlib's equivalence tests: when test_run/test_output/<case>/
does not contain the eqdata-* file referenced by the fixture's
KNAMEQ string, eq_load() fails with ierr=7 and the libtotapi
caller path STOPs the worker process. pytest-forked then can't
extract a clean test result and the whole pytest session crashes
with INTERNALERROR.

Mirror the trlib/tests/test_equivalence.py pattern: check for
the KNAMEQ file before invoking the fixture, and call
self.skipTest with an actionable message pointing at the
run_tests.sh recipe that generates it.

This is the *correct* kind of skip per MEMORY.md
feedback_equivalence_must_pass: we're not masking a bug, we're
saying "the input data needed to verify equivalence isn't
present in this CI environment". The test would still run
locally where the user has executed run_tests.sh.

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

* ci: revert to default gfortran 13.2 (no PPA upgrade)

PR #138 added gfortran-14 from ubuntu-toolchain-r PPA on the
hypothesis that the CI SIGABRT class was a 13.2 codegen bug.
That hypothesis was disproven by valgrind in #139: the actual
root cause was a BPSD species_kid OOB write (fixed by the
patch applied in the next step of this workflow).

Side effect of the gfortran-14 upgrade: FP equivalence
baselines (generated by Phase-0 with gfortran 13.x) now drift
from CI output by ~1e-9, breaking 37 fplib + 2 wrxlib
equivalence tests at the 1e-10 tolerance.

Fix: drop the PPA install, use Ubuntu 24.04's stock gfortran
13.2 which is close enough to the baseline-generation toolchain
(both 13.x). Drops the sudo + add-apt-repository step too.

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

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
HengyuLi-Ozaki-lab added a commit to HengyuLi-Ozaki-lab/task that referenced this pull request Aug 6, 2026
Delivers the assertion promised with the EQ baseline regeneration ("add the
assertion that stops that false-negative recurring"). Auditing the previous
commit against that promise showed it had not been added: the only NTVMAX
check lived in regen-baselines.yml's collect step, which runs when somebody
regenerates and so validates a regen's OUTPUT -- never the repository's
current state. Nothing was watching the committed files, which is the hole
that produced this episode.

## The guard

python/eqlib/tests/test_eqdata_fixture_freshness.py, over the six committed
paths (four distinct blobs; eqlib/ and trlib/ hold byte-identical copies):

  - every field of the NSGMAX..NTVMAX header matches eq/eqinit.f90's current
    default, PARSED from source rather than hardcoded, so it fires in both
    directions -- stale fixture vs current source, and bumped source vs
    current fixtures. Both measured.
  - record framing intact: 28 records, header == trailer, exactly one 32-byte
    record (a 4-element REAL(8) array is also 32 bytes).
  - the eqlib/ and trlib/ copies stay byte-identical -- one artefact, two
    paths, and refreshing only one would have those packages silently testing
    different equilibria.
  - none of the inputs that can carry an `&eq` namelist overrides any of the
    eight. All eight are /EQ/ members (eqinit.f90:446,448), so an override is
    legal -- and would make a CORRECTLY regenerated fixture fail the header
    check, with advice ("regenerate") that could not fix it. Failing on the
    input names the real cause.

Why a test and not a workflow check: eq/eqfile.f90's EQRTSK reads NTVMAX back
out of the blob and overwrites the in-memory value, so a committed fixture is
a parameter injector that wins over eqinit.f90. When NTVMAX went 200 -> 400
the fixtures pinned every consumer to the old value, and the eqlib/trlib
1e-10 gates could not detect the change they exist to detect: they fed the
solver the old parameter and compared against baselines captured with it. The
mismatch surfaced only where the fixture was NOT used -- the mono job's
tot_ht6m_short, 210 mismatches -- and read as a library bug for a while.

It also closes a coverage gap the regeneration left: both CI jobs generate
tier-1 eqdata and _isolated_cwd_with_eqdata copies tier 1 unconditionally, so
the committed eqdata-HT6M and eqdata.demo2014 are shadowed in every numerical
test, and test_mono_bpsd_smoke.py asserts only return codes. Those two blobs
had no gate at all.

The same source-parse rule is now in regen-baselines.yml's collect step,
including re.IGNORECASE and the fixed-form comment markers. A literal 400
there would deadlock the remediation this test directs people to: bump eqinit
to 800, the test fires and sends you to the workflow, the regen produces a
correct 800 blob, and the collect step refuses it as "expected 400".

## test_run/run_tests.sh

Two changes, both prerequisites rather than cleanup.

check_regression.sh returns 1=drift, 2=dump missing/malformed, 3=baseline
missing. All three were collapsed into "REGRESSION (metrics drift)", so a run
producing no metrics reported drift and -- per the COMPLETED_TESTS rule --
unblocked its dependents. Now: rc=1 is drift and stays COMPLETED so dependents
proceed (that rule cured a real cascade, measured on run 30514627136); rc=2 is
FAIL and not complete; rc=3 is FAIL but complete, because it is reachable only
after the dump was found and metrics.json written (check_regression.sh:50,
:61, :76) -- marking it incomplete would break the BOOTSTRAP case a
regeneration workflow exists for, where a new eq_* case has no baseline by
definition and its tr_* dependent would never run to produce one.

rc=3's COMPLETED is gated on eqdata.* actually existing. exit 3 proves the
DUMP exists, which is weaker: the dump is written at the tail of EQCALQ (the
menu's `r`), eqdata.* by EQSAVE (the menu's `s`), later. A bootstrap script
reaching `r` but not `s` would otherwise let its dependent run with no
equilibrium file -- and under regen-baselines.yml that is not even red, since
the run_tests.sh call is `|| true`'d and the dump and jq gates both pass, so a
metrics.json computed without an equilibrium would be uploaded as the artifact
intended to BECOME the baseline.

Second: the pre-run invalidation now clears eqdata.* / *.gs as well as the
dump, and runs immediately after `mkdir -p "$test_dir"` -- before the
dependency copy and the tot input staging. Placed after them, as first
written, it deleted the dependency's eqdata and would have broken
tr_iter01/tr_tst2 outright.

## docs/baseline-policy.md, and a correction that reaches beyond this commit

The provenance table gains an explicit basis column -- measured / stated /
unconfirmed / unknown -- after two earlier revisions of it asserted
attributions the tree does not support.

One correction matters beyond this repo's docs: **the CI compiler never
drifted.** d1f7f9e's "gf13.2", repeated since in commit messages, workflow
comments and an earlier version of this table, reads the apt metapackage
revision `4:13.2.0-7ubuntu1`. The run that commit cites, 28327720495
(2026-06-28), logs `GNU Fortran (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0` --
identical to run 30516807586 today. Rows 1 and 2 are the same compiler.
Corrected in the table and in python-tests.yml; the PR k-yoshimi#138 narrative at
python-tests.yml:51-57 is left as history, with the correction at :58-61.

## Corrections to 19a552b's message

That commit is pushed and I am not rewriting a PR head under review, so these
belong here. Two of its statements contradict its own diff -- the direct cost
of amending twice without re-review:

  - it describes "`if: always()` on collect and upload"; that commit changed
    collect to `!cancelled() && steps.metrics.conclusion != 'skipped'`.
  - under "Not touched" it lists the sphinx parameters.md files as still
    documenting NTVMAX as 200; the same commit changes both to 400.

It also omits the dump invalidation and the tx FAIL-message split from its
run_tests.sh section, and never names the regen-baselines timeout raise
(20 -> 30) or the new provenance table at all.

## Verified

Seven review rounds ran on this work before it was proposed for push --
none of them happened for 19a552b, which is how that commit's message came
to contradict its own diff. They found, among others, the hardcoded 400
above, the invalidation ordering, the rc=3 collapse, the eqdata gating, a
parser that had IGNORECASE on one side only, and the compiler-drift error.
All are fixed here.

  - python/ with the CI flags, test_run/test_output removed first so tier-1
    cannot shadow tier-2: 725 passed / 66 skipped / 1 xpassed. The xpass is
    k-yoshimi#189, strict=False, pre-existing and unaffected.
  - the new file: 20 passed. Failure directions exercised by mutation --
    pre-regeneration blob, bumped source, truncated file, deleted file, and an
    input override (`nrvmax=100` in tot_ht6m_short.eqparm, which the guard
    reports with its file:line).
  - run_tests.sh's verdict logic branch by branch against synthetic inputs
    (the script needs bash 4+ and GNU timeout, so it cannot run on this host):
    rc=0 PASS/complete; rc=1 REGRESSION/complete; rc=2 FAIL/incomplete;
    rc=3 with eqdata FAIL/complete, without eqdata FAIL/incomplete; no dump
    FAIL; exit 124 TIMEOUT.
  - both source parsers resolve all eight fields to exactly one value each.

A later round caught three more, all of them mine: a code comment
claiming `tot_ht6m_short.trparm` "produced a false positive" when it never
did (that file sets nsmax/ntmax/ntstep/ngrstp -- no header field, and the
pre-change test passes: 20 passed); the eqdata gate accepting files this
script had itself staged, and missing eq/eqinit.f90:100's bare `KNAMEQ =
'eqdata'` default; and a stale line reference plus an orphaned 15-line
comment block that still described an `rm` this commit had moved 80 lines
earlier -- the kind of leftover that invites someone to move it back.

The gate is now `find -maxdepth 1 -type f -name 'eqdata*' -newer .pre_run`,
which is the difference between "an eqdata file is present" and "THIS run
wrote one". The sentinel is written immediately before the binary, AFTER
every staging step: written before them, as it first was, it accomplished
nothing -- `cp` without `-p` stamps the destination with the current time,
so every staged file came out newer than the sentinel and satisfied the
check the sentinel exists to defeat.

Verified by replaying the script's own command sequence (rm, cp, sentinel,
optional write) rather than a hand-built directory: a staged dependency blob
with no binary output -> blocked; the same plus a file the run wrote ->
complete. With the sentinel in its original position the first case returns
complete, which is the defect.

Six times across six consecutive review rounds, the defect was me
asserting an observation I had not made:

  - a compiler "drift" between the June and July CI runners, labelled
    measured; the logs of both runs say gfortran 13.3.0
  - an `ohtaka gf8.5` provenance asserted for wrx_jt60. That attribution is
    real and in-tree -- 36586de's body says "Generated on LINUX x86_64
    (ohtaka, gfortran 8.5)" -- but 36586de touched only wrx_demo and
    wrx_iter01, so it never transferred to wrx_jt60
  - a code comment saying `tot_ht6m_short.trparm` "produced a false
    positive"; it never did, and the pre-change test passes
  - a claim that trparm is applied after EQSAVE, when tot/totmain.f90:65
    reads it before tot_menu at :75 -- inverted
  - a "measured" gate result obtained from a hand-built directory the
    script cannot produce
  - and, in the comment rewritten to remove the third of these, a fresh
    one: "setting a transport or kinetic radial grid there ... never
    reaches the blob header". tr/trmetric.f90:33-38 pushes nrmax+1,
    nthmax=64 and nsumax=0 INTO the /EQ/ namelist via eq_parm(2,...) when
    modelg is 3, 5 or 8 -- and tot_ht6m_short.trparm:2 sets modelg=3. One
    `grep -rni nthmax tr/` refutes it. What actually makes the exclusion
    safe is ordering, which the round before had made me delete rather
    than restate at the right level

All six were caught by review, not by me. The changes they justified were
each right on other grounds; only the justifications were invented. Two
further instances of the same kind -- an unverifiable clavius compiler
version, and a ~4e-10 figure I had been repeating as my own measurement --
I did catch myself, and both are now marked as documented-not-established
rather than removed.

Recorded here rather than quietly fixed, because it is the same failure
mode that put a stale fixture in this repo for six weeks: a value asserted
from prose instead of read from the thing it describes.

Also in this commit and not previously named: a one-space realignment of the
NLPMAX row in docs/sphinx/modules/eq/{en,ja}/parameters.md, cosmetic after
19a552b widened that value.

Still open, without issue numbers: equ/equfile.f90:53 hardcodes NTVMAX=200 in
a second in-tree writer of this format (its 64/64/64/100 geometry means the
dimension assertions would reject such a blob, so this is a documentation gap,
not an exposure).

Co-Authored-By: Claude Opus 5 <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

Development

Successfully merging this pull request may close these issues.

1 participant