blob: 5705c63ace518ab5898a15f205ad65dfa16a15c1 [file] [log] [blame]
giob7df27f2026-07-28 10:36:17 +04001package e2e
2
3import (
4 "fmt"
5 "os"
6 "sort"
7 "strings"
8 "sync"
9 "time"
10)
11
12const (
13 // The harness deadline includes setup and leaves one minute before Make's
14 // exact 10-minute Go timeout for cleanup and the emergency exit bound.
15 defaultSuiteWatchdogTimeout = 9 * time.Minute
16 watchdogCleanupExitTimeout = 45 * time.Second
17 emergencyCleanupExitGrace = 5 * time.Second
18 suiteWatchdogTimeoutEnv = "AUTH_UI_E2E_WATCHDOG_TIMEOUT"
19)
20
21type sessionClaim uint8
22
23const (
24 sessionUnclaimed sessionClaim = iota
25 sessionNormal
26 sessionTimeout
27)
28
29type sessionEntry struct {
30 claim sessionClaim
31 done chan struct{}
32 err error
33}
34
35type sessionRegistry struct {
36 mu sync.Mutex
37 sessions map[*browserSession]*sessionEntry
38 timedOut bool
39}
40
41func newSessionRegistry() *sessionRegistry {
42 return &sessionRegistry{sessions: make(map[*browserSession]*sessionEntry)}
43}
44
45func (r *sessionRegistry) add(session *browserSession) bool {
46 if r == nil || session == nil {
47 return false
48 }
49 r.mu.Lock()
50 defer r.mu.Unlock()
51 if r.timedOut {
52 return false
53 }
54 r.sessions[session] = &sessionEntry{done: make(chan struct{})}
55 return true
56}
57
58func (r *sessionRegistry) finalizeNormal(session *browserSession) error {
59 if r == nil {
60 return session.finalizeClaimed(false)
61 }
62 r.mu.Lock()
63 entry := r.sessions[session]
64 if entry == nil {
65 r.mu.Unlock()
66 return session.finalizeClaimed(false)
67 }
68 owner := entry.claim == sessionUnclaimed
69 if owner {
70 entry.claim = sessionNormal
71 }
72 r.mu.Unlock()
73 if owner {
74 err := session.finalizeClaimed(false)
75 r.complete(session, entry, err)
76 return err
77 }
78 <-entry.done
79 return entry.err
80}
81
82func (r *sessionRegistry) complete(session *browserSession, entry *sessionEntry, err error) {
83 r.mu.Lock()
84 entry.err = err
85 delete(r.sessions, session)
86 close(entry.done)
87 r.mu.Unlock()
88}
89
90func (r *sessionRegistry) finalizeAfterTimeout() error {
91 if r == nil {
92 return nil
93 }
94 r.mu.Lock()
95 r.timedOut = true
96 type claimedSession struct {
97 session *browserSession
98 entry *sessionEntry
99 owner bool
100 }
101 claimed := make([]claimedSession, 0, len(r.sessions))
102 for session, entry := range r.sessions {
103 owner := entry.claim == sessionUnclaimed
104 if owner {
105 entry.claim = sessionTimeout
106 }
107 claimed = append(claimed, claimedSession{session: session, entry: entry, owner: owner})
108 }
109 r.mu.Unlock()
110 sort.Slice(claimed, func(i, j int) bool { return claimed[i].session.dir < claimed[j].session.dir })
111 var errs []string
112 for _, claim := range claimed {
113 if claim.owner {
114 err := claim.session.finalizeClaimed(true)
115 r.complete(claim.session, claim.entry, err)
116 }
117 <-claim.entry.done
118 if claim.entry.err != nil {
119 errs = append(errs, fmt.Sprintf("%s: %v", filepathBase(claim.session.dir), claim.entry.err))
120 }
121 }
122 if len(errs) > 0 {
123 return fmt.Errorf("finalize timed-out browser sessions: %s", strings.Join(errs, "; "))
124 }
125 return nil
126}
127
128// filepathBase avoids putting full retained paths into aggregate errors.
129func filepathBase(path string) string {
130 for len(path) > 1 && os.IsPathSeparator(path[len(path)-1]) {
131 path = path[:len(path)-1]
132 }
133 if i := strings.LastIndexAny(path, `/\\`); i >= 0 {
134 return path[i+1:]
135 }
136 return path
137}
138
139type ownedCloser interface{ Close() error }
140
141type closerRegistry struct {
142 mu sync.Mutex
143 closers map[ownedCloser]struct{}
144 closing bool
145}
146
147func newCloserRegistry() *closerRegistry {
148 return &closerRegistry{closers: make(map[ownedCloser]struct{})}
149}
150
151func (r *closerRegistry) add(closer ownedCloser) bool {
152 if r == nil || closer == nil {
153 return false
154 }
155 r.mu.Lock()
156 defer r.mu.Unlock()
157 if r.closing {
158 return false
159 }
160 r.closers[closer] = struct{}{}
161 return true
162}
163
164func (r *closerRegistry) remove(closer ownedCloser) {
165 if r == nil || closer == nil {
166 return
167 }
168 r.mu.Lock()
169 delete(r.closers, closer)
170 r.mu.Unlock()
171}
172
173func (r *closerRegistry) closeAll() error {
174 if r == nil {
175 return nil
176 }
177 r.mu.Lock()
178 r.closing = true
179 closers := make([]ownedCloser, 0, len(r.closers))
180 for closer := range r.closers {
181 closers = append(closers, closer)
182 delete(r.closers, closer)
183 }
184 r.mu.Unlock()
185 var errs []string
186 for _, closer := range closers {
187 if err := closer.Close(); err != nil {
188 errs = append(errs, err.Error())
189 }
190 }
191 if len(errs) > 0 {
192 return fmt.Errorf("close owned listeners: %s", strings.Join(errs, "; "))
193 }
194 return nil
195}
196
197type terminalReason uint8
198
199const (
200 terminalRunning terminalReason = iota
201 terminalNormal
202 terminalTimeout
203)
204
205type suiteLifecycle struct {
206 mu sync.Mutex
207 reason terminalReason
208 timeoutExitClaimed bool
209 stack *Stack
210 browser *browserRuntime
211 sessions *sessionRegistry
212 closers *closerRegistry
213
214 finishOnce sync.Once
215 finishErr error
216}
217
218// Installed before tagged-suite setup and retained until process exit.
219var activeSuiteLifecycle *suiteLifecycle
220
221func newSuiteLifecycle(stack *Stack, browser *browserRuntime) *suiteLifecycle {
222 return &suiteLifecycle{stack: stack, browser: browser, sessions: newSessionRegistry(), closers: newCloserRegistry()}
223}
224
225func (l *suiteLifecycle) setStack(stack *Stack) bool {
226 l.mu.Lock()
227 defer l.mu.Unlock()
228 if l.reason != terminalRunning {
229 return false
230 }
231 l.stack = stack
232 return true
233}
234
235func (l *suiteLifecycle) setBrowser(browser *browserRuntime) bool {
236 l.mu.Lock()
237 defer l.mu.Unlock()
238 if l.reason != terminalRunning {
239 return false
240 }
241 l.browser = browser
242 return true
243}
244
245func (l *suiteLifecycle) claimTimeout() bool {
246 l.mu.Lock()
247 defer l.mu.Unlock()
248 if l.reason != terminalRunning {
249 return false
250 }
251 l.reason = terminalTimeout
252 return true
253}
254
255// claimAfterRun stops and claims normal cleanup atomically. If the timer is
256// already firing, this caller claims timeout completion so timeout status and
257// artifacts cannot be lost while the callback is waiting to run.
258func (l *suiteLifecycle) claimAfterRun(watchdog *suiteWatchdog) terminalReason {
259 l.mu.Lock()
260 defer l.mu.Unlock()
261 if l.reason != terminalRunning {
262 return l.reason
263 }
264 if watchdog != nil && !watchdog.timer.Stop() {
265 l.reason = terminalTimeout
266 } else {
267 l.reason = terminalNormal
268 }
269 return l.reason
270}
271
272func (l *suiteLifecycle) claimTimeoutExit() bool {
273 l.mu.Lock()
274 defer l.mu.Unlock()
275 if l.reason != terminalTimeout || l.timeoutExitClaimed {
276 return false
277 }
278 l.timeoutExitClaimed = true
279 return true
280}
281
282func (l *suiteLifecycle) currentReason() terminalReason {
283 l.mu.Lock()
284 defer l.mu.Unlock()
285 return l.reason
286}
287
288func (l *suiteLifecycle) diagnosticPaths() (workspace, artifacts string) {
289 l.mu.Lock()
290 stack := l.stack
291 l.mu.Unlock()
292 if stack != nil {
293 return stack.Workspace, stack.ArtifactDir
294 }
295 return "unavailable (setup incomplete)", "unavailable (setup incomplete)"
296}
297
298func (l *suiteLifecycle) finish(success bool) error {
299 if l == nil {
300 return nil
301 }
302 l.finishOnce.Do(func() {
303 l.mu.Lock()
304 reason, stack, browser := l.reason, l.stack, l.browser
305 l.mu.Unlock()
306 timedOut := reason == terminalTimeout
307 var errs []string
308 if timedOut && stack != nil {
309 stack.recordRun("watchdog_timeout", fmt.Errorf("internal E2E suite deadline exceeded"))
310 }
311 if timedOut {
312 if err := l.sessions.finalizeAfterTimeout(); err != nil {
313 errs = append(errs, err.Error())
314 }
315 }
316 if err := l.closers.closeAll(); err != nil {
317 errs = append(errs, err.Error())
318 }
319 if stack != nil {
320 if err := stack.StopServices(); err != nil {
321 errs = append(errs, "services: "+err.Error())
322 }
323 }
324 if browser != nil {
325 if err := browser.Close(); err != nil {
326 errs = append(errs, "browser: "+err.Error())
327 }
328 }
329 finalSuccess := success && !timedOut && len(errs) == 0
330 if stack != nil {
331 if err := stack.Finalize(finalSuccess); err != nil {
332 errs = append(errs, "artifacts: "+err.Error())
333 }
334 }
335 if len(errs) > 0 {
336 l.finishErr = fmt.Errorf("E2E suite cleanup: %s", strings.Join(errs, "; "))
337 }
338 })
339 return l.finishErr
340}
341
342func (l *suiteLifecycle) emergencyTerminate() error {
343 l.mu.Lock()
344 stack := l.stack
345 l.mu.Unlock()
346 var groups []int
347 if stack != nil {
348 groups = stack.emergencyProcessGroups()
349 }
350 return emergencyTerminateOwnedProcesses(os.Getpid(), groups)
351}
352
353func suiteWatchdogTimeout() (time.Duration, error) {
354 value := os.Getenv(suiteWatchdogTimeoutEnv)
355 if value == "" {
356 return defaultSuiteWatchdogTimeout, nil
357 }
358 duration, err := time.ParseDuration(value)
359 if err != nil || duration <= 0 {
360 return 0, fmt.Errorf("%s must be a positive Go duration", suiteWatchdogTimeoutEnv)
361 }
362 return duration, nil
363}
364
365type suiteWatchdog struct{ timer *time.Timer }
366
367func startSuiteWatchdog(timeout time.Duration, fire func()) *suiteWatchdog {
368 return &suiteWatchdog{timer: time.AfterFunc(timeout, fire)}
369}
370
371func (w *suiteWatchdog) stop() bool { return w == nil || w.timer.Stop() }
372
373func stopCleanupTimerOrWait(stop func() bool, wait func()) {
374 if !stop() {
375 wait()
376 }
377}
378
379func waitForEmergencyExit() { select {} }