1package hints
2
3import (
4 "context"
5 "io"
6 "os"
7 "os/exec"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/require"
14)
15
16// The detector child of these tests is the test binary itself, re-executed:
17// a real process, started with the detector's own arguments. TestMain turns
18// it into helperChild when helperEnv is set.
19const (
20 helperEnv = "NEFERWL_HINTS_HELPER"
21 helperPIDFile = "NEFERWL_HINTS_HELPER_PIDFILE"
22 helperChildPID = "NEFERWL_HINTS_HELPER_CHILDPID"
23)
24
25func TestMain(m *testing.M) {
26 if mode := os.Getenv(helperEnv); mode != "" {
27 helperChild(mode)
28 os.Exit(0)
29 }
30 os.Exit(m.Run())
31}
32
33func helperHello() []byte {
34 return cat(magicHello[:], u32(protocolVersion), u32(0), u32(4), []byte("test"), u32(imgsz))
35}
36
37// helperChild plays the detector for mode:
38//
39// nohello never says hello
40// silent says hello, then never answers
41// grandchild like nohello, after spawning a grandchild that shares the
42// child's stdout, stderr and process group
43// ok says hello and answers every request with no box
44func helperChild(mode string) {
45 if f := os.Getenv(helperChildPID); f != "" {
46 _ = os.WriteFile(f, []byte(strconv.Itoa(os.Getpid())), 0o600)
47 }
48 if mode == "grandchild" {
49 gc := exec.Command("sleep", "60")
50 gc.Stdout, gc.Stderr = os.Stdout, os.Stderr
51 if err := gc.Start(); err != nil {
52 os.Exit(2)
53 }
54 _ = os.WriteFile(os.Getenv(helperPIDFile), []byte(strconv.Itoa(gc.Process.Pid)), 0o600)
55 mode = "nohello"
56 }
57 if mode != "nohello" {
58 _, _ = os.Stdout.Write(helperHello())
59 }
60 if mode != "ok" {
61 time.Sleep(time.Minute)
62 return
63 }
64 for {
65 var h [40]byte
66 if _, err := io.ReadFull(os.Stdin, h[:]); err != nil {
67 return
68 }
69 _, err := io.CopyN(io.Discard, os.Stdin, int64(le.Uint32(h[20:])*le.Uint32(h[24:])))
70 if err != nil {
71 return
72 }
73 _, _ = os.Stdout.Write(cat(magicResponse[:], u32(protocolVersion), h[8:16], u32(0), u32(0), u32(0)))
74 }
75}
76
77func helperDetector(t *testing.T, mode string) *procDetector {
78 t.Helper()
79 t.Setenv(helperEnv, mode)
80 d := newProcDetector(os.Args[0], t.TempDir(), 0.05)
81 d.helloTimeout, d.reqTimeout = 300*time.Millisecond, 300*time.Millisecond
82 t.Cleanup(func() { _ = d.Close() })
83 return d
84}
85
86func tinyFrame() detFrame {
87 return detFrame{width: 1, height: 1, stride: 4, m: &mapping{b: make([]byte, 4), live: new(int)}}
88}
89
90// processGone reports whether pid is dead (or a zombie waiting for its
91// reaper).
92func processGone(pid int) bool {
93 b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
94 if err != nil {
95 return true
96 }
97 s := string(b)
98 return strings.HasPrefix(s[strings.LastIndexByte(s, ')')+2:], "Z")
99}
100
101func readPID(t *testing.T, file string) int {
102 t.Helper()
103 var pid int
104 require.Eventually(t, func() bool {
105 b, err := os.ReadFile(file)
106 if err != nil {
107 return false
108 }
109 pid, err = strconv.Atoi(string(b))
110 return err == nil
111 }, time.Second, 10*time.Millisecond)
112 return pid
113}
114
115func TestProcDetectorHungChildIsKilledAndRestarted(t *testing.T) {
116 for _, mode := range []string{"nohello", "silent"} {
117 t.Run(mode, func(t *testing.T) {
118 pidFile := t.TempDir() + "/child"
119 t.Setenv(helperChildPID, pidFile)
120 d := helperDetector(t, mode)
121 ctx := context.Background()
122 start := time.Now()
123 _, err := d.Detect(ctx, tinyFrame())
124 require.ErrorContains(t, err, "timed out")
125 if mode == "nohello" {
126 require.ErrorContains(t, err, "hello")
127 }
128 require.Less(t, time.Since(start), 2*time.Second)
129 require.Nil(t, d.proc)
130 pid := readPID(t, pidFile)
131 require.True(t, processGone(pid), "child %d survives", pid)
132
133 // The next Detect starts a new, healthy child.
134 t.Setenv(helperEnv, "ok")
135 t.Setenv(helperChildPID, "")
136 _, err = d.Detect(ctx, tinyFrame())
137 require.NoError(t, err)
138 require.NotNil(t, d.proc)
139 start = time.Now()
140 require.NoError(t, d.Close())
141 require.Less(t, time.Since(start), 3*time.Second)
142 })
143 }
144}
145
146// A grandchild holding the child's stdout and stderr open must neither
147// survive the kill nor keep Wait blocked.
148func TestProcDetectorKillsGrandchild(t *testing.T) {
149 pidFile := t.TempDir() + "/pid"
150 t.Setenv(helperPIDFile, pidFile)
151 d := helperDetector(t, "grandchild")
152 start := time.Now()
153 _, err := d.Detect(context.Background(), tinyFrame())
154 require.ErrorContains(t, err, "hello timed out")
155 require.Less(t, time.Since(start), 2500*time.Millisecond)
156 pid := readPID(t, pidFile)
157 require.Eventually(t, func() bool { return processGone(pid) }, 2*time.Second, 20*time.Millisecond, "grandchild %d survives", pid)
158 require.Nil(t, d.proc)
159}
160
161// Cancelling the context interrupts a pending read.
162func TestProcDetectorContextCancel(t *testing.T) {
163 d := helperDetector(t, "silent")
164 d.reqTimeout = time.Minute
165 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
166 defer cancel()
167 start := time.Now()
168 _, err := d.Detect(ctx, tinyFrame())
169 require.ErrorIs(t, err, context.DeadlineExceeded)
170 require.Less(t, time.Since(start), 2*time.Second)
171 require.Nil(t, d.proc)
172}