package app import ( "strings" "syscall" "testing" "time" ) // TestParentExitKillsDescendants verifies that when a parent process // exits, every still-live process that was spawned under it is signaled // and dies, recursively through the tree. func TestParentExitKillsDescendants(t *testing.T) { sess := NewSession(t.TempDir(), "test") // Use a tiny pause-then-trap shell so the children survive long // enough for the parent to die first; SIGTERM should terminate them. sleepArgv := []string{"sh", "-c", "trap 'exit 0' TERM; sleep 30"} parent, err := sess.Spawn(SpawnSpec{ Kind: KindTerminal, Argv: []string{"sh", "-c", "sleep 30"}, }, 80, 24) if err != nil { t.Fatalf("spawn parent: %v", err) } childA, err := sess.Spawn(SpawnSpec{ Kind: KindCommand, Argv: sleepArgv, ParentID: parent.ID, }, 80, 24) if err != nil { t.Fatalf("spawn childA: %v", err) } grandchild, err := sess.Spawn(SpawnSpec{ Kind: KindCommand, Argv: sleepArgv, ParentID: childA.ID, }, 80, 24) if err != nil { t.Fatalf("spawn grandchild: %v", err) } // Wait for everyone to be running. waitUntilLive(t, parent) waitUntilLive(t, childA) waitUntilLive(t, grandchild) // Kill the parent. Its reapChild should cascade to childA, whose // reapChild should in turn cascade to grandchild. if err := parent.signal(syscall.SIGTERM); err != nil { t.Fatalf("signal parent: %v", err) } waitUntilNotLive(t, parent) waitUntilNotLive(t, childA) waitUntilNotLive(t, grandchild) } // TestSpawnInstallsIdleDetectionBeforePublish guarantees that a child // spawned with SpawnSpec.IdleDetection has its resolved config visible // the instant the child appears in s.children — closing the race where // the classifier could read c.idleDetection before the launcher set it. func TestSpawnInstallsIdleDetectionBeforePublish(t *testing.T) { sess := NewSession(t.TempDir(), "test") want := &resolvedIdleDetection{ strategy: StrategyOSCTitleStability, idleThresholdMS: 9999, } c, err := sess.Spawn(SpawnSpec{ Kind: KindCommand, Argv: []string{"sh", "-c", "sleep 30"}, IdleDetection: want, }, 80, 24) if err != nil { t.Fatalf("spawn: %v", err) } defer func() { _ = c.signal(syscall.SIGTERM) }() // Read back via the same access path the classifier uses // (sess.Children) so the test fails if the field is set only // AFTER the child is published. var found *Child for _, ch := range sess.Children() { if ch.ID == c.ID { found = ch break } } if found == nil { t.Fatalf("spawned child %s not in Children()", c.ID) } if found.idleDetection == nil { t.Fatalf("idleDetection nil after Spawn returned") } if found.idleDetection.strategy != StrategyOSCTitleStability { t.Fatalf("strategy: got %q want %q", found.idleDetection.strategy, StrategyOSCTitleStability) } if found.idleDetection.idleThresholdMS != 9999 { t.Fatalf("threshold: got %d want 9999", found.idleDetection.idleThresholdMS) } } func TestTerminateEscalatesWithoutRemovingEntry(t *testing.T) { sess := NewSession(t.TempDir(), "test") c, err := sess.Spawn(SpawnSpec{ Kind: KindAgent, Argv: []string{"sh", "-c", "trap '' TERM; echo ready; while :; do sleep 1; done"}, }, 80, 24) if err != nil { t.Fatalf("spawn: %v", err) } t.Cleanup(func() { if c.IsLive() { _ = c.signal(syscall.SIGKILL) } }) waitUntilLive(t, c) waitForStreamText(t, c, "ready") start := time.Now() if err := sess.Terminate(c.ID, syscall.SIGTERM); err != nil { t.Fatalf("Terminate: %v", err) } if elapsed := time.Since(start); elapsed < childStopTimeout { t.Fatalf("Terminate returned before SIGKILL fallback: elapsed=%s timeout=%s", elapsed, childStopTimeout) } waitUntilNotLive(t, c) if got := sess.FindChild(c.ID); got == nil { t.Fatalf("Terminate removed child entry %s", c.ID) } } func waitForStreamText(t *testing.T, c *Child, want string) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { b, _ := c.StreamRead(0) if strings.Contains(string(b), want) { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("child %s never wrote %q", c.ID, want) } func waitUntilLive(t *testing.T, c *Child) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if c.IsLive() { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("child %s never went live", c.ID) } func waitUntilNotLive(t *testing.T, c *Child) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { if !c.IsLive() { return } time.Sleep(20 * time.Millisecond) } t.Fatalf("child %s still live after parent died (status=%s)", c.ID, c.Status()) }