mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 23:40:28 -10:00
* Background: escalate process termination from SIGTERM to SIGKILL terminateProcess (POSIX) sent only SIGTERM, so a background process that traps or ignores it kept running after a Kill/KillRunning — a leak, e.g. on Ctrl+C shutdown. It now sends SIGTERM (letting the process clean up), polls liveness, and force-kills with SIGKILL if the process is still alive after a grace period. - internal/background/process_posix.go: SIGTERM -> poll (signal 0) until a 3s grace deadline -> SIGKILL; treats an already-exited process as success (ESRCH / ErrProcessDone). Grace/poll are vars so tests run fast. Windows already force-kills via taskkill /F, so it is unchanged. Tests: a SIGTERM-ignoring process (trap, with a readiness handshake to avoid signalling before the trap installs) is killed only after the grace period; a well-behaved process dies on SIGTERM well before escalation; an already-exited pid is a no-op. -race + full-suite + GOOS=windows build green; no new deps. * Background test: assert the terminating signal (SIGTERM vs SIGKILL) The graceful and escalation tests now check the actual signal that killed the process (via WaitStatus.Signal()), not just timing — so a regression to an immediate SIGKILL (or to never escalating) is caught. Graceful path must be SIGTERM; the SIGTERM-ignoring process must be SIGKILL. * Background: keep user kill intent when the exit waiter reaps during terminate Vasanthdev P1: terminateProcess now blocks until the child exits, so the background Wait-goroutine can reap the process and MarkExited the task to 'error' BEFORE Kill marked it 'killed' — leaving a user-initiated stop persisted as 'error' even though TaskStop returned killed. Manager.Kill now records the kill intent (markKilledIfStillRunning) BEFORE terminating. MarkExited only acts on a running task, so once the task is killed the waiter's MarkExited is a no-op and the status stays killed. If terminateProcess fails, restoreRunningAfterFailedKill reverts the optimistic mark so a still-running task isn't falsely reported killed (preserves TestManagerDoesNotMarkKilledWhenKillFails). Adds TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate (cross-platform: a KillProcess that simulates the waiter reaping mid-terminate; asserts final status stays killed). build/-race/full-suite + GOOS=windows green. * Background: don't signal a stale pid + surface rollback failures in Kill Two follow-ups to the kill-ordering fix (CodeRabbit): 1. markKilledIfStillRunning now returns whether it actually marked. If the task exited (or was reused) between the running check and the mark, Kill returns without calling killProcess — so it never signals a possibly-stale pid in the exit/reuse race. 2. restoreRunningAfterFailedKill returns its persistence error; on a failed terminate, Kill joins the kill error and the rollback error (errors.Join) instead of swallowing a failed rollback that would leave the task wrongly marked killed. Adds TestMarkKilledIfStillRunningReportsWhetherItMarked. build/-race/full-suite + GOOS=windows green. --------- Co-authored-by: KRATOS <[email protected]>
488 lines
16 KiB
Go
488 lines
16 KiB
Go
package background
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestManagerRegistersListsAndKillsTask(t *testing.T) {
|
|
now := sequenceClock(
|
|
time.Date(2026, 6, 7, 9, 0, 0, 0, time.UTC),
|
|
time.Date(2026, 6, 7, 9, 0, 1, 0, time.UTC),
|
|
time.Date(2026, 6, 7, 9, 0, 2, 0, time.UTC),
|
|
)
|
|
killed := []int{}
|
|
manager, err := NewManagerWithOptions(ManagerOptions{
|
|
RootDir: t.TempDir(),
|
|
Now: now,
|
|
KillProcess: func(pid int) error {
|
|
killed = append(killed, pid)
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
|
|
outputFile, err := manager.Register(RegisterInput{
|
|
TaskID: "task_1",
|
|
Type: "specialist",
|
|
SpecialistName: "worker",
|
|
Description: "Read the release notes",
|
|
ParentID: "parent",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
if outputFile != filepath.Join(manager.RootDir(), "task_1.ndjson") {
|
|
t.Fatalf("output file = %q", outputFile)
|
|
}
|
|
info, err := os.Stat(outputFile)
|
|
if err != nil {
|
|
t.Fatalf("expected output file to exist: %v", err)
|
|
}
|
|
if runtime.GOOS != "windows" && info.Mode().Perm() != 0o600 {
|
|
t.Fatalf("output file permissions = %v, want 0600", info.Mode().Perm())
|
|
}
|
|
if err := manager.SetPID("task_1", 1234); err != nil {
|
|
t.Fatalf("SetPID returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task_2", Type: "specialist", SpecialistName: "explorer", ParentID: "parent", PID: 5678}); err != nil {
|
|
t.Fatalf("Register second task returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "other", Type: "specialist", ParentID: "different-parent"}); err != nil {
|
|
t.Fatalf("Register other task returned error: %v", err)
|
|
}
|
|
|
|
task, ok := manager.Get("task_1")
|
|
if !ok {
|
|
t.Fatalf("Get did not find task")
|
|
}
|
|
if task.ID != "task_1" || task.PID != 1234 || task.Status != StatusRunning || task.SpecialistName != "worker" {
|
|
t.Fatalf("unexpected task: %#v", task)
|
|
}
|
|
|
|
parentTasks := manager.ListByParent("parent")
|
|
gotIDs := []string{}
|
|
for _, task := range parentTasks {
|
|
gotIDs = append(gotIDs, task.ID)
|
|
}
|
|
if !reflect.DeepEqual(gotIDs, []string{"task_2", "task_1"}) {
|
|
t.Fatalf("ListByParent ids = %#v, want newest first", gotIDs)
|
|
}
|
|
|
|
if err := manager.Kill("task_1"); err != nil {
|
|
t.Fatalf("Kill returned error: %v", err)
|
|
}
|
|
if !reflect.DeepEqual(killed, []int{1234}) {
|
|
t.Fatalf("killed pids = %#v", killed)
|
|
}
|
|
task, ok = manager.Get("task_1")
|
|
if !ok || task.Status != StatusKilled || task.ExitCode != -1 || task.CompletedAt.IsZero() {
|
|
t.Fatalf("killed task status was not recorded: %#v", task)
|
|
}
|
|
}
|
|
|
|
func TestManagerPersistsAndLoadsTasks(t *testing.T) {
|
|
root := t.TempDir()
|
|
now := sequenceClock(
|
|
time.Date(2026, 6, 7, 10, 0, 0, 0, time.UTC),
|
|
time.Date(2026, 6, 7, 10, 0, 1, 0, time.UTC),
|
|
)
|
|
manager, err := NewManagerWithOptions(ManagerOptions{RootDir: root, Now: now})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
outputFile, err := manager.Register(RegisterInput{
|
|
TaskID: "task_1",
|
|
Type: "specialist",
|
|
SpecialistName: "worker",
|
|
Description: "Persist metadata",
|
|
ParentID: "parent",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
if err := manager.SetPID("task_1", 4321); err != nil {
|
|
t.Fatalf("SetPID returned error: %v", err)
|
|
}
|
|
if err := manager.UpdateStatus("task_1", StatusCompleted, 0); err != nil {
|
|
t.Fatalf("UpdateStatus returned error: %v", err)
|
|
}
|
|
|
|
reloaded, err := NewManager(root)
|
|
if err != nil {
|
|
t.Fatalf("NewManager reload returned error: %v", err)
|
|
}
|
|
task, ok := reloaded.Get("task_1")
|
|
if !ok {
|
|
t.Fatal("reloaded manager did not find task")
|
|
}
|
|
if task.ID != "task_1" ||
|
|
task.Type != "specialist" ||
|
|
task.SpecialistName != "worker" ||
|
|
task.Description != "Persist metadata" ||
|
|
task.ParentID != "parent" ||
|
|
task.PID != 4321 ||
|
|
task.Status != StatusCompleted ||
|
|
task.ExitCode != 0 ||
|
|
task.OutputFile != outputFile ||
|
|
task.StartedAt.IsZero() ||
|
|
task.CompletedAt.IsZero() {
|
|
t.Fatalf("reloaded task = %#v", task)
|
|
}
|
|
parentTasks := reloaded.ListByParent("parent")
|
|
if len(parentTasks) != 1 || parentTasks[0].ID != "task_1" {
|
|
t.Fatalf("reloaded parent tasks = %#v", parentTasks)
|
|
}
|
|
}
|
|
|
|
func TestManagerPersistsKilledStatus(t *testing.T) {
|
|
root := t.TempDir()
|
|
manager, err := NewManagerWithOptions(ManagerOptions{
|
|
RootDir: root,
|
|
KillProcess: func(pid int) error {
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
if err := manager.Kill("task"); err != nil {
|
|
t.Fatalf("Kill returned error: %v", err)
|
|
}
|
|
|
|
reloaded, err := NewManager(root)
|
|
if err != nil {
|
|
t.Fatalf("NewManager reload returned error: %v", err)
|
|
}
|
|
task, ok := reloaded.Get("task")
|
|
if !ok || task.Status != StatusKilled || task.ExitCode != -1 || task.CompletedAt.IsZero() {
|
|
t.Fatalf("reloaded killed task = %#v", task)
|
|
}
|
|
}
|
|
|
|
func TestManagerSkipsInvalidPersistedMetadata(t *testing.T) {
|
|
root := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(root, "task.json"), []byte(`{"id":"other","type":"specialist","status":"running","outputFile":"task.ndjson"}`+"\n"), 0o600); err != nil {
|
|
t.Fatalf("write metadata: %v", err)
|
|
}
|
|
|
|
manager, err := NewManager(root)
|
|
|
|
if err != nil {
|
|
t.Fatalf("NewManager returned error for invalid metadata: %v", err)
|
|
}
|
|
if _, ok := manager.Get("task"); ok {
|
|
t.Fatal("manager loaded invalid task metadata")
|
|
}
|
|
warnings := strings.Join(manager.LoadWarnings(), "\n")
|
|
if !strings.Contains(warnings, "does not match file id") {
|
|
t.Fatalf("load warnings = %q, want id mismatch warning", warnings)
|
|
}
|
|
}
|
|
|
|
func TestManagerMarksReloadedRunningTaskNonKillable(t *testing.T) {
|
|
root := t.TempDir()
|
|
manager, err := NewManager(root)
|
|
if err != nil {
|
|
t.Fatalf("NewManager returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
killed := []int{}
|
|
reloaded, err := NewManagerWithOptions(ManagerOptions{
|
|
RootDir: root,
|
|
KillProcess: func(pid int) error {
|
|
killed = append(killed, pid)
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManager reload returned error: %v", err)
|
|
}
|
|
task, ok := reloaded.Get("task")
|
|
if !ok {
|
|
t.Fatal("reloaded manager did not find task")
|
|
}
|
|
if task.Status != StatusError || task.PID != 0 || task.ExitCode != -1 || task.CompletedAt.IsZero() {
|
|
t.Fatalf("reloaded running task was not orphaned: %#v", task)
|
|
}
|
|
if err := reloaded.Kill("task"); err == nil || !strings.Contains(err.Error(), "is error") {
|
|
t.Fatalf("Kill reloaded running task error = %v", err)
|
|
}
|
|
if len(killed) != 0 {
|
|
t.Fatalf("Kill signaled stale pids: %#v", killed)
|
|
}
|
|
|
|
reloadedAgain, err := NewManager(root)
|
|
if err != nil {
|
|
t.Fatalf("second reload returned error: %v", err)
|
|
}
|
|
task, ok = reloadedAgain.Get("task")
|
|
if !ok || task.Status != StatusError || task.PID != 0 {
|
|
t.Fatalf("repaired task metadata did not persist: %#v", task)
|
|
}
|
|
}
|
|
|
|
func TestManagerRejectsUnsafeTaskIDsAndOutputPaths(t *testing.T) {
|
|
manager, err := NewManager(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewManager returned error: %v", err)
|
|
}
|
|
|
|
for _, taskID := range []string{"", "../escape", "bad/name", `bad\name`} {
|
|
if _, err := manager.Register(RegisterInput{TaskID: taskID, Type: "specialist"}); err == nil {
|
|
t.Fatalf("Register accepted unsafe task id %q", taskID)
|
|
}
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "safe", Type: "specialist", OutputFile: "../escape.ndjson"}); err == nil || !strings.Contains(err.Error(), "inside") {
|
|
t.Fatalf("Register unsafe output file error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestManagerRejectsDuplicateAndMissingTasks(t *testing.T) {
|
|
manager, err := NewManager(t.TempDir())
|
|
if err != nil {
|
|
t.Fatalf("NewManager returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist"}); err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist"}); err == nil || !strings.Contains(err.Error(), "already registered") {
|
|
t.Fatalf("duplicate Register error = %v", err)
|
|
}
|
|
if err := manager.SetPID("missing", 1); err == nil || !strings.Contains(err.Error(), "not found") {
|
|
t.Fatalf("missing SetPID error = %v", err)
|
|
}
|
|
if err := manager.UpdateStatus("missing", StatusCompleted, 0); err == nil || !strings.Contains(err.Error(), "not found") {
|
|
t.Fatalf("missing UpdateStatus error = %v", err)
|
|
}
|
|
if err := manager.Kill("missing"); err == nil || !strings.Contains(err.Error(), "not found") {
|
|
t.Fatalf("missing Kill error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestManagerDoesNotMarkKilledWhenKillFails(t *testing.T) {
|
|
manager, err := NewManagerWithOptions(ManagerOptions{
|
|
RootDir: t.TempDir(),
|
|
KillProcess: func(pid int) error {
|
|
return errors.New("denied")
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
if err := manager.Kill("task"); err == nil || !strings.Contains(err.Error(), "denied") {
|
|
t.Fatalf("Kill error = %v", err)
|
|
}
|
|
task, ok := manager.Get("task")
|
|
if !ok || task.Status != StatusRunning || !task.CompletedAt.IsZero() {
|
|
t.Fatalf("failed kill mutated task: %#v", task)
|
|
}
|
|
}
|
|
|
|
func TestManagerKillDoesNotClobberCompletedTask(t *testing.T) {
|
|
now := sequenceClock(
|
|
time.Date(2026, 6, 7, 9, 0, 0, 0, time.UTC),
|
|
time.Date(2026, 6, 7, 9, 0, 1, 0, time.UTC),
|
|
)
|
|
var manager *Manager
|
|
manager, err := NewManagerWithOptions(ManagerOptions{
|
|
RootDir: t.TempDir(),
|
|
Now: now,
|
|
KillProcess: func(pid int) error {
|
|
return manager.UpdateStatus("task", StatusCompleted, 0)
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
if err := manager.Kill("task"); err != nil {
|
|
t.Fatalf("Kill returned error: %v", err)
|
|
}
|
|
task, ok := manager.Get("task")
|
|
if !ok || task.Status != StatusCompleted || task.ExitCode != 0 {
|
|
t.Fatalf("Kill clobbered completed task: %#v", task)
|
|
}
|
|
}
|
|
|
|
func TestManagerMarkExitedDoesNotClobberKilledTask(t *testing.T) {
|
|
manager, err := NewManagerWithOptions(ManagerOptions{
|
|
RootDir: t.TempDir(),
|
|
KillProcess: func(pid int) error {
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task", Type: "specialist", PID: 42}); err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
if err := manager.Kill("task"); err != nil {
|
|
t.Fatalf("Kill returned error: %v", err)
|
|
}
|
|
if err := manager.MarkExited("task", StatusError, 1); err != nil {
|
|
t.Fatalf("MarkExited returned error: %v", err)
|
|
}
|
|
task, ok := manager.Get("task")
|
|
if !ok || task.Status != StatusKilled || task.ExitCode != -1 {
|
|
t.Fatalf("MarkExited clobbered killed task: %#v", task)
|
|
}
|
|
}
|
|
|
|
func TestManagerKillRunningStopsOnlyRunningTasks(t *testing.T) {
|
|
now := sequenceClock(
|
|
time.Date(2026, 6, 7, 9, 0, 0, 0, time.UTC),
|
|
time.Date(2026, 6, 7, 9, 0, 1, 0, time.UTC),
|
|
time.Date(2026, 6, 7, 9, 0, 2, 0, time.UTC),
|
|
)
|
|
killed := []int{}
|
|
manager, err := NewManagerWithOptions(ManagerOptions{
|
|
RootDir: t.TempDir(),
|
|
Now: now,
|
|
KillProcess: func(pid int) error {
|
|
killed = append(killed, pid)
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "running_1", Type: "specialist", PID: 11}); err != nil {
|
|
t.Fatalf("Register running_1 returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "done", Type: "specialist", PID: 22}); err != nil {
|
|
t.Fatalf("Register done returned error: %v", err)
|
|
}
|
|
if err := manager.UpdateStatus("done", StatusCompleted, 0); err != nil {
|
|
t.Fatalf("UpdateStatus returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "running_2", Type: "specialist", PID: 33}); err != nil {
|
|
t.Fatalf("Register running_2 returned error: %v", err)
|
|
}
|
|
|
|
if err := manager.KillRunning(); err != nil {
|
|
t.Fatalf("KillRunning returned error: %v", err)
|
|
}
|
|
|
|
if !reflect.DeepEqual(killed, []int{33, 11}) {
|
|
t.Fatalf("killed pids = %#v", killed)
|
|
}
|
|
for _, id := range []string{"running_1", "running_2"} {
|
|
task, ok := manager.Get(id)
|
|
if !ok || task.Status != StatusKilled {
|
|
t.Fatalf("%s status = %#v", id, task)
|
|
}
|
|
}
|
|
done, ok := manager.Get("done")
|
|
if !ok || done.Status != StatusCompleted {
|
|
t.Fatalf("completed task was changed: %#v", done)
|
|
}
|
|
}
|
|
|
|
func TestDefaultRootHonorsXDGDataHome(t *testing.T) {
|
|
got := DefaultRoot(map[string]string{
|
|
"XDG_DATA_HOME": "/tmp/zero-data",
|
|
"HOME": "/home/example",
|
|
})
|
|
want := filepath.Join("/tmp/zero-data", "zero", "background")
|
|
if got != want {
|
|
t.Fatalf("DefaultRoot = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate reproduces the
|
|
// lifecycle race created by a blocking terminateProcess: while Kill waits for the
|
|
// child to exit, the background Wait-goroutine reaps it and calls MarkExited. The
|
|
// task must stay "killed" (the user's intent), not be clobbered to "error".
|
|
func TestManagerKillStaysKilledWhenExitWaiterReapsDuringTerminate(t *testing.T) {
|
|
now := sequenceClock(
|
|
time.Date(2026, 6, 8, 9, 0, 0, 0, time.UTC),
|
|
time.Date(2026, 6, 8, 9, 0, 1, 0, time.UTC),
|
|
time.Date(2026, 6, 8, 9, 0, 2, 0, time.UTC),
|
|
)
|
|
var manager *Manager
|
|
var err error
|
|
manager, err = NewManagerWithOptions(ManagerOptions{
|
|
RootDir: t.TempDir(),
|
|
Now: now,
|
|
KillProcess: func(int) error {
|
|
// terminateProcess blocks until exit; simulate the Wait-goroutine
|
|
// reaping the child and marking it exited mid-kill.
|
|
_ = manager.MarkExited("task_1", StatusError, -1)
|
|
return nil
|
|
},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "task_1", Type: "specialist", SpecialistName: "worker", ParentID: "p", PID: 4242}); err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
|
|
if err := manager.Kill("task_1"); err != nil {
|
|
t.Fatalf("Kill returned error: %v", err)
|
|
}
|
|
task, ok := manager.Get("task_1")
|
|
if !ok {
|
|
t.Fatal("task_1 not found after Kill")
|
|
}
|
|
if task.Status != StatusKilled {
|
|
t.Fatalf("status = %q, want %q — the exit waiter must not clobber a user-initiated kill", task.Status, StatusKilled)
|
|
}
|
|
}
|
|
|
|
func TestMarkKilledIfStillRunningReportsWhetherItMarked(t *testing.T) {
|
|
manager, err := NewManagerWithOptions(ManagerOptions{
|
|
RootDir: t.TempDir(),
|
|
Now: sequenceClock(time.Date(2026, 6, 8, 9, 0, 0, 0, time.UTC)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewManagerWithOptions returned error: %v", err)
|
|
}
|
|
if _, err := manager.Register(RegisterInput{TaskID: "t", Type: "specialist", PID: 7}); err != nil {
|
|
t.Fatalf("Register returned error: %v", err)
|
|
}
|
|
|
|
marked, err := manager.markKilledIfStillRunning("t", 7)
|
|
if err != nil || !marked {
|
|
t.Fatalf("first call: marked=%v err=%v, want true/nil", marked, err)
|
|
}
|
|
// Already killed (not running) → must report not-marked so Kill skips signaling
|
|
// a possibly-stale pid.
|
|
marked, err = manager.markKilledIfStillRunning("t", 7)
|
|
if err != nil || marked {
|
|
t.Fatalf("second call: marked=%v err=%v, want false/nil", marked, err)
|
|
}
|
|
}
|
|
|
|
func sequenceClock(times ...time.Time) func() time.Time {
|
|
index := 0
|
|
return func() time.Time {
|
|
if index >= len(times) {
|
|
return times[len(times)-1]
|
|
}
|
|
next := times[index]
|
|
index++
|
|
return next
|
|
}
|
|
}
|