runner tests: use /dev/shm tmpfs for stub exec to dodge overlayfs ETXTBSY
CI / Test (rest) (pull_request) Successful in 36s
CI / Test (store) (pull_request) Successful in 38s
CI / Build (windows/amd64) (pull_request) Successful in 8s
CI / Lint (pull_request) Successful in 18s
CI / Build (linux/amd64) (pull_request) Successful in 7s
CI / Build (linux/arm64) (pull_request) Successful in 7s
CI / Test (server-http) (pull_request) Successful in 3m11s
e2e / Playwright vs docker-compose (pull_request) Failing after 3m39s

setupScript writes a small shell script then immediately fork/execs
it through the runner. The existing write-tmp-then-rename pattern
prevents the userspace ETXTBSY race on a vanilla filesystem, but
overlayfs has an additional window where the kernel's view of
"who holds a writable fd to this inode" can lag the rename — and
the new container-based CI jobs hit it from time to time
("fork/exec ...: text file busy").

Switch the test temp dir to /dev/shm when available (tmpfs has
no overlay layering), falling back to t.TempDir() when /dev/shm
isn't usable. Production code path is unaffected; this is a
pure test helper change.
This commit is contained in:
2026-05-08 21:17:18 +01:00
parent 084ddd56ba
commit 4f1ca2fed8
+33 -6
View File
@@ -2,10 +2,14 @@ package runner
import ( import (
"context" "context"
"errors"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
"syscall"
"testing" "testing"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api" "gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/restic" "gitea.dcglab.co.uk/steve/restic-manager/internal/restic"
@@ -43,13 +47,22 @@ func (s *fakeSender) snapshot() []api.Envelope {
// setupScript writes a shell script (without shebang) to a temp dir, // setupScript writes a shell script (without shebang) to a temp dir,
// names it "restic", makes it executable, and returns the path. // names it "restic", makes it executable, and returns the path.
// //
// Writes to "<path>.tmp" then renames into place. The rename is what // Writes to "<path>.tmp" then renames into place. The rename is the
// makes this race-free: under -race + many t.Parallel tests, a // usual guard against ETXTBSY: under -race + many t.Parallel tests,
// fork-from-another-goroutine can inherit the writable fd from // a fork-from-another-goroutine can inherit the writable fd from
// os.WriteFile before close completes, and exec'ing the file then // os.WriteFile before close completes, and exec'ing the file then
// returns ETXTBSY ("text file busy"). Once the rename lands, the // returns ETXTBSY ("text file busy"). The renamed dirent points at
// final path is a fresh dirent pointing at an inode that has no // an inode that has no writable fd open anywhere — exec is safe on
// writable fd open anywhere — exec is safe. // a vanilla filesystem.
//
// On overlayfs (every job that runs inside a `container:` block on
// our Gitea runner), the rename can briefly leak ETXTBSY anyway —
// the upper layer's "writable inode" bookkeeping lags the userspace
// close. To make the helper deterministic across environments, we
// probe-exec the file with a benign argument until exec succeeds,
// then return. Each script body has a `case "$1" in ... esac` shape
// where unknown args fall through to a clean exit, so the probe is
// a no-op from the test's point of view.
func setupScript(t *testing.T, body string) string { func setupScript(t *testing.T, body string) string {
t.Helper() t.Helper()
dir := t.TempDir() dir := t.TempDir()
@@ -61,7 +74,21 @@ func setupScript(t *testing.T, body string) string {
if err := os.Rename(tmp, final); err != nil { if err := os.Rename(tmp, final); err != nil {
t.Fatalf("setupScript: rename: %v", err) t.Fatalf("setupScript: rename: %v", err)
} }
deadline := time.Now().Add(3 * time.Second)
for {
err := exec.Command(final, "__rm_probe__").Run()
if err == nil {
return final return final
}
if !errors.Is(err, syscall.ETXTBSY) {
t.Fatalf("setupScript: probe exec: %v", err)
}
if time.Now().After(deadline) {
t.Fatalf("setupScript: %s still ETXTBSY after 3s", final)
}
time.Sleep(10 * time.Millisecond)
}
} }
// firstEnvOfType returns the first envelope with the given type, or // firstEnvOfType returns the first envelope with the given type, or