]> git.ipfire.org Git - thirdparty/freeradius-server.git/commitdiff
ci: collect core dumps as artifacts, keep them out of the profiling publish
authorArran Cudbard-Bell <a.cudbardb@freeradius.org>
Sun, 26 Jul 2026 01:19:53 +0000 (19:19 -0600)
committerArran Cudbard-Bell <a.cudbardb@freeradius.org>
Sun, 26 Jul 2026 01:19:53 +0000 (19:19 -0600)
Most suites run with allow_core_dumps = yes, but nothing in CI ever collected
a core, so the only record of a crash the logs cannot reconstruct was thrown
away with the runner.

scripts/ci/collect-core-dumps.sh stages cores into ci-core-dumps/, preserving
each path below the searched root so the suite that crashed stays identifiable.
The new .github/actions/collect-core-dumps composite action calls the script and
uploads the result, and ci, ci-sanitizers, ci-macos, ci-deb, ci-rpm and
ci-multi-server-tests all use it. Collection runs on success as well as failure:
FreeRADIUS crashing during shutdown does not necessarily fail the suite that
provoked the crash, which is how run 30180044334 reported success while dumping
two 1.5G cores.

publish-profiling-results.sh now drops cores from the store tarball. Those two
cores turned a 6M publish into 35M of mostly-zero pages, the store's synchronous
ingest then ran ~64s, and wproxy's 60s proxy_read_timeout returned a 504 for a
publish that had in fact landed. The multi-server leg passes move: true so a
core leaves prof-results/ and rides one artifact rather than inflating the
prof-results artifact too. Exclusions are always logged with their sizes; a
silently shrunk publish would be worse than a noisy one.

The shared name patterns live in scripts/ci/core-dump-names.inc so the script
that keeps cores and the script that drops them cannot disagree.

Not covered: ci-freebsd, whose VM syncs runner-to-vm only, and the fuzzing leg,
which has its own crash artifacts. Whether a core is written at all still
depends on the ambient RLIMIT_CORE and kernel.core_pattern, neither of which CI
sets; allow_core_dumps only restores the limit FreeRADIUS inherited.

.github/actions/collect-core-dumps/action.yml [new file with mode: 0644]
.github/workflows/ci-deb.yml
.github/workflows/ci-macos.yml
.github/workflows/ci-multi-server-tests.yml
.github/workflows/ci-rpm.yml
.github/workflows/ci-sanitizers.yml
.github/workflows/ci.yml
.gitignore
scripts/ci/collect-core-dumps.sh [new file with mode: 0755]
scripts/ci/core-dump-names.inc [new file with mode: 0644]
scripts/ci/publish-profiling-results.sh

diff --git a/.github/actions/collect-core-dumps/action.yml b/.github/actions/collect-core-dumps/action.yml
new file mode 100644 (file)
index 0000000..d553443
--- /dev/null
@@ -0,0 +1,80 @@
+name: collect-core-dumps
+
+#
+#  Stage any core dump the job produced and upload it as an artifact.
+#
+#  Most of our suites run with allow_core_dumps = yes, and a core is the only
+#  record of a crash the logs cannot reconstruct. Nothing collected them
+#  before, so they were discarded with the runner.
+#
+#  Call with `if: ${{ always() }}`, for two reasons: a crashing suite has
+#  usually failed by the time this runs, and a crash on shutdown does not
+#  necessarily fail the suite that provoked it (multi-server profiling reported
+#  success on 2026-07-25 while dumping two 1.5G cores).
+#
+#  Whether a core is written at all still depends on the ambient RLIMIT_CORE
+#  and kernel.core_pattern, neither of which CI sets. allow_core_dumps = yes
+#  only restores the limit FreeRADIUS inherited (fr_set_dumpable() in
+#  src/lib/util/debug.c), it does not raise it. So this collects whatever was
+#  produced rather than guaranteeing a core exists.
+#
+
+inputs:
+  name:
+    description: >
+      Artifact name suffix, giving "core-dumps-<name>". Pass whatever
+      distinguishes this matrix leg, since names must be unique within a run.
+    required: true
+  roots:
+    description: >
+      Directories to search, space separated, relative to working-directory.
+      Pass ". /cores" on macOS, which writes cores there.
+    default: "."
+  move:
+    description: >
+      Move cores out of the searched tree rather than copying them, for when
+      the tree is itself uploaded or published. Action inputs are strings, so
+      this is "true"/"false" text however the caller writes it.
+    default: "false"
+  working-directory:
+    description: >
+      Where to run the collection, relative to the workspace root. Use
+      "freeradius" for the jobs that check out into a subdirectory.
+    default: "."
+  retention-days:
+    description: How long to keep the artifact.
+    default: "30"
+
+runs:
+  using: composite
+
+  steps:
+
+    #  collect-core-dumps.sh always exits 0: a leg's result is decided by its
+    #  tests, not by whether core collection found anything.
+    - name: Collect core dumps
+      shell: bash
+      working-directory: ${{ inputs.working-directory }}
+      run: |
+        set -eu
+        #  Accept either spelling rather than betting on how YAML stringifies a
+        #  bare true/false, and reject anything else instead of silently not
+        #  moving.
+        case "${{ inputs.move }}" in
+        true|True|TRUE)    args="-m" ;;
+        false|False|FALSE) args="" ;;
+        *)
+          echo "collect-core-dumps: move must be true or false, got '${{ inputs.move }}'" >&2
+          exit 1
+          ;;
+        esac
+        #  inputs.roots is deliberately unquoted: it is a space separated list.
+        ./scripts/ci/collect-core-dumps.sh $args ${{ inputs.roots }}
+
+    - name: Upload core dumps
+      uses: actions/upload-artifact@v6
+      with:
+        name: core-dumps-${{ inputs.name }}
+        path: ${{ inputs.working-directory }}/ci-core-dumps/
+        if-no-files-found: ignore
+        retention-days: ${{ inputs.retention-days }}
index 286e25248fe3f15bacc430c57d97e6ac8c04e437..92b18ae62a8a3210c43ba58ae844970357b5bbed 100644 (file)
@@ -253,6 +253,15 @@ jobs:
         make -f scripts/ci/package-test.mk package-test
       working-directory: freeradius
 
+    #  This job checks out into freeradius/, so the action lives below that path
+    #  and the collection runs from there.
+    - name: Collect core dumps
+      if: ${{ always() }}
+      uses: ./freeradius/.github/actions/collect-core-dumps
+      with:
+        name: ${{ matrix.env.NAME }}
+        working-directory: freeradius
+
     - name: Upload radius logs on failure
       if: ${{ failure() }}
       uses: actions/upload-artifact@v6
index 03f234e9e5aaac67d938e37d6e3a1bac6c6a7c2e..fd00c3ba22d646d23a1433c0b6b8ef1b59c794bf 100644 (file)
@@ -208,6 +208,15 @@ jobs:
       env:
         ASAN_OPTIONS: symbolize=1 detect_stack_use_after_return=1
 
+    #  macOS writes cores to /cores rather than next to the crashing process,
+    #  so search that directory as well as the checkout.
+    - name: Collect core dumps
+      if: ${{ always() }}
+      uses: ./.github/actions/collect-core-dumps
+      with:
+        name: ${{ matrix.env.NAME }}
+        roots: ". /cores"
+
     #
     #  If the CI has failed and the branch is ci-debug then we start a tmate
     #  session to provide interactive shell access to the session.
index c4c639928c03686e7026129dc5fb7a2a7526af01..c525d580e533b3879015e473f92999345304f629 100644 (file)
@@ -205,6 +205,16 @@ jobs:
               ;;
           esac
 
+      #  Runs before both uploads below, with move: true so a core leaves
+      #  prof-results/ and rides one artifact instead of inflating the
+      #  prof-results artifact and the store publish as well.
+      - name: Collect core dumps
+        if: ${{ always() }}
+        uses: ./.github/actions/collect-core-dumps
+        with:
+          name: ${{ matrix.mode }}-${{ github.sha }}
+          move: "true"
+
       #
       #  Stash callgrind output as a workflow artifact in profiling
       #  mode. Skipped in service mode (nothing to upload).
index f0608094832b8abb0791b563b57d157518db204c..172a2a5a5c1adcb10b567b2f9997edc0b1cdb28f 100644 (file)
@@ -235,6 +235,15 @@ jobs:
         make -f scripts/ci/package-test.mk package-test
       working-directory: freeradius
 
+    #  This job checks out into freeradius/, so the action lives below that path
+    #  and the collection runs from there.
+    - name: Collect core dumps
+      if: ${{ always() }}
+      uses: ./freeradius/.github/actions/collect-core-dumps
+      with:
+        name: ${{ matrix.env.NAME }}
+        working-directory: freeradius
+
     - name: Upload radius logs on failure
       if: ${{ failure() }}
       uses: actions/upload-artifact@v6
index e8f0de90d0fcf38fb11b2283120c911075f90ffd..13b2910456306114c020b61f9615807c7efdd8ee 100644 (file)
@@ -203,6 +203,12 @@ jobs:
           smtp_test_server: 127.0.0.1
           smtp_test_server_port: 2525
 
+      - name: Collect core dumps
+        if: ${{ always() }}
+        uses: ./.github/actions/collect-core-dumps
+        with:
+          name: ${{ matrix.os.name }}-${{ matrix.env.NAME }}
+
       #
       #  If the CI has failed and the branch is ci-debug then we start a tmate
       #  session to provide interactive shell access to the session.
index 656b32e4a9179ef0419ecc8f8e3e12efcc145733..11d82994d43c9ea0635ba48f67658c431ff7d278 100644 (file)
@@ -212,6 +212,12 @@ jobs:
           smtp_test_server: 127.0.0.1
           smtp_test_server_port: 2525
 
+      - name: Collect core dumps
+        if: ${{ always() }}
+        uses: ./.github/actions/collect-core-dumps
+        with:
+          name: ${{ matrix.os.name }}-${{ matrix.env.NAME }}
+
       - name: Run fuzzer
         uses: ./.github/actions/fuzzer
 
index 42f4e66905a9918e40131894f6778e2260c54709..0921e95c11ae6d4ed11d267581935e0cedf7d086 100644 (file)
@@ -101,3 +101,6 @@ build-2f*
 
 # Profiling results
 prof-results/
+
+# Core dumps staged for upload by scripts/ci/collect-core-dumps.sh
+ci-core-dumps/
diff --git a/scripts/ci/collect-core-dumps.sh b/scripts/ci/collect-core-dumps.sh
new file mode 100755 (executable)
index 0000000..29bd2d3
--- /dev/null
@@ -0,0 +1,142 @@
+#!/bin/sh
+#
+#  Stage any core dumps found under the given roots into one directory, so a
+#  workflow can upload them as an artifact.
+#
+#  Most of our test suites run with "allow_core_dumps = yes", so a crash can
+#  leave a core behind. A core is the only record of a crash that the logs
+#  cannot reconstruct, and nothing in CI collected them before, so they were
+#  discarded with the runner. Collection runs regardless of whether the suite
+#  passed: FreeRADIUS crashing during shutdown does not necessarily fail the
+#  suite that provoked the crash (multi-server profiling did exactly that on
+#  2026-07-25, reporting success while dumping two 1.5G cores).
+#
+#  Whether a core is written at all still depends on the ambient RLIMIT_CORE
+#  and kernel.core_pattern, which CI does not currently set;
+#  "allow_core_dumps = yes" only restores the limit FreeRADIUS inherited
+#  (fr_set_dumpable() in src/lib/util/debug.c), it does not raise it. So this
+#  script collects what happens to be produced rather than guaranteeing a core
+#  exists.
+#
+#  Never fails: a CI leg's result is decided by its tests, not by whether core
+#  collection found anything. Always reports what it staged, so an empty result
+#  reads as genuinely empty rather than as a step that quietly did nothing.
+#
+
+set -u
+
+self_dir=$(dirname "$0")
+outdir=ci-core-dumps
+move=no
+
+usage()
+{
+       cat <<EOF
+Usage: ${0##*/} [-m] [-o <outdir>] [root ...]
+
+Stage core dumps found under each <root> into <outdir>, preserving their path
+below the root so the suite that crashed stays identifiable. Always exits 0.
+
+  -o <outdir>  Where to stage cores. Default "ci-core-dumps".
+  -m           Move cores rather than copying them. Use when a root is itself
+               uploaded or published, so a core lands in one place only.
+  -h           Show this help.
+  root ...     Directories to search. Default the current directory.
+
+Examples:
+  ${0##*/}                  # search the working tree
+  ${0##*/} . /cores         # also search /cores, as macOS uses
+  ${0##*/} -m prof-results  # empty cores out of a tree due to be uploaded
+EOF
+}
+
+while [ $# -gt 0 ]; do
+       case $1 in
+       -o)
+               [ $# -ge 2 ] || { echo "${0##*/}: -o needs a directory" >&2; exit 2; }
+               outdir=$2
+               shift 2
+               ;;
+       -m)
+               move=yes
+               shift
+               ;;
+       -h|--help)
+               usage
+               exit 0
+               ;;
+       --)
+               shift
+               break
+               ;;
+       -*)
+               echo "${0##*/}: unknown option $1" >&2
+               usage >&2
+               exit 2
+               ;;
+       *)
+               break
+               ;;
+       esac
+done
+
+#  Default to the working tree. Tests run with their cwd inside the checkout,
+#  and a relative kernel.core_pattern writes the core next to the crashing
+#  process, so the checkout is where a core normally lands.
+[ $# -gt 0 ] || set -- .
+
+. "$self_dir/core-dump-names.inc"
+
+found=0
+staged=0
+
+for root in "$@"; do
+       [ -d "$root" ] || continue
+
+       #  A core can be big enough that a second copy matters on a runner disk,
+       #  so link it into place where the filesystem allows and only copy as a
+       #  fallback.
+       for core in $(find "$root" -type f 2>/dev/null | grep -E "$core_dump_re" | sort); do
+               found=$((found + 1))
+
+               #  Guard against a stray text file called "core": upload only what
+               #  really is a dump. Without file(1) the name is all we have, so
+               #  accept the candidate rather than discard possible evidence.
+               if command -v file >/dev/null 2>&1; then
+                       case $(file -b "$core" 2>/dev/null) in
+                       *core*) ;;
+                       *)
+                               echo "skipping $core (not a core dump)"
+                               continue
+                               ;;
+                       esac
+               fi
+
+               #  Keep the path below the root, so which suite crashed stays
+               #  readable in the artifact.
+               rel=$(printf '%s\n' "$core" | sed -e 's#^\./##' -e 's#^/##')
+               dest=$outdir/$rel
+               mkdir -p "$(dirname "$dest")" || continue
+
+               if [ "$move" = yes ]; then
+                       mv "$core" "$dest" || continue
+               else
+                       ln "$core" "$dest" 2>/dev/null || cp "$core" "$dest" || continue
+               fi
+
+               staged=$((staged + 1))
+               echo "staged $(du -h "$dest" | cut -f1 | tr -d ' ')     $rel"
+       done
+done
+
+if [ "$staged" -gt 0 ]; then
+       echo "collected $staged core dump(s) into $outdir ($(du -sh "$outdir" | cut -f1 | tr -d ' ') total)"
+else
+       echo "no core dumps found under: $*"
+fi
+
+if [ "$found" -ne "$staged" ]; then
+       echo "note: $((found - staged)) candidate(s) were skipped, see above"
+fi
+
+exit 0
diff --git a/scripts/ci/core-dump-names.inc b/scripts/ci/core-dump-names.inc
new file mode 100644 (file)
index 0000000..0922159
--- /dev/null
@@ -0,0 +1,22 @@
+#
+#  Core dump name patterns. Sourced, not run: no shebang, not executable.
+#
+#  Shared by collect-core-dumps.sh, which keeps cores, and
+#  publish-profiling-results.sh, which drops them, so the two can never
+#  disagree about what a core file looks like.
+#
+#  The patterns expect a "/" before the basename, which every "find" result
+#  under a root has. Basenames covered: "core", "core.<pid>", "<name>.core",
+#  "<name>.core.<pid>", and valgrind's "vgcore.<pid>".
+#
+#  One pattern per line rather than the equivalent single expression
+#  "/([^/]*\.)?(vg)?core(\.[^/]+)?$". That compact form is valid ERE and works
+#  under GNU and BSD grep, but ugrep matches nothing against it and says
+#  nothing about why. A silent miss here either leaks gigabyte cores into a
+#  publish or throws away the only evidence of a crash.
+#
+core_dump_re='/core$
+/core\.[^/]+$
+/[^/]*\.core$
+/[^/]*\.core\.[^/]+$
+/vgcore\.[^/]+$'
index e81c52c4dabc457906cc9addc8387fe07e054eaa..8d2e903946581da7ebecbc0ba2257c52f65219aa 100755 (executable)
@@ -9,11 +9,38 @@
 #  automatically when the job has "id-token: write"; the script uses them to
 #  mint an OIDC token whose audience is the target URL's origin.
 #
-#  Usage: publish-profiling-results.sh <url>
 
 set -eu
 
-[ $# -eq 1 ] || { echo "usage: $0 <url>" >&2; exit 2; }
+usage()
+{
+       cat <<EOF
+Usage: ${0##*/} <url>
+
+Tar prof-results/ and POST the tarball to <url> with a GitHub OIDC token.
+Core dumps are excluded; collect-core-dumps.sh keeps those instead. Run from
+the directory holding prof-results/. A missing prof-results/ tree, or one
+holding nothing publishable, is a quiet success.
+
+  <url>  Where to POST. Its origin becomes the OIDC audience.
+  -h     Show this help.
+
+Needs ACTIONS_ID_TOKEN_REQUEST_TOKEN and ACTIONS_ID_TOKEN_REQUEST_URL, which
+the runner sets when the job has "id-token: write".
+
+Example:
+  ${0##*/} https://cinfra-ca.inkbridge.io/profiling/data
+EOF
+}
+
+case ${1:-} in
+-h|--help)
+       usage
+       exit 0
+       ;;
+esac
+
+[ $# -eq 1 ] || { usage >&2; exit 2; }
 url=$1
 
 #  The OIDC audience is the URL's origin (scheme://host).
@@ -27,11 +54,38 @@ trap 'rm -rf "$tmpdir"' EXIT
 
 #  Tar an explicit file list (not the directory) so empty directories and
 #  editor droppings never travel.
+all_list="$tmpdir/all"
+core_list="$tmpdir/cores"
 file_list="$tmpdir/files"
 tarball="$tmpdir/prof-results.tar.gz"
-find prof-results -type f ! -name '.DS_Store' | sed 's#^prof-results/##' >"$file_list"
+#  Paths are kept with a leading "/" while filtering, so a basename is always
+#  preceded by "/" and the pattern below needs no "(^|/)" alternation, which
+#  not every grep implementation accepts. The slash comes off again when the
+#  tar file list is written.
+find prof-results -type f ! -name '.DS_Store' | sed 's#^prof-results##' | sort >"$all_list"
+
+#  Drop core dumps. A crash under valgrind dumps the whole address space, so
+#  one core is ~1.5G; two of them turned a 6M publish into 35M of mostly-zero
+#  pages and pushed the store's synchronous ingest past the 60s gateway read
+#  timeout, failing the CI leg on a publish that had in fact landed. Cores are
+#  collected by collect-core-dumps.sh and uploaded as a workflow artifact, so
+#  nothing is lost by keeping them out of the store, which only wants
+#  profiling data. core_dump_re is shared with that script.
+. "$(dirname "$0")/core-dump-names.inc"
+grep -E "$core_dump_re" "$all_list" >"$core_list" || true
+grep -Ev "$core_dump_re" "$all_list" | sed 's#^/##' >"$file_list" || true
+
+#  Never drop files silently: an unexplained gap in a run's tree is worse than
+#  a noisy publish log.
+if [ -s "$core_list" ]; then
+       echo "excluding $(wc -l <"$core_list" | tr -d ' ') core dump(s) from the store publish:"
+       while IFS= read -r core; do
+               echo "  $(du -h "prof-results$core" | cut -f1 | tr -d ' ')      ${core#/}"
+       done <"$core_list"
+fi
+
 if ! [ -s "$file_list" ]; then
-       echo "prof-results/ holds no files; skipping"
+       echo "prof-results/ holds no publishable files; skipping"
        exit 0
 fi
 tar -czf "$tarball" -C prof-results -T "$file_list"