mirror of
https://github.com/priyanshujain/sanderling.git
synced 2026-10-02 19:17:10 +00:00
fix(testrun): report a sidecar that dies at startup as the exit it was
This commit is contained in:
1 parent
f819dad7b6
commit
708a8a2929
3 files changed
+148
-25
No files matched your search
@@ -221,11 +221,7 @@ func navOverlayCommand(ctx context.Context, adb, serial, overlay string) *exec.C
|
|||||||
// EnvWithAndroidPlatformTools returns env with the directory containing adb
|
// EnvWithAndroidPlatformTools returns env with the directory containing adb
|
||||||
// prepended to PATH, so child processes (the sidecar) can invoke adb even
|
// prepended to PATH, so child processes (the sidecar) can invoke adb even
|
||||||
// when the user hasn't set up their shell PATH.
|
// when the user hasn't set up their shell PATH.
|
||||||
func EnvWithAndroidPlatformTools(env []string) []string {
|
func EnvWithAndroidPlatformTools(env []string, adb string) []string {
|
||||||
adb, err := AdbBinary()
|
|
||||||
if err != nil {
|
|
||||||
return env
|
|
||||||
}
|
|
||||||
adbDir := filepath.Dir(adb)
|
adbDir := filepath.Dir(adb)
|
||||||
result := make([]string, 0, len(env))
|
result := make([]string, 0, len(env))
|
||||||
found := false
|
found := false
|
||||||
|
|||||||
+75
-20
@@ -148,10 +148,14 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
if options.Device != "" {
|
if options.Device != "" {
|
||||||
sidecarArgs = append(sidecarArgs, "--serial", options.Device)
|
sidecarArgs = append(sidecarArgs, "--serial", options.Device)
|
||||||
}
|
}
|
||||||
|
adbPath, err := android.AdbBinary()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, preflightFailure("android", err)
|
||||||
|
}
|
||||||
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
|
sidecarCommand := exec.CommandContext(ctx, "java", sidecarArgs...)
|
||||||
sidecarCommand.Stdout = stdout
|
sidecarCommand.Stdout = stdout
|
||||||
sidecarCommand.Stderr = stdout
|
sidecarCommand.Stderr = stdout
|
||||||
sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ())
|
sidecarCommand.Env = android.EnvWithAndroidPlatformTools(os.Environ(), adbPath)
|
||||||
// SIGTERM lets the sidecar's shutdown hook stop the iOS XCTest runner.
|
// SIGTERM lets the sidecar's shutdown hook stop the iOS XCTest runner.
|
||||||
// SIGKILL skips the hook and orphans an xcodebuild session that later
|
// SIGKILL skips the hook and orphans an xcodebuild session that later
|
||||||
// restarts its runner and hijacks the simulator mid-run.
|
// restarts its runner and hijacks the simulator mid-run.
|
||||||
@@ -162,11 +166,19 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
if err := sidecarCommand.Start(); err != nil {
|
if err := sidecarCommand.Start(); err != nil {
|
||||||
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
|
return nil, nil, fmt.Errorf("spawn sidecar: %w", err)
|
||||||
}
|
}
|
||||||
fmt.Fprintf(stdout, "sidecar pid=%d listening on 127.0.0.1:%d\n", sidecarCommand.Process.Pid, sidecarPort)
|
// The close after the send lets the cleanup path receive again once the
|
||||||
|
// startup path has already taken the exit status.
|
||||||
|
sidecarExited := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
sidecarExited <- sidecarCommand.Wait()
|
||||||
|
close(sidecarExited)
|
||||||
|
}()
|
||||||
|
address := fmt.Sprintf("127.0.0.1:%d", sidecarPort)
|
||||||
|
fmt.Fprintf(stdout, "sidecar pid=%d listening on %s (adb: %s)\n", sidecarCommand.Process.Pid, address, adbPath)
|
||||||
|
|
||||||
driverClient, err := driverSidecar.Dial(fmt.Sprintf("127.0.0.1:%d", sidecarPort))
|
driverClient, err := driverSidecar.Dial(address)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
stopSidecar(sidecarCommand)
|
stopSidecar(sidecarCommand, sidecarExited)
|
||||||
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
|
return nil, nil, fmt.Errorf("dial sidecar: %w", err)
|
||||||
}
|
}
|
||||||
driverClient.SetPlatform(options.Platform)
|
driverClient.SetPlatform(options.Platform)
|
||||||
@@ -175,22 +187,70 @@ func buildDriver(ctx context.Context, options Options, stdout io.Writer) (driver
|
|||||||
// (absorbing the XCUITest startup race) runs inside IosDriverBackend.init
|
// (absorbing the XCUITest startup race) runs inside IosDriverBackend.init
|
||||||
// in the sidecar - no additional sleep needed here.
|
// in the sidecar - no additional sleep needed here.
|
||||||
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
|
healthCtx, healthCancel := context.WithTimeout(ctx, sidecarStartupTimeout)
|
||||||
if err := driverClient.WaitForHealth(healthCtx, 250e6); err != nil {
|
healthErr := awaitSidecar(healthCtx, address, sidecarStartupTimeout, func(pollCtx context.Context) error {
|
||||||
healthCancel()
|
return driverClient.WaitForHealth(pollCtx, 250e6)
|
||||||
stopSidecar(sidecarCommand)
|
}, sidecarExited)
|
||||||
_ = driverClient.Close()
|
|
||||||
return nil, nil, fmt.Errorf("sidecar health check: %w", err)
|
|
||||||
}
|
|
||||||
healthCancel()
|
healthCancel()
|
||||||
|
if healthErr != nil {
|
||||||
|
stopSidecar(sidecarCommand, sidecarExited)
|
||||||
|
_ = driverClient.Close()
|
||||||
|
return nil, nil, healthErr
|
||||||
|
}
|
||||||
fmt.Fprintln(stdout, "sidecar is healthy")
|
fmt.Fprintln(stdout, "sidecar is healthy")
|
||||||
|
|
||||||
cleanup := func() {
|
cleanup := func() {
|
||||||
_ = driverClient.Close()
|
_ = driverClient.Close()
|
||||||
stopSidecar(sidecarCommand)
|
stopSidecar(sidecarCommand, sidecarExited)
|
||||||
}
|
}
|
||||||
return driverClient, cleanup, nil
|
return driverClient, cleanup, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// awaitSidecar waits for the sidecar to answer a health check, racing that
|
||||||
|
// against the process exiting so a sidecar that dies during startup is reported
|
||||||
|
// as the exit it was rather than as a deadline half a minute later. Neither
|
||||||
|
// failure knows why the sidecar was unhappy, so both name what to look at
|
||||||
|
// instead of picking a cause.
|
||||||
|
func awaitSidecar(
|
||||||
|
ctx context.Context,
|
||||||
|
address string,
|
||||||
|
timeout time.Duration,
|
||||||
|
health func(context.Context) error,
|
||||||
|
exited <-chan error,
|
||||||
|
) error {
|
||||||
|
healthy := make(chan error, 1)
|
||||||
|
go func() { healthy <- health(ctx) }()
|
||||||
|
select {
|
||||||
|
case exitErr := <-exited:
|
||||||
|
return sidecarExitedError(address, exitErr)
|
||||||
|
case err := <-healthy:
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case exitErr := <-exited:
|
||||||
|
return sidecarExitedError(address, exitErr)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf(
|
||||||
|
"sidecar did not answer a health check on %s within %s and is still running\n%s",
|
||||||
|
address, timeout, sidecarWhatToCheck,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sidecarWhatToCheck = "check the sidecar output above, then `sanderling doctor --platform=android` (java 17+, adb, Android SDK)"
|
||||||
|
|
||||||
|
func sidecarExitedError(address string, exitErr error) error {
|
||||||
|
status := "exit status 0"
|
||||||
|
if exitErr != nil {
|
||||||
|
status = exitErr.Error()
|
||||||
|
}
|
||||||
|
return fmt.Errorf(
|
||||||
|
"sidecar exited before it answered a health check on %s: %s\n%s",
|
||||||
|
address, status, sidecarWhatToCheck,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// sidecarShutdownGrace bounds how long the sidecar gets to run its shutdown
|
// sidecarShutdownGrace bounds how long the sidecar gets to run its shutdown
|
||||||
// hook (terminate the app, stop the XCTest runner) before being killed.
|
// hook (terminate the app, stop the XCTest runner) before being killed.
|
||||||
const sidecarShutdownGrace = 15 * time.Second
|
const sidecarShutdownGrace = 15 * time.Second
|
||||||
@@ -198,25 +258,20 @@ const sidecarShutdownGrace = 15 * time.Second
|
|||||||
// stopSidecar terminates the sidecar gracefully so its shutdown hook can stop
|
// stopSidecar terminates the sidecar gracefully so its shutdown hook can stop
|
||||||
// the device-side runner processes, escalating to SIGKILL when it does not
|
// the device-side runner processes, escalating to SIGKILL when it does not
|
||||||
// exit within the grace window.
|
// exit within the grace window.
|
||||||
func stopSidecar(sidecarCommand *exec.Cmd) {
|
func stopSidecar(sidecarCommand *exec.Cmd, exited <-chan error) {
|
||||||
if sidecarCommand.Process == nil {
|
if sidecarCommand.Process == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := sidecarCommand.Process.Signal(syscall.SIGTERM); err != nil {
|
if err := sidecarCommand.Process.Signal(syscall.SIGTERM); err != nil {
|
||||||
_ = sidecarCommand.Process.Kill()
|
_ = sidecarCommand.Process.Kill()
|
||||||
_ = sidecarCommand.Wait()
|
<-exited
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
done := make(chan struct{})
|
|
||||||
go func() {
|
|
||||||
_ = sidecarCommand.Wait()
|
|
||||||
close(done)
|
|
||||||
}()
|
|
||||||
select {
|
select {
|
||||||
case <-done:
|
case <-exited:
|
||||||
case <-time.After(sidecarShutdownGrace):
|
case <-time.After(sidecarShutdownGrace):
|
||||||
_ = sidecarCommand.Process.Kill()
|
_ = sidecarCommand.Process.Kill()
|
||||||
<-done
|
<-exited
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
"io"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/priyanshujain/sanderling/internal/driver"
|
"github.com/priyanshujain/sanderling/internal/driver"
|
||||||
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
"github.com/priyanshujain/sanderling/internal/driver/ioscompanion"
|
||||||
@@ -65,6 +67,76 @@ func TestBuildDriverSurfacesDeviceConstructionError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A sidecar that dies during startup leaves the health poll with nothing to
|
||||||
|
// talk to, and reporting that as a deadline sends the reader after a gRPC
|
||||||
|
// timeout instead of the exit that already happened.
|
||||||
|
func TestAwaitSidecarReportsTheExitItSaw(t *testing.T) {
|
||||||
|
exited := make(chan error, 1)
|
||||||
|
exited <- errors.New("exit status 1")
|
||||||
|
close(exited)
|
||||||
|
|
||||||
|
err := awaitSidecar(
|
||||||
|
context.Background(),
|
||||||
|
"127.0.0.1:54321",
|
||||||
|
30*time.Second,
|
||||||
|
func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() },
|
||||||
|
exited,
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when the sidecar exits before it is healthy")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"sidecar exited before it answered a health check on 127.0.0.1:54321: exit status 1",
|
||||||
|
"check the sidecar output above",
|
||||||
|
"sanderling doctor --platform=android",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q missing %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwaitSidecarTimeoutSaysOnlyWhatItObserved(t *testing.T) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
err := awaitSidecar(
|
||||||
|
ctx,
|
||||||
|
"127.0.0.1:54321",
|
||||||
|
50*time.Millisecond,
|
||||||
|
func(ctx context.Context) error { <-ctx.Done(); return ctx.Err() },
|
||||||
|
make(chan error, 1),
|
||||||
|
)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected an error when the sidecar never answers")
|
||||||
|
}
|
||||||
|
for _, want := range []string{
|
||||||
|
"sidecar did not answer a health check on 127.0.0.1:54321 within 50ms and is still running",
|
||||||
|
"check the sidecar output above",
|
||||||
|
"sanderling doctor --platform=android",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(err.Error(), want) {
|
||||||
|
t.Errorf("error %q missing %q", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "context deadline exceeded") {
|
||||||
|
t.Errorf("error %q must not hand the reader a bare gRPC deadline", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwaitSidecarHealthyReturnsNil(t *testing.T) {
|
||||||
|
err := awaitSidecar(
|
||||||
|
context.Background(),
|
||||||
|
"127.0.0.1:54321",
|
||||||
|
30*time.Second,
|
||||||
|
func(context.Context) error { return nil },
|
||||||
|
make(chan error, 1),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected a healthy sidecar to pass, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// stubPreflight bypasses the host-readiness checks so routing tests exercise
|
// stubPreflight bypasses the host-readiness checks so routing tests exercise
|
||||||
// driver construction on a Linux CI runner that lacks xcrun/java.
|
// driver construction on a Linux CI runner that lacks xcrun/java.
|
||||||
func stubPreflight(t *testing.T) {
|
func stubPreflight(t *testing.T) {
|
||||||
|
|||||||
Reference in new issue
Block a user