blob: 5705c63ace518ab5898a15f205ad65dfa16a15c1 [file] [log] [blame]
package e2e
import (
"fmt"
"os"
"sort"
"strings"
"sync"
"time"
)
const (
// The harness deadline includes setup and leaves one minute before Make's
// exact 10-minute Go timeout for cleanup and the emergency exit bound.
defaultSuiteWatchdogTimeout = 9 * time.Minute
watchdogCleanupExitTimeout = 45 * time.Second
emergencyCleanupExitGrace = 5 * time.Second
suiteWatchdogTimeoutEnv = "AUTH_UI_E2E_WATCHDOG_TIMEOUT"
)
type sessionClaim uint8
const (
sessionUnclaimed sessionClaim = iota
sessionNormal
sessionTimeout
)
type sessionEntry struct {
claim sessionClaim
done chan struct{}
err error
}
type sessionRegistry struct {
mu sync.Mutex
sessions map[*browserSession]*sessionEntry
timedOut bool
}
func newSessionRegistry() *sessionRegistry {
return &sessionRegistry{sessions: make(map[*browserSession]*sessionEntry)}
}
func (r *sessionRegistry) add(session *browserSession) bool {
if r == nil || session == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
if r.timedOut {
return false
}
r.sessions[session] = &sessionEntry{done: make(chan struct{})}
return true
}
func (r *sessionRegistry) finalizeNormal(session *browserSession) error {
if r == nil {
return session.finalizeClaimed(false)
}
r.mu.Lock()
entry := r.sessions[session]
if entry == nil {
r.mu.Unlock()
return session.finalizeClaimed(false)
}
owner := entry.claim == sessionUnclaimed
if owner {
entry.claim = sessionNormal
}
r.mu.Unlock()
if owner {
err := session.finalizeClaimed(false)
r.complete(session, entry, err)
return err
}
<-entry.done
return entry.err
}
func (r *sessionRegistry) complete(session *browserSession, entry *sessionEntry, err error) {
r.mu.Lock()
entry.err = err
delete(r.sessions, session)
close(entry.done)
r.mu.Unlock()
}
func (r *sessionRegistry) finalizeAfterTimeout() error {
if r == nil {
return nil
}
r.mu.Lock()
r.timedOut = true
type claimedSession struct {
session *browserSession
entry *sessionEntry
owner bool
}
claimed := make([]claimedSession, 0, len(r.sessions))
for session, entry := range r.sessions {
owner := entry.claim == sessionUnclaimed
if owner {
entry.claim = sessionTimeout
}
claimed = append(claimed, claimedSession{session: session, entry: entry, owner: owner})
}
r.mu.Unlock()
sort.Slice(claimed, func(i, j int) bool { return claimed[i].session.dir < claimed[j].session.dir })
var errs []string
for _, claim := range claimed {
if claim.owner {
err := claim.session.finalizeClaimed(true)
r.complete(claim.session, claim.entry, err)
}
<-claim.entry.done
if claim.entry.err != nil {
errs = append(errs, fmt.Sprintf("%s: %v", filepathBase(claim.session.dir), claim.entry.err))
}
}
if len(errs) > 0 {
return fmt.Errorf("finalize timed-out browser sessions: %s", strings.Join(errs, "; "))
}
return nil
}
// filepathBase avoids putting full retained paths into aggregate errors.
func filepathBase(path string) string {
for len(path) > 1 && os.IsPathSeparator(path[len(path)-1]) {
path = path[:len(path)-1]
}
if i := strings.LastIndexAny(path, `/\\`); i >= 0 {
return path[i+1:]
}
return path
}
type ownedCloser interface{ Close() error }
type closerRegistry struct {
mu sync.Mutex
closers map[ownedCloser]struct{}
closing bool
}
func newCloserRegistry() *closerRegistry {
return &closerRegistry{closers: make(map[ownedCloser]struct{})}
}
func (r *closerRegistry) add(closer ownedCloser) bool {
if r == nil || closer == nil {
return false
}
r.mu.Lock()
defer r.mu.Unlock()
if r.closing {
return false
}
r.closers[closer] = struct{}{}
return true
}
func (r *closerRegistry) remove(closer ownedCloser) {
if r == nil || closer == nil {
return
}
r.mu.Lock()
delete(r.closers, closer)
r.mu.Unlock()
}
func (r *closerRegistry) closeAll() error {
if r == nil {
return nil
}
r.mu.Lock()
r.closing = true
closers := make([]ownedCloser, 0, len(r.closers))
for closer := range r.closers {
closers = append(closers, closer)
delete(r.closers, closer)
}
r.mu.Unlock()
var errs []string
for _, closer := range closers {
if err := closer.Close(); err != nil {
errs = append(errs, err.Error())
}
}
if len(errs) > 0 {
return fmt.Errorf("close owned listeners: %s", strings.Join(errs, "; "))
}
return nil
}
type terminalReason uint8
const (
terminalRunning terminalReason = iota
terminalNormal
terminalTimeout
)
type suiteLifecycle struct {
mu sync.Mutex
reason terminalReason
timeoutExitClaimed bool
stack *Stack
browser *browserRuntime
sessions *sessionRegistry
closers *closerRegistry
finishOnce sync.Once
finishErr error
}
// Installed before tagged-suite setup and retained until process exit.
var activeSuiteLifecycle *suiteLifecycle
func newSuiteLifecycle(stack *Stack, browser *browserRuntime) *suiteLifecycle {
return &suiteLifecycle{stack: stack, browser: browser, sessions: newSessionRegistry(), closers: newCloserRegistry()}
}
func (l *suiteLifecycle) setStack(stack *Stack) bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.reason != terminalRunning {
return false
}
l.stack = stack
return true
}
func (l *suiteLifecycle) setBrowser(browser *browserRuntime) bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.reason != terminalRunning {
return false
}
l.browser = browser
return true
}
func (l *suiteLifecycle) claimTimeout() bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.reason != terminalRunning {
return false
}
l.reason = terminalTimeout
return true
}
// claimAfterRun stops and claims normal cleanup atomically. If the timer is
// already firing, this caller claims timeout completion so timeout status and
// artifacts cannot be lost while the callback is waiting to run.
func (l *suiteLifecycle) claimAfterRun(watchdog *suiteWatchdog) terminalReason {
l.mu.Lock()
defer l.mu.Unlock()
if l.reason != terminalRunning {
return l.reason
}
if watchdog != nil && !watchdog.timer.Stop() {
l.reason = terminalTimeout
} else {
l.reason = terminalNormal
}
return l.reason
}
func (l *suiteLifecycle) claimTimeoutExit() bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.reason != terminalTimeout || l.timeoutExitClaimed {
return false
}
l.timeoutExitClaimed = true
return true
}
func (l *suiteLifecycle) currentReason() terminalReason {
l.mu.Lock()
defer l.mu.Unlock()
return l.reason
}
func (l *suiteLifecycle) diagnosticPaths() (workspace, artifacts string) {
l.mu.Lock()
stack := l.stack
l.mu.Unlock()
if stack != nil {
return stack.Workspace, stack.ArtifactDir
}
return "unavailable (setup incomplete)", "unavailable (setup incomplete)"
}
func (l *suiteLifecycle) finish(success bool) error {
if l == nil {
return nil
}
l.finishOnce.Do(func() {
l.mu.Lock()
reason, stack, browser := l.reason, l.stack, l.browser
l.mu.Unlock()
timedOut := reason == terminalTimeout
var errs []string
if timedOut && stack != nil {
stack.recordRun("watchdog_timeout", fmt.Errorf("internal E2E suite deadline exceeded"))
}
if timedOut {
if err := l.sessions.finalizeAfterTimeout(); err != nil {
errs = append(errs, err.Error())
}
}
if err := l.closers.closeAll(); err != nil {
errs = append(errs, err.Error())
}
if stack != nil {
if err := stack.StopServices(); err != nil {
errs = append(errs, "services: "+err.Error())
}
}
if browser != nil {
if err := browser.Close(); err != nil {
errs = append(errs, "browser: "+err.Error())
}
}
finalSuccess := success && !timedOut && len(errs) == 0
if stack != nil {
if err := stack.Finalize(finalSuccess); err != nil {
errs = append(errs, "artifacts: "+err.Error())
}
}
if len(errs) > 0 {
l.finishErr = fmt.Errorf("E2E suite cleanup: %s", strings.Join(errs, "; "))
}
})
return l.finishErr
}
func (l *suiteLifecycle) emergencyTerminate() error {
l.mu.Lock()
stack := l.stack
l.mu.Unlock()
var groups []int
if stack != nil {
groups = stack.emergencyProcessGroups()
}
return emergencyTerminateOwnedProcesses(os.Getpid(), groups)
}
func suiteWatchdogTimeout() (time.Duration, error) {
value := os.Getenv(suiteWatchdogTimeoutEnv)
if value == "" {
return defaultSuiteWatchdogTimeout, nil
}
duration, err := time.ParseDuration(value)
if err != nil || duration <= 0 {
return 0, fmt.Errorf("%s must be a positive Go duration", suiteWatchdogTimeoutEnv)
}
return duration, nil
}
type suiteWatchdog struct{ timer *time.Timer }
func startSuiteWatchdog(timeout time.Duration, fire func()) *suiteWatchdog {
return &suiteWatchdog{timer: time.AfterFunc(timeout, fire)}
}
func (w *suiteWatchdog) stop() bool { return w == nil || w.timer.Stop() }
func stopCleanupTimerOrWait(stop func() bool, wait func()) {
if !stop() {
wait()
}
}
func waitForEmergencyExit() { select {} }