| gio | b7df27f | 2026-07-28 10:36:17 +0400 | [diff] [blame^] | 1 | package e2e |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "os" |
| 6 | "sort" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | "time" |
| 10 | ) |
| 11 | |
| 12 | const ( |
| 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 | |
| 21 | type sessionClaim uint8 |
| 22 | |
| 23 | const ( |
| 24 | sessionUnclaimed sessionClaim = iota |
| 25 | sessionNormal |
| 26 | sessionTimeout |
| 27 | ) |
| 28 | |
| 29 | type sessionEntry struct { |
| 30 | claim sessionClaim |
| 31 | done chan struct{} |
| 32 | err error |
| 33 | } |
| 34 | |
| 35 | type sessionRegistry struct { |
| 36 | mu sync.Mutex |
| 37 | sessions map[*browserSession]*sessionEntry |
| 38 | timedOut bool |
| 39 | } |
| 40 | |
| 41 | func newSessionRegistry() *sessionRegistry { |
| 42 | return &sessionRegistry{sessions: make(map[*browserSession]*sessionEntry)} |
| 43 | } |
| 44 | |
| 45 | func (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 | |
| 58 | func (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 | |
| 82 | func (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 | |
| 90 | func (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. |
| 129 | func 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 | |
| 139 | type ownedCloser interface{ Close() error } |
| 140 | |
| 141 | type closerRegistry struct { |
| 142 | mu sync.Mutex |
| 143 | closers map[ownedCloser]struct{} |
| 144 | closing bool |
| 145 | } |
| 146 | |
| 147 | func newCloserRegistry() *closerRegistry { |
| 148 | return &closerRegistry{closers: make(map[ownedCloser]struct{})} |
| 149 | } |
| 150 | |
| 151 | func (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 | |
| 164 | func (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 | |
| 173 | func (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 | |
| 197 | type terminalReason uint8 |
| 198 | |
| 199 | const ( |
| 200 | terminalRunning terminalReason = iota |
| 201 | terminalNormal |
| 202 | terminalTimeout |
| 203 | ) |
| 204 | |
| 205 | type 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. |
| 219 | var activeSuiteLifecycle *suiteLifecycle |
| 220 | |
| 221 | func newSuiteLifecycle(stack *Stack, browser *browserRuntime) *suiteLifecycle { |
| 222 | return &suiteLifecycle{stack: stack, browser: browser, sessions: newSessionRegistry(), closers: newCloserRegistry()} |
| 223 | } |
| 224 | |
| 225 | func (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 | |
| 235 | func (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 | |
| 245 | func (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. |
| 258 | func (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 | |
| 272 | func (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 | |
| 282 | func (l *suiteLifecycle) currentReason() terminalReason { |
| 283 | l.mu.Lock() |
| 284 | defer l.mu.Unlock() |
| 285 | return l.reason |
| 286 | } |
| 287 | |
| 288 | func (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 | |
| 298 | func (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 | |
| 342 | func (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 | |
| 353 | func 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 | |
| 365 | type suiteWatchdog struct{ timer *time.Timer } |
| 366 | |
| 367 | func startSuiteWatchdog(timeout time.Duration, fire func()) *suiteWatchdog { |
| 368 | return &suiteWatchdog{timer: time.AfterFunc(timeout, fire)} |
| 369 | } |
| 370 | |
| 371 | func (w *suiteWatchdog) stop() bool { return w == nil || w.timer.Stop() } |
| 372 | |
| 373 | func stopCleanupTimerOrWait(stop func() bool, wait func()) { |
| 374 | if !stop() { |
| 375 | wait() |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | func waitForEmergencyExit() { select {} } |