Files
patterm/internal/app/session_test.go
Harry Bayliss 50fd7be70d Escalate agent Close to SIGKILL so it terminates in one action
Agent 'Close' (agent-close) sent a single SIGTERM via Session.Kill and
never escalated, so an agent that traps/ignores SIGTERM (e.g. opencode)
stayed in the running tab bar until the user closed it again. Add
Session.Terminate, which reuses terminateAndWait (SIGTERM, wait, then
SIGKILL) but preserves the session entry so the exited pane stays
readable, and route handleChildClose's agent path through it in a
goroutine to keep the UI responsive during the stop timeout.

Resolves the opencode double-close TODO item.
2026-05-25 12:30:13 +01:00

172 lines
4.6 KiB
Go

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())
}