194 lines
5.7 KiB
Go
194 lines
5.7 KiB
Go
package fswatch
|
||
|
||
import "testing"
|
||
|
||
// testingT is the subset of [testing.T] (and [testing.TB]) used by every
|
||
// test in this package. It exists so that the per-backend test bodies
|
||
// dispatched through [runForEachWatcher] can be re-run by a fake T that
|
||
// captures Fatal/Skip via panic+recover instead of terminating the
|
||
// goroutine. macOS event-delivery stalls (which are not regressions but
|
||
// environmental flakes) can then be transparently retried before
|
||
// propagating to the real *testing.T.
|
||
//
|
||
// Restricted vs *testing.T:
|
||
// - No Parallel, Run, or other subtest plumbing.
|
||
// - No Setenv / Chdir (would race across retries).
|
||
//
|
||
// All helper functions in this file accept testingT rather than
|
||
// *testing.T so they work with both the real test runner and the retry
|
||
// wrapper.
|
||
type testingT interface {
|
||
Helper()
|
||
Cleanup(fn func())
|
||
TempDir() string
|
||
Name() string
|
||
|
||
Log(args ...any)
|
||
Logf(format string, args ...any)
|
||
|
||
Error(args ...any)
|
||
Errorf(format string, args ...any)
|
||
Fatal(args ...any)
|
||
Fatalf(format string, args ...any)
|
||
|
||
Skip(args ...any)
|
||
Skipf(format string, args ...any)
|
||
SkipNow()
|
||
|
||
Failed() bool
|
||
}
|
||
|
||
// Compile-time assertion that *testing.T satisfies testingT.
|
||
var _ testingT = (*testing.T)(nil)
|
||
|
||
// retryAttempts is the number of times runForEachWatcher will re-run a
|
||
// failing per-backend test body before propagating the failure to the
|
||
// real *testing.T. The per-event timeouts inside the body scale with
|
||
// the attempt number (1×, 5×, 15×), so the fast-path is cheap and only
|
||
// real environmental flakes pay the cost of longer waits.
|
||
const retryAttempts = 3
|
||
|
||
// retryTimeoutScale returns the multiplier applied to per-event timeouts
|
||
// on the given (1-based) attempt. 1× on first try, 5× on second,
|
||
// 15× on third.
|
||
func retryTimeoutScale(attempt int) int {
|
||
switch attempt {
|
||
case 1:
|
||
return 1
|
||
case 2:
|
||
return 5
|
||
default:
|
||
return 15
|
||
}
|
||
}
|
||
|
||
// retryT is a fake [testingT] used to run a test body and decide
|
||
// whether it passed without committing the verdict to the real
|
||
// *testing.T on intermediate attempts.
|
||
//
|
||
// Most methods (Helper, Cleanup, TempDir, Name, Log, Logf) are direct
|
||
// passthroughs to the real T so messages stream to test output as they
|
||
// happen rather than waiting for a verdict. Error/Errorf record a
|
||
// failure locally but also log to the real T (so the message is visible
|
||
// even on a successful retry). Fatal/Fatalf/Skip[Now/f] additionally
|
||
// panic with [retryBail] to unwind the goroutine; the retry driver
|
||
// recovers and either retries or surfaces a final failure.
|
||
type retryT struct {
|
||
t *testing.T
|
||
|
||
// attempt is 1-based and increases on each retry. Per-event
|
||
// timeouts in helpers (waitForEvent etc.) scale from this so the
|
||
// fast-path uses a short deadline and only retries pay the cost of
|
||
// longer waits.
|
||
attempt int
|
||
|
||
failed bool
|
||
skipped bool
|
||
}
|
||
|
||
// retryBail is panicked by Fatal/Fatalf/SkipNow/Skip[f] to abort the
|
||
// test body. The retry driver recovers it and inspects the retryT
|
||
// state to decide whether to retry, surface a skip, or accept success.
|
||
type retryBail struct{}
|
||
|
||
func newRetryT(t *testing.T, attempt int) *retryT {
|
||
return &retryT{t: t, attempt: attempt}
|
||
}
|
||
|
||
func (r *retryT) Helper() { r.t.Helper() }
|
||
func (r *retryT) Cleanup(fn func()) { r.t.Cleanup(fn) }
|
||
func (r *retryT) TempDir() string { return r.t.TempDir() }
|
||
func (r *retryT) Name() string { return r.t.Name() }
|
||
func (r *retryT) Log(args ...any) { r.t.Helper(); r.t.Log(args...) }
|
||
func (r *retryT) Logf(format string, args ...any) { r.t.Helper(); r.t.Logf(format, args...) }
|
||
func (r *retryT) Failed() bool { return r.failed }
|
||
|
||
func (r *retryT) Error(args ...any) {
|
||
r.t.Helper()
|
||
r.failed = true
|
||
r.t.Log(args...)
|
||
}
|
||
|
||
func (r *retryT) Errorf(format string, args ...any) {
|
||
r.t.Helper()
|
||
r.failed = true
|
||
r.t.Logf(format, args...)
|
||
}
|
||
|
||
func (r *retryT) Fatal(args ...any) {
|
||
r.t.Helper()
|
||
r.failed = true
|
||
r.t.Log(args...)
|
||
panic(retryBail{})
|
||
}
|
||
|
||
func (r *retryT) Fatalf(format string, args ...any) {
|
||
r.t.Helper()
|
||
r.failed = true
|
||
r.t.Logf(format, args...)
|
||
panic(retryBail{})
|
||
}
|
||
|
||
func (r *retryT) Skip(args ...any) {
|
||
r.t.Helper()
|
||
r.skipped = true
|
||
r.t.Log(args...)
|
||
panic(retryBail{})
|
||
}
|
||
|
||
func (r *retryT) Skipf(format string, args ...any) {
|
||
r.t.Helper()
|
||
r.skipped = true
|
||
r.t.Logf(format, args...)
|
||
panic(retryBail{})
|
||
}
|
||
|
||
func (r *retryT) SkipNow() {
|
||
r.skipped = true
|
||
panic(retryBail{})
|
||
}
|
||
|
||
// runWithRetry runs body up to [retryAttempts] times. Each attempt uses
|
||
// a fresh retryT whose attempt counter scales the per-event timeouts in
|
||
// the test helpers. Returns on the first attempt that does not fail
|
||
// (Skip and success both terminate the loop). On final failure the real
|
||
// T is marked failed; intermediate failures are visible in test output
|
||
// as Log messages (from Error/Errorf/Fatal/Fatalf streaming through)
|
||
// followed by a "retry: ..." log noting the next attempt.
|
||
//
|
||
// On the fast-path (body passes first try), this is one call with
|
||
// negligible overhead over a direct invocation.
|
||
func runWithRetry(t *testing.T, body func(testingT)) {
|
||
t.Helper()
|
||
for attempt := 1; attempt <= retryAttempts; attempt++ {
|
||
r := newRetryT(t, attempt)
|
||
func() {
|
||
defer func() {
|
||
if rec := recover(); rec != nil {
|
||
if _, ok := rec.(retryBail); !ok {
|
||
// Not our panic; resurface.
|
||
panic(rec)
|
||
}
|
||
}
|
||
}()
|
||
body(r)
|
||
}()
|
||
|
||
if r.skipped {
|
||
t.SkipNow()
|
||
return
|
||
}
|
||
if !r.failed {
|
||
if attempt > 1 {
|
||
t.Logf("retry: succeeded on attempt %d/%d", attempt, retryAttempts)
|
||
}
|
||
return
|
||
}
|
||
if attempt < retryAttempts {
|
||
t.Logf("retry: attempt %d/%d failed, retrying with %d× timeout scale",
|
||
attempt, retryAttempts, retryTimeoutScale(attempt+1))
|
||
}
|
||
}
|
||
t.Errorf("retry: gave up after %d attempts", retryAttempts)
|
||
}
|