mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-04 03:57:09 +00:00
fix(runner): scope WDA-drop detection and cap consecutive transient failures
This commit is contained in:
1 parent
a68cbb0224
commit
1410ea0268
2 files changed
+94
-4
No files matched your search
@@ -81,6 +81,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
summary := Summary{StartTime: time.Now()}
|
summary := Summary{StartTime: time.Now()}
|
||||||
deadline := summary.StartTime.Add(options.Duration)
|
deadline := summary.StartTime.Add(options.Duration)
|
||||||
stepIndex := 0
|
stepIndex := 0
|
||||||
|
consecutiveApplyFailures := 0
|
||||||
var lastAction *verifier.Action
|
var lastAction *verifier.Action
|
||||||
var lastLogTime time.Time
|
var lastLogTime time.Time
|
||||||
for time.Now().Before(deadline) {
|
for time.Now().Before(deadline) {
|
||||||
@@ -229,9 +230,13 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
if nextErr == nil {
|
if nextErr == nil {
|
||||||
if err := applyAction(ctx, options.Driver, nextAction, tree, options.IdleTimeout); err != nil {
|
if err := applyAction(ctx, options.Driver, nextAction, tree, options.IdleTimeout); err != nil {
|
||||||
if isWDADrop(err) {
|
if isWDADrop(err) {
|
||||||
return summary, fmt.Errorf("step %d: iOS XCTest runner lost connection - known WDA startup flake, re-run the test: %w", stepIndex, err)
|
return summary, fmt.Errorf("step %d: the iOS XCTest runner could not be restarted - re-run the test: %w", stepIndex, err)
|
||||||
}
|
}
|
||||||
if isTransientApplyError(ctx, err) {
|
if isTransientApplyError(ctx, err) {
|
||||||
|
consecutiveApplyFailures++
|
||||||
|
if consecutiveApplyFailures >= maxConsecutiveApplyFailures {
|
||||||
|
return summary, fmt.Errorf("step %d apply: %d consecutive transient failures; the device is not recovering: %w", stepIndex, consecutiveApplyFailures, err)
|
||||||
|
}
|
||||||
logger.Warn("transient apply error; marking step transitional", "step", stepIndex, "err", err)
|
logger.Warn("transient apply error; marking step transitional", "step", stepIndex, "err", err)
|
||||||
transitional = true
|
transitional = true
|
||||||
applySkipped = true
|
applySkipped = true
|
||||||
@@ -240,6 +245,7 @@ func Run(ctx context.Context, options Options) (Summary, error) {
|
|||||||
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
|
return summary, fmt.Errorf("step %d apply: %w", stepIndex, err)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
consecutiveApplyFailures = 0
|
||||||
actionCopy := nextAction
|
actionCopy := nextAction
|
||||||
lastAction = &actionCopy
|
lastAction = &actionCopy
|
||||||
}
|
}
|
||||||
@@ -912,10 +918,19 @@ func encodeResiduals(residuals map[string]ltl.Formula) (map[string]json.RawMessa
|
|||||||
return encoded, firstErr
|
return encoded, firstErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxConsecutiveApplyFailures bounds how many transient apply failures in a
|
||||||
|
// row the run tolerates before aborting. One or two absorb a runner restart;
|
||||||
|
// an unbroken streak means the device is wedged and the rest of the budget
|
||||||
|
// would be spent doing nothing.
|
||||||
|
const maxConsecutiveApplyFailures = 3
|
||||||
|
|
||||||
|
// isWDADrop reports that the sidecar could not restart the iOS XCTest
|
||||||
|
// runner: the channel is gone for good and the run must abort. Transient
|
||||||
|
// drops are classified by the sidecar itself (it reconnects and surfaces
|
||||||
|
// UNAVAILABLE), so matching on raw exception text like "ConnectException"
|
||||||
|
// here would kill runs the sidecar already recovered.
|
||||||
func isWDADrop(err error) bool {
|
func isWDADrop(err error) bool {
|
||||||
msg := err.Error()
|
return strings.Contains(err.Error(), "WDA reconnect failed")
|
||||||
return strings.Contains(msg, "ConnectException") ||
|
|
||||||
(strings.Contains(msg, "code = Internal") && strings.Contains(msg, "SocketException"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// isTransientApplyError reports whether an applyAction failure is a transient
|
// isTransientApplyError reports whether an applyAction failure is a transient
|
||||||
|
|||||||
@@ -1384,6 +1384,81 @@ func TestIsTransientApplyError_Classification(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsWDADrop_Classification pins the fatal-vs-recoverable boundary: only
|
||||||
|
// the sidecar's explicit reconnect-failure signal is a drop. A structured
|
||||||
|
// UNAVAILABLE that happens to embed raw exception text (e.g. ConnectException
|
||||||
|
// from the original failure) means the sidecar already recovered and the run
|
||||||
|
// must continue.
|
||||||
|
func TestIsWDADrop_Classification(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"unavailable with embedded ConnectException is recovered",
|
||||||
|
status.Error(codes.Unavailable, "connection dropped mid-action; the action may have applied: java.net.ConnectException: Failed to connect to /127.0.0.1:22161"),
|
||||||
|
false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"reconnect failure is a drop",
|
||||||
|
status.Error(codes.Internal, "java.lang.IllegalStateException: WDA reconnect failed: IOSDriverTimeoutException"),
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
{"generic internal is not a drop", status.Error(codes.Internal, "boom"), false},
|
||||||
|
}
|
||||||
|
for _, testCase := range cases {
|
||||||
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
if got := isWDADrop(testCase.err); got != testCase.want {
|
||||||
|
t.Errorf("got %v, want %v", got, testCase.want)
|
||||||
|
}
|
||||||
|
if testCase.want && isTransientApplyError(context.Background(), testCase.err) {
|
||||||
|
t.Error("a WDA drop must never also classify as transient")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tapSelectorAlwaysUnavailable wraps a mock driver so every TapSelector call
|
||||||
|
// fails with a transient Unavailable error, mimicking a device whose channel
|
||||||
|
// never recovers between steps.
|
||||||
|
type tapSelectorAlwaysUnavailable struct {
|
||||||
|
*mockdriver.Driver
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *tapSelectorAlwaysUnavailable) TapSelector(ctx context.Context, selector string) error {
|
||||||
|
return status.Error(codes.Unavailable, "connection dropped mid-action; the action may have applied")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunner_ConsecutiveTransientApplyFailuresAbort verifies the run fails
|
||||||
|
// fast once transient apply errors form an unbroken streak instead of burning
|
||||||
|
// the whole budget on a wedged device.
|
||||||
|
func TestRunner_ConsecutiveTransientApplyFailuresAbort(t *testing.T) {
|
||||||
|
state := newHarness(t)
|
||||||
|
wrapped := &tapSelectorAlwaysUnavailable{Driver: state.mock}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
summary, err := Run(ctx, Options{
|
||||||
|
Duration: 5 * time.Second,
|
||||||
|
IdleTimeout: 20 * time.Millisecond,
|
||||||
|
Driver: wrapped,
|
||||||
|
Verifier: state.verifier,
|
||||||
|
TraceWriter: state.writer,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Run must abort after consecutive transient apply failures")
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "consecutive transient failures") {
|
||||||
|
t.Errorf("expected consecutive-failure abort, got %v", err)
|
||||||
|
}
|
||||||
|
// The aborting step returns before it is recorded, so the summary holds
|
||||||
|
// the steps before the cap-hitting one.
|
||||||
|
if summary.Steps != maxConsecutiveApplyFailures-1 {
|
||||||
|
t.Errorf("run must stop at the failure cap, got %d recorded steps", summary.Steps)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRunner_WaitActionSkipsIdle ensures the runner does not call WaitForIdle
|
// TestRunner_WaitActionSkipsIdle ensures the runner does not call WaitForIdle
|
||||||
// after a Wait action - the action already provides settling time.
|
// after a Wait action - the action already provides settling time.
|
||||||
func TestRunner_WaitActionSkipsIdle(t *testing.T) {
|
func TestRunner_WaitActionSkipsIdle(t *testing.T) {
|
||||||
|
|||||||
Reference in new issue
Block a user