package browser import ( "fmt" "sync" "time" ) // CdpWaitIdleOpts configura el comportamiento de CdpWaitIdle. type CdpWaitIdleOpts struct { QuietMs int // ms que inflight debe permanecer <= MaxInflight (default 500) Timeout time.Duration // maximo total a esperar (default 8s) MaxInflight int // requests en vuelo tolerados para considerar idle (default 2) PollMs int // intervalo de chequeo en ms (default 100) } // isPersistentResourceType indica si un Network resourceType corresponde a una // conexion de larga duracion que NO emite loadingFinished/loadingFailed y por // tanto colgaria el contador inflight para siempre. La pagina abre estas // conexiones (analytics en vivo, push, hot-reload) y nunca "terminan". func isPersistentResourceType(resourceType string) bool { switch resourceType { case "WebSocket", "EventSource": return true default: return false } } // InflightTracker cuenta requests de red en vuelo de forma pura y testeable: no // toca red ni CDP, solo recibe eventos ya parseados (requestId + resourceType) y // mantiene el conjunto de requests activos. Trackea por requestId para que el // loadingFinished/loadingFailed de un request que nunca contamos (una conexion // persistente) sea un no-op en vez de un decremento espurio. // // Las conexiones persistentes (WebSocket, EventSource) se excluyen del conteo // porque no emiten un evento de finalizacion: contarlas haria que la red nunca // pareciera idle. type InflightTracker struct { mu sync.Mutex tracked map[string]bool } // NewInflightTracker crea un tracker vacio listo para recibir eventos. func NewInflightTracker() *InflightTracker { return &InflightTracker{tracked: map[string]bool{}} } // OnRequest registra el inicio de un request (Network.requestWillBeSent). Ignora // las conexiones persistentes para no contaminar el conteo. func (t *InflightTracker) OnRequest(requestID, resourceType string) { if isPersistentResourceType(resourceType) { return } t.mu.Lock() t.tracked[requestID] = true t.mu.Unlock() } // OnFinish marca un request como completado (Network.loadingFinished). func (t *InflightTracker) OnFinish(requestID string) { t.complete(requestID) } // OnFail marca un request como fallido (Network.loadingFailed). A efectos de // inflight, fallar y terminar son lo mismo: el request ya no esta en vuelo. func (t *InflightTracker) OnFail(requestID string) { t.complete(requestID) } func (t *InflightTracker) complete(requestID string) { t.mu.Lock() delete(t.tracked, requestID) t.mu.Unlock() } // Inflight retorna el numero de requests actualmente en vuelo. func (t *InflightTracker) Inflight() int { t.mu.Lock() defer t.mu.Unlock() return len(t.tracked) } // IsIdle indica si el numero de requests en vuelo esta dentro del umbral dado. func (t *InflightTracker) IsIdle(maxInflight int) bool { return t.Inflight() <= maxInflight } // CdpWaitIdle espera a que la actividad de red de la pagina llegue a idle. // Suscribe eventos Network.requestWillBeSent / Network.loadingFinished / // Network.loadingFailed via el mecanismo OnEvent del CDPConn y delega el conteo // en un InflightTracker. Cuando inflight <= MaxInflight de forma continuada // durante QuietMs milisegundos, la funcion retorna nil. Si se alcanza Timeout // sin lograr esa ventana quieta, retorna error con el inflight actual. // // Inmune a extensiones que mutan el DOM (Dark Reader, uBlock) y a animaciones // JS, ya que la seƱal es red, no DOM. func CdpWaitIdle(c *CDPConn, opts CdpWaitIdleOpts) error { if c == nil { return fmt.Errorf("cdp wait idle: conexion nula") } // Aplicar defaults. if opts.QuietMs <= 0 { opts.QuietMs = 500 } if opts.Timeout <= 0 { opts.Timeout = 8 * time.Second } // MaxInflight default 2: la web moderna mantiene 1-2 beacons/analytics de // fondo que casi nunca dejan inflight en 0; exigir 0 cuelga hasta el timeout. if opts.MaxInflight <= 0 { opts.MaxInflight = 2 } if opts.PollMs <= 0 { opts.PollMs = 100 } tracker := NewInflightTracker() // Suscribir eventos Network usando el mismo mecanismo que cdp_har_record: // c.OnEvent retorna una funcion cancel que des-registra el handler. // Multiples consumidores del mismo metodo son soportados (slice de handlers). cancel1 := c.OnEvent("Network.requestWillBeSent", func(_ string, p map[string]any) { id, _ := p["requestId"].(string) typ, _ := p["type"].(string) tracker.OnRequest(id, typ) }) defer cancel1() cancel2 := c.OnEvent("Network.loadingFinished", func(_ string, p map[string]any) { id, _ := p["requestId"].(string) tracker.OnFinish(id) }) defer cancel2() cancel3 := c.OnEvent("Network.loadingFailed", func(_ string, p map[string]any) { id, _ := p["requestId"].(string) tracker.OnFail(id) }) defer cancel3() // Habilitar dominio Network (igual que cdp_har_record). if _, err := c.sendCDP("Network.enable", nil); err != nil { return fmt.Errorf("cdp wait idle: Network.enable: %w", err) } defer c.sendCDP("Network.disable", nil) //nolint:errcheck deadline := time.Now().Add(opts.Timeout) pollInterval := time.Duration(opts.PollMs) * time.Millisecond quietThreshold := time.Duration(opts.QuietMs) * time.Millisecond var quietSince time.Time for time.Now().Before(deadline) { time.Sleep(pollInterval) if tracker.IsIdle(opts.MaxInflight) { // Red idle: iniciar o mantener la ventana de quietud. if quietSince.IsZero() { quietSince = time.Now() } if time.Since(quietSince) >= quietThreshold { return nil } } else { // Actividad detectada: reiniciar ventana. quietSince = time.Time{} } } return fmt.Errorf("cdp wait idle: red no alcanzo idle despues de %s (inflight=%d)", opts.Timeout, tracker.Inflight()) }