From d703d88c6d0775168ab4a4f3c4d4491aec4dad74 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Thu, 29 Oct 2026 07:18:22 -0400 Subject: [PATCH] test(remote): kill arithmetic mutants and refresh equivalents --- sdk/go/remote/knownhosts_poll_unix_test.go | 52 ++++++++++++++++++++++ sdk/go/remote/options_allocation_test.go | 17 +++++++ sdk/go/remote/testdata/mutation-allow.txt | 6 +-- 3 files changed, 70 insertions(+), 5 deletions(-) create mode 100644 sdk/go/remote/knownhosts_poll_unix_test.go create mode 100644 sdk/go/remote/options_allocation_test.go diff --git a/sdk/go/remote/knownhosts_poll_unix_test.go b/sdk/go/remote/knownhosts_poll_unix_test.go new file mode 100644 index 000000000..a7bc92c9e --- /dev/null +++ b/sdk/go/remote/knownhosts_poll_unix_test.go @@ -0,0 +1,52 @@ +//go:build unix && !aix && !solaris && !illumos + +package remote + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestWaitLockPollDelays(t *testing.T) { + for _, tc := range []struct { + name string + wait, minimum time.Duration + }{ + // A deadline shorter than the first poll must still sleep 5ms. + {"initial", time.Millisecond, 5 * time.Millisecond}, + // 5+10+20+40+80+160+200ms. A zero cap busy-spins until 350ms. + {"cap", 350 * time.Millisecond, 515 * time.Millisecond}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "poll.lock") + holder, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + defer holder.Close() + if err := lockFD(holder, true); err != nil { + t.Fatal(err) + } + defer unlockFD(holder) + waiter, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + t.Fatal(err) + } + defer waiter.Close() + old := pinLockWait + pinLockWait = tc.wait + defer func() { pinLockWait = old }() + start := time.Now() + err = waitLock(waiter, true, path) + elapsed := time.Since(start) + if err == nil { + t.Fatal("held lock unexpectedly acquired") + } + if elapsed < tc.minimum { + t.Fatalf("polling took %s, want at least %s", elapsed, tc.minimum) + } + }) + } +} diff --git a/sdk/go/remote/options_allocation_test.go b/sdk/go/remote/options_allocation_test.go new file mode 100644 index 000000000..16edb29b5 --- /dev/null +++ b/sdk/go/remote/options_allocation_test.go @@ -0,0 +1,17 @@ +package remote + +import "testing" + +// Pin the reserved extra pair before append growth conceals arithmetic errors. +func TestCIOptionsBaseAllocation(t *testing.T) { + for _, minor := range []int{4, 5, 6} { + out := CIOptions("node", "/pins/known_hosts", Version{8, minor}) + want := 40 + if minor >= 5 { + want = 42 + } + if len(out) != want || cap(out) != 42 { + t.Errorf("8.%d: len/cap=%d/%d, want %d/42", minor, len(out), cap(out), want) + } + } +} diff --git a/sdk/go/remote/testdata/mutation-allow.txt b/sdk/go/remote/testdata/mutation-allow.txt index 9d5c974d7..0fa972784 100644 --- a/sdk/go/remote/testdata/mutation-allow.txt +++ b/sdk/go/remote/testdata/mutation-allow.txt @@ -1,11 +1,7 @@ # Equivalent mutants for: bash scripts/mutation.sh sdk/go/remote --only copy.go,doc.go,exec.go,hostspec.go,knownhosts.go,knownhosts_check.go,knownhosts_compat.go,lock_unix.go,options.go,pin_option.go,resolve.go,rsync_flags.go,run.go,scan.go,ssh_allow.go,validate.go --allow remote/testdata/mutation-allow.txt # The gate covers every non-test .go file except lock_other.go: it carries a !unix build tag (no-op stubs) and is not compiled on the linux CI host. doc.go holds no code; it is listed only so the set is exhaustive. # Format: MUTATOR at file.go:line:col | reason. Line numbers are those of the current source; a moved line needs its entry moved (D-0253). -ARITHMETIC_BASE at knownhosts_check.go:285:13 | `5 * time.Millisecond` first poll delay in waitLock: `/` makes it 0, so the loop retries without sleeping; the same tryLockFD calls succeed or the same pinLockWait deadline refuses, so every result and error is identical; under a held lock it busy-spins on tryLockFD until the same deadline instead of sleeping -ARITHMETIC_BASE at knownhosts_check.go:298:27 | `200*time.Millisecond` poll delay cap in waitLock: `/` makes the cap 0, so later retries do not sleep; grant and deadline outcomes are identical; under a held lock it busy-spins until the same deadline instead of sleeping ARITHMETIC_BASE at knownhosts_check.go:30:22 | unmeasurable, not equivalent: package var initialiser: Go emits no coverage block for it, so gremlins can only report NOT COVERED; TestPinLockWaitDefaultIsThirtySeconds pins the value -ARITHMETIC_BASE at options.go:115:28 | `2*len(d4Options)` in make([]string, 0, cap) is only a capacity hint; a smaller capacity makes append grow the slice, so the mutant changes only cap() of the returned slice, an allocation detail outside CIOptions' contract; the returned elements are identical -ARITHMETIC_BASE at options.go:115:43 | `+2` in the same make() capacity hint; any non-negative capacity yields the same elements and only changes cap() of the returned slice, an allocation detail outside CIOptions' contract -CONDITIONALS_BOUNDARY at scan.go:124:16 | `i < len(pairs)` vs `<=`: i starts at 1 and steps by 2 over CIOptions' flat "-o",value pairs, whose length is always even, so i never equals len(pairs) and the loop runs the same iterations +CONDITIONALS_BOUNDARY at scan.go:140:16 | `i < len(pairs)` vs `<=`: i starts at 1 and steps by 2 over CIOptions' flat "-o",value pairs, whose length is always even, so i never equals len(pairs) and the loop runs the same iterations CONDITIONALS_BOUNDARY at ssh_allow.go:114:51 | `s[n]|0x20 <= 'z'` vs `< 'z'`: a key stopped early at 'z' is followed by neither space nor '=', so rest becomes empty and the option is refused with the same error; no sshOptionKeys entry holds a 'z', so the verdict is identical for every input CONDITIONALS_BOUNDARY at ssh_allow.go:49:15 | `len(opts) >= 3` vs `> 3` in splitD4: with exactly -T -a -x the d4Options loop (never empty) hits i+1 >= len and returns (nil, opts), the same as the skipped branch, so splitD4's result is identical