diff --git a/internal/driver/ioscompanion/driver.go b/internal/driver/ioscompanion/driver.go index 74a73cc..9fb4725 100644 --- a/internal/driver/ioscompanion/driver.go +++ b/internal/driver/ioscompanion/driver.go @@ -39,8 +39,8 @@ const startupTimeout = 30 * time.Second const runnerStartupTimeout = 120 * time.Second // shutdownGrace bounds how long the companion child gets to exit after SIGTERM -// before it is killed. -const shutdownGrace = 15 * time.Second +// before it is killed. A variable so the kill-escalation test can shrink it. +var shutdownGrace = 15 * time.Second // longPressHoldMilliseconds is how long LongPress holds the finger down. const longPressHoldMilliseconds = 600 diff --git a/internal/driver/ioscompanion/supervision_test.go b/internal/driver/ioscompanion/supervision_test.go new file mode 100644 index 0000000..306a67c --- /dev/null +++ b/internal/driver/ioscompanion/supervision_test.go @@ -0,0 +1,157 @@ +package ioscompanion + +import ( + "context" + "errors" + "net" + "os/exec" + "syscall" + "testing" + "time" + + "github.com/priyanshujain/sanderling/internal/driver/ioscompanion/transport" +) + +// These tests cover the process-supervision logic (stopProcess, bringUp, +// respawnAndRedial) with real child processes and seamed transports, so the +// default suite exercises it without a simulator. + +func TestStopProcessReapsChildOnSigterm(t *testing.T) { + child := exec.Command("sleep", "30") + if err := child.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + start := time.Now() + stopProcess(child) + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Fatalf("stopProcess took %v; SIGTERM on sleep should reap promptly", elapsed) + } + if child.ProcessState == nil { + t.Fatal("child not reaped: ProcessState is nil") + } + if err := child.Process.Signal(syscall.Signal(0)); err == nil { + t.Fatal("child still signalable after stopProcess") + } +} + +func TestStopProcessEscalatesToKillWhenSigtermIgnored(t *testing.T) { + previousGrace := shutdownGrace + shutdownGrace = 200 * time.Millisecond + defer func() { shutdownGrace = previousGrace }() + + child := exec.Command("sh", "-c", `trap "" TERM; sleep 30`) + if err := child.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + // Give the shell a beat to install its TERM trap. + time.Sleep(100 * time.Millisecond) + stopProcess(child) + if child.ProcessState == nil { + t.Fatal("child not reaped: ProcessState is nil") + } + if child.ProcessState.Success() { + t.Fatal("child should have died by signal, not exited cleanly") + } +} + +func TestStopProcessHandlesNilChild(t *testing.T) { + stopProcess(nil) + stopProcess(&exec.Cmd{}) +} + +// TestRespawnAndRedialReplacesTransportAndChild drives the real restart path +// through the seams: the old transport must be closed, a fresh child spawned, +// and the fresh transport installed. +func TestRespawnAndRedialReplacesTransportAndChild(t *testing.T) { + t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + _ = connection.Close() + } + }() + + spawns := 0 + dials := []*fakeCompanion{} + options := Options{ + UniqueDeviceIdentifier: "FAKE-UDID", + pickAddress: func() (string, error) { return listener.Addr().String(), nil }, + spawnChild: func(context.Context, string) (*exec.Cmd, error) { + spawns++ + return &exec.Cmd{}, nil + }, + dialCompanion: func(string) (transport.Companion, error) { + companion := &fakeCompanion{accessibilityJSON: "[]"} + dials = append(dials, companion) + return companion, nil + }, + } + d, err := New(context.Background(), options) + if err != nil { + t.Fatal(err) + } + defer d.Close() + + first := dials[0] + if err := d.respawnAndRedial(context.Background()); err != nil { + t.Fatalf("respawnAndRedial: %v", err) + } + if spawns != 2 { + t.Fatalf("spawns = %d, want 2 (initial bring-up plus restart)", spawns) + } + if len(dials) != 2 || d.companion != transport.Companion(dials[1]) { + t.Fatalf("restart must install the freshly dialed transport") + } + if indexOf(first.recorded(), "close") < 0 { + t.Fatal("restart must close the dead transport") + } +} + +// TestBringUpStopsChildWhenDialFails proves a failed bring-up does not leak +// the child it spawned. +func TestBringUpStopsChildWhenDialFails(t *testing.T) { + t.Setenv("SANDERLING_SIMULATOR_COMPANION", "legacy") + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + go func() { + for { + connection, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + _ = connection.Close() + } + }() + + child := exec.Command("sleep", "30") + if err := child.Start(); err != nil { + t.Fatalf("start child: %v", err) + } + options := Options{ + UniqueDeviceIdentifier: "FAKE-UDID", + pickAddress: func() (string, error) { return listener.Addr().String(), nil }, + spawnChild: func(context.Context, string) (*exec.Cmd, error) { + return child, nil + }, + dialCompanion: func(string) (transport.Companion, error) { + return nil, errors.New("dial refused") + }, + } + if _, err := New(context.Background(), options); err == nil { + t.Fatal("New should fail when dial fails") + } + if child.ProcessState == nil { + t.Fatal("failed bring-up must reap the spawned child") + } +}