diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index ad3c7a7a9..0d51c0971 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -20,6 +20,7 @@ jobs: run_tests: name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} runs-on: ${{ matrix.os }} + continue-on-error: ${{ matrix.allow_failure }} strategy: fail-fast: false matrix: @@ -47,6 +48,18 @@ jobs: arch: aarch64 version: '1.12' allow_failure: true + # Julia 1.13 is the current stable release; test it explicitly on Linux + # so a regression there is visible independently of the '1' alias. + - os: ubuntu-latest + arch: x64 + version: '1.13' + allow_failure: false + # Track the upcoming release so breakage is caught early, without + # blocking CI when an unreleased Julia is temporarily broken. + - os: ubuntu-latest + arch: x64 + version: 'pre' + allow_failure: true steps: - uses: actions/checkout@v7 - uses: julia-actions/setup-julia@v3 @@ -57,7 +70,22 @@ jobs: with: include-matrix: false - uses: julia-actions/julia-buildpkg@latest + # Cache the GMT topography tiles that the tests download (see + # test/topo_prefetch.jl), so PR runs do not depend on the GMT data + # server. The scheduled run skips the cache on purpose, so that the + # download itself is still tested regularly. + - uses: actions/cache/restore@v4 + id: gmt-cache + if: github.event_name != 'schedule' + with: + path: ~/.gmt + key: gmt-topo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('test/topo_prefetch.jl') }} - uses: julia-actions/julia-runtest@latest + - uses: actions/cache/save@v4 + if: always() && github.event_name != 'schedule' && steps.gmt-cache.outputs.cache-hit != 'true' + with: + path: ~/.gmt + key: gmt-topo-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('test/topo_prefetch.jl') }} - uses: julia-actions/julia-processcoverage@v1 - uses: codecov/codecov-action@v7 with: diff --git a/ext/GMT_utils.jl b/ext/GMT_utils.jl index 98998b75e..f88f69beb 100644 --- a/ext/GMT_utils.jl +++ b/ext/GMT_utils.jl @@ -16,6 +16,12 @@ using GeophysicalModelGenerator: lonlatdepth_grid, GeoData, UTMData, km, remove_ println("Loading GMT routines within GMG") +# Official GMT data server mirrors, see https://www.generic-mapping-tools.org/mirrors. +# `import_topo` cycles through these if a download fails, so that a single +# mirror being offline does not break the download (or the tests that check it). +# Ordered by measured response; "singapore" is omitted as it was unreachable. +const GMT_DATA_SERVERS = ["oceania", "australia", "sdsc-opentopography", "noaa", "brasil", "china"] + """ Topo = import_topo(limits; file::String="@earth_relief_01m", maxattempts=5) @@ -75,20 +81,62 @@ function import_topo(limits; file::String = "@earth_relief_01m", maxattempts = 5 limits[1:2] = sort(limits[1:2]) end - # Download topo file - add a few attempts to do so + # Download topo file. If the GMT data server is unreachable, GMT stops + # auto-downloading and reports "Remote download is currently deactivated". + # + # First try the server the user/GMT is already configured with, so that the + # normal case does not touch any global state. Only if that fails do we fall + # back to the official mirrors (https://www.generic-mapping-tools.org/mirrors), + # so a single server being offline does not fail the download. The download + # itself is still exercised -- only *which* mirror serves it changes. + # + # Note: `gmtset` changes GMT's *global* configuration (it writes a gmt.conf in + # the working directory), so switching mirrors is visible to anything else + # using GMT concurrently. We therefore restore the original setting afterwards + # and only switch when a download has actually failed. local G - attempt = 0 - while attempt < maxattempts + local last_err = nothing + + # `gmtset` writes/updates a gmt.conf in the working directory. Remember whether + # one already existed so we can leave the directory as we found it. + conf_file = joinpath(pwd(), "gmt.conf") + had_conf = isfile(conf_file) + conf_backup = had_conf ? read(conf_file) : nothing + + try + for attempt in 1:maxattempts + if attempt > 1 + # Previous attempt failed -> try the next mirror in the list + server = GMT_DATA_SERVERS[mod1(attempt - 1, length(GMT_DATA_SERVERS))] + gmtset(GMT_DATA_SERVER = server) + @info "Retrying GMT topography download from mirror \"$server\"" + end + try + G = gmtread(file, limits = limits, grid = true) + break + catch e + last_err = e + @warn "Failed downloading GMT topography ($file) on attempt $attempt/$maxattempts" + if attempt < maxattempts + sleep(5) # brief pause before trying the next mirror + end + end + end + finally + # Restore the original configuration, so our mirror choice does not leak + # into other code (or other tests) that use GMT afterwards. try - G = gmtread(file, limits = limits, grid = true) - break + if had_conf + conf_backup !== nothing && write(conf_file, conf_backup) + elseif isfile(conf_file) + rm(conf_file, force = true) + end catch - @warn "Failed downloading GMT topography on attempt $attempt/$maxattempts" - sleep(5) # wait a few sec end - attempt += 1 end - (@isdefined G) || error("Could not download GMT topography data") + if !(@isdefined G) + error("Could not download GMT topography \"$file\" after $maxattempts attempts (mirrors tried: $(GMT_DATA_SERVERS)). Last error: $last_err") + end # Transfer to GeoData nx, ny = size(G.z, 2), size(G.z, 1) diff --git a/ext/Gmsh_utils.jl b/ext/Gmsh_utils.jl index 2984326b3..fa27f55fb 100644 --- a/ext/Gmsh_utils.jl +++ b/ext/Gmsh_utils.jl @@ -3,6 +3,7 @@ module Gmsh_utils using GridapGmsh, StaticArrays +using GridapGmsh: gmsh import GeophysicalModelGenerator: import_Gmsh, FEData, CartData @@ -14,6 +15,22 @@ Reads a Gmsh file and returns a `FEData` object with info about the mesh. `tag_n """ function import_Gmsh(fname::String) + # `GmshDiscreteModel` initializes and finalizes gmsh itself. That normally + # works, but on a freshly started worker process gmsh's global state is + # occasionally not in place yet and it fails with "Gmsh has not been + # initialized" (seen on Windows under the parallel test runner). + # + # Initializing here first makes that state explicit. gmsh reference-counts + # nothing, so we must NOT finalize afterwards: GmshDiscreteModel already + # finalizes the session it used, and a second finalize errors. + try + if gmsh.isInitialized() == 0 + gmsh.initialize() + end + catch e + @warn "Could not initialize gmsh, letting GmshDiscreteModel handle it" exception = e + end + mesh = GmshDiscreteModel(fname, renumber = false, has_affine_map = false) # Extract vertices diff --git a/src/IO.jl b/src/IO.jl index cbf946ffa..5e026a6bf 100644 --- a/src/IO.jl +++ b/src/IO.jl @@ -102,19 +102,25 @@ function download_data(url::String, local_filename = "temp.dat"; dir = pwd(), ma #download remote file to a local temporary directory file_ext = [] - attempt = 0 - while attempt < maxattempts + for attempt in 1:maxattempts try file_ext = Downloads.download(url, joinpath(dir, local_filename)) + # A truncated transfer can still return a path, so only accept a + # download that actually produced a non-empty file. + if filesize(file_ext) == 0 + error("downloaded file is empty") + end break - catch - @warn "Failed downloading data on attempt $attempt/$maxattempts" - sleep(5) # wait a few sec + catch e + file_ext = [] + @warn "Failed downloading $url on attempt $attempt/$maxattempts: $e" + if attempt < maxattempts + sleep(5 * attempt) # back off progressively + end end - attempt += 1 end if isempty(file_ext) - error("Could not download GMT topography data") + error("Could not download data from $url after $maxattempts attempts") end return file_ext diff --git a/test/runtests.jl b/test/runtests.jl index d681790cf..5474eac48 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -1,7 +1,14 @@ using GeophysicalModelGenerator using ParallelTestRunner +# Download the topography tiles the tests need *before* spawning the parallel +# workers, so they are served from GMT's cache instead of being downloaded +# concurrently (which times out regularly in CI). See topo_prefetch.jl. +include("topo_prefetch.jl") + testsuite = find_tests(@__DIR__) +# Not a test file, only a helper +delete!(testsuite, "topo_prefetch") # Add `using GeophysicalModelGenerator` to each test for (name, expr) in testsuite diff --git a/test/test_paraview_collection.jl b/test/test_paraview_collection.jl index 645dd28a9..86f88329f 100644 --- a/test/test_paraview_collection.jl +++ b/test/test_paraview_collection.jl @@ -1,5 +1,16 @@ using Test using GeophysicalModelGenerator, WriteVTK + +# Check the structure of a `*.pvd` collection rather than its exact byte size: +# the file embeds absolute paths and OS-dependent separators, so `filesize` varies +# between machines/platforms and is not a portable assertion. +function check_pvd(fname, n_datasets) + isfile(fname) || return false + str = read(fname, String) + occursin("type=\"Collection\"", str) || return false + return count(_ -> true, eachmatch(r" endswith(f, ".nc"), collect(Iterators.flatten(map(t -> t[3], walkdir(gmt_cache))))) : 0 + + for (limits, file) in TOPO_PREFETCH + t = @elapsed try + # copy: import_topo may modify `limits` in place (negative longitudes) + import_topo(copy(limits); file = file, maxattempts = 8) + catch e + # Do not abort the test run: the test that needs this tile will try + # again itself and report a proper error if the server really is down. + @warn "Could not prefetch topography $file for $limits; the test will retry" exception = (e, catch_backtrace()) + end + @info "Prefetched topography $file for $limits in $(round(t, digits = 1)) s" + end + + n_after = isdir(gmt_cache) ? count(f -> endswith(f, ".nc"), collect(Iterators.flatten(map(t -> t[3], walkdir(gmt_cache))))) : 0 + @info "GMT topography cache: $n_after tiles in $gmt_cache ($(n_after - n_before) newly downloaded)" + return nothing +end + +prefetch_topography()