1package visualproc
2
3import (
4 "context"
5 "io"
6 "os"
7 "os/exec"
8 "syscall"
9 "testing"
10 "time"
11
12 "github.com/bnema/neferafk/internal/ports"
13 "github.com/bnema/zerowrap"
14)
15
16// TestMain doubles as the child process: with NEFERAFK_TEST_CHILD set the test
17// binary behaves like a tiny visual on fds 3/4 instead of running tests.
18func TestMain(m *testing.M) {
19 switch os.Getenv("NEFERAFK_TEST_CHILD") {
20 case "":
21 os.Exit(m.Run())
22 case "echo": // confirm the lock it is asked for, then exit cleanly
23 if os.Getenv("NEFERAFK_TEST_PIN") != "" {
24 os.Exit(9) // the omitted variable leaked into the child
25 }
26 cmd, err := ReadCommand(os.NewFile(3, "cmd"))
27 lock, ok := cmd.(ports.VisualLock)
28 if err != nil || !ok || string(lock.Auth.EnvPIN) != "123456" {
29 os.Exit(10)
30 }
31 if WriteEvent(os.NewFile(4, "ev"), ports.LockConfirmed{Generation: lock.Generation}) != nil {
32 os.Exit(11)
33 }
34 os.Exit(0)
35 case "crash":
36 os.Exit(7)
37 }
38}
39
40func launcher(mode string) *Launcher {
41 exe, _ := os.Executable()
42 return &Launcher{Executable: exe, Env: append(os.Environ(), "NEFERAFK_TEST_CHILD="+mode, "NEFERAFK_TEST_PIN=123456"), Log: zerowrap.New(zerowrap.Config{Output: io.Discard})}
43}
44
45func next(t *testing.T, s ports.VisualSession) ports.VisualEvent {
46 t.Helper()
47 select {
48 case ev, ok := <-s.Events():
49 if !ok {
50 t.Fatal("events closed")
51 }
52 return ev
53 case <-time.After(5 * time.Second):
54 t.Fatal("no event")
55 return nil
56 }
57}
58
59func TestLauncherPrivatePipesAndEnvOmission(t *testing.T) {
60 s, err := launcher("echo").Launch(context.Background(), ports.VisualSpawn{OmitEnv: []string{"NEFERAFK_TEST_PIN"}})
61 if err != nil {
62 t.Fatal(err)
63 }
64 defer s.Close()
65 if err := s.Send(ports.VisualLock{Generation: 5, Auth: ports.AuthBootstrap{Generation: 5, EnvPIN: []byte("123456")}}); err != nil {
66 t.Fatal(err)
67 }
68 if ev := next(t, s); ev != (ports.LockConfirmed{Generation: 5}) {
69 t.Fatalf("%#v", ev)
70 }
71 ev := next(t, s)
72 if x, ok := ev.(ports.VisualExited); !ok || x.Err != nil {
73 t.Fatalf("clean exit reported as %#v", ev)
74 }
75 if _, ok := <-s.Events(); ok {
76 t.Fatal("events not closed after exit")
77 }
78}
79
80func TestLauncherReportsCrashAsExitNeverAsUnlock(t *testing.T) {
81 s, err := launcher("crash").Launch(context.Background(), ports.VisualSpawn{})
82 if err != nil {
83 t.Fatal(err)
84 }
85 defer s.Close()
86 ev := next(t, s)
87 if x, ok := ev.(ports.VisualExited); !ok || x.Err == nil {
88 t.Fatalf("crash reported as %#v", ev)
89 }
90}
91
92func TestLauncherDetachLeavesChildAndSecondCloseIsSafe(t *testing.T) {
93 s, err := launcher("echo").Launch(context.Background(), ports.VisualSpawn{OmitEnv: []string{"NEFERAFK_TEST_PIN"}})
94 if err != nil {
95 t.Fatal(err)
96 }
97 if err := s.Detach(); err != nil {
98 t.Fatal(err)
99 }
100 _ = s.Detach()
101}
102
103func TestLauncherLaunchFailure(t *testing.T) {
104 if _, err := (&Launcher{Executable: "/nonexistent/neferafk", Log: zerowrap.New(zerowrap.Config{Output: io.Discard})}).Launch(context.Background(), ports.VisualSpawn{}); err == nil {
105 t.Fatal("missing executable launched")
106 }
107}
108
109// sleeper starts an unrelated process in its own group standing in for a
110// reused pid/pgid.
111func sleeper(t *testing.T) *exec.Cmd {
112 t.Helper()
113 c := exec.Command("sleep", "30")
114 c.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
115 if err := c.Start(); err != nil {
116 t.Skipf("sleep unavailable: %v", err)
117 }
118 t.Cleanup(func() { _ = c.Process.Kill(); _ = c.Wait() })
119 return c
120}
121
122func alive(pid int) bool { return syscall.Kill(pid, 0) == nil }
123
124func TestCloseAfterReapNeverSignalsTheGroup(t *testing.T) {
125 victim := sleeper(t)
126 _, w, err := os.Pipe()
127 if err != nil {
128 t.Fatal(err)
129 }
130 exited := make(chan struct{})
131 close(exited) // the visual was already reaped: its pgid may be reused
132 s := &session{cmd: victim, w: w, exited: exited, log: zerowrap.New(zerowrap.Config{Output: io.Discard})}
133 if err := s.Close(); err != nil {
134 t.Fatal(err)
135 }
136 if err := s.Close(); err != nil { // idempotent
137 t.Fatal(err)
138 }
139 time.Sleep(50 * time.Millisecond)
140 if !alive(victim.Process.Pid) {
141 t.Fatal("Close signalled a process group after the child was reaped")
142 }
143}
144
145func TestCloseBeforeReapKillsTheGroup(t *testing.T) {
146 child := sleeper(t)
147 _, w, _ := os.Pipe()
148 s := &session{cmd: child, w: w, exited: make(chan struct{}), log: zerowrap.New(zerowrap.Config{Output: io.Discard})}
149 if err := s.Close(); err != nil {
150 t.Fatal(err)
151 }
152 done := make(chan error, 1)
153 go func() { done <- child.Wait() }()
154 select {
155 case <-done:
156 case <-time.After(5 * time.Second):
157 t.Fatal("live child group not killed")
158 }
159}