88import * as Context from "effect/Context" ;
99import * as NodePath from "node:path" ;
1010import * as Effect from "effect/Effect" ;
11+ import * as Fiber from "effect/Fiber" ;
1112import * as Layer from "effect/Layer" ;
1213import * as Option from "effect/Option" ;
1314import * as PubSub from "effect/PubSub" ;
@@ -26,6 +27,9 @@ const AGENT_INPUT_COMMAND =
2627const AGENT_INPUT_METHOD = / ^ (?: I n p u t \. | P a g e \. n a v i g a t e $ | R u n t i m e \. (?: e v a l u a t e | c a l l F u n c t i o n O n ) $ ) / ;
2728const AGENT_DOWNLOAD_WINDOW_MS = 5_000 ;
2829const FOCUS_IDLE = "5 seconds" ;
30+ /** Well inside the server's 10 s DESKTOP_ATTACH_TIMEOUT, for a first page that never reports it is up. */
31+ const FIRST_PAGE_TIMEOUT = "5 seconds" ;
32+ const ERR_ABORTED = - 3 ;
2933
3034export interface DesktopBrowserTabKey {
3135 readonly threadId : string ;
@@ -78,6 +82,8 @@ interface AttachedTab {
7882 sessionId : string ,
7983 ) => void ;
8084 readonly onNewDocument : ( ) => void ;
85+ /** Stops waiting for the first page; set until the tab is announced. */
86+ stopWaiting : ( ( ) => void ) | null ;
8187}
8288
8389export class DesktopBrowserHost extends Context . Service <
@@ -413,11 +419,13 @@ export const make = Effect.gen(function* () {
413419 const tab = tabs . get ( id ) ;
414420 if ( ! tab ) return ;
415421 tabs . delete ( id ) ;
422+ const announced = tab . stopWaiting === null ;
423+ tab . stopWaiting ?.( ) ;
416424 tab . relay = null ;
417425 tab . debuggee . debugger . off ( "message" , tab . onMessage ) ;
418426 tab . debuggee . webContents . off ( "did-navigate" , tab . onNewDocument ) ;
419427 syncPainting ( tab ) ;
420- emit ( { type : "detached" , ...key } ) ;
428+ if ( announced ) emit ( { type : "detached" , ...key } ) ;
421429 } ;
422430
423431 const attach = ( key : DesktopBrowserTabKey , debuggee : DesktopBrowserTabDebugger ) => {
@@ -455,13 +463,53 @@ export const make = Effect.gen(function* () {
455463 dropFocusHolds ( tab ) ;
456464 if ( tab . screenshotTurn !== null ) nextScreenshot ( tab ) ;
457465 } ,
466+ stopWaiting : null ,
458467 } ;
459468 tabs . set ( id , tab ) ;
460469 debuggee . debugger . on ( "message" , tab . onMessage ) ;
461470 debuggee . webContents . on ( "did-navigate" , tab . onNewDocument ) ;
462- emit ( { type : "attached" , ...key } ) ;
463- const shown = shownTabs . get ( id ) ;
464- if ( shown !== undefined ) emit ( { type : "shown" , ...key , shown } ) ;
471+ const announce = ( ) => {
472+ emit ( { type : "attached" , ...key } ) ;
473+ const shown = shownTabs . get ( id ) ;
474+ if ( shown !== undefined ) emit ( { type : "shown" , ...key , shown } ) ;
475+ } ;
476+ if ( ! debuggee . webContents . isLoadingMainFrame ( ) ) {
477+ announce ( ) ;
478+ return ;
479+ }
480+ // Electron does not pause a guest for the debugger, so Playwright connecting during its first
481+ // commit builds its utility world in the discarded document and every wait on it hangs.
482+ const { webContents } = debuggee ;
483+ // ERR_ABORTED is a newer navigation replacing this one, whose commit is still to come.
484+ const onFailLoad = (
485+ _event : Electron . Event ,
486+ code : number ,
487+ _description : string ,
488+ _url : string ,
489+ isMainFrame : boolean ,
490+ ) => {
491+ if ( isMainFrame && code !== ERR_ABORTED ) ready ( ) ;
492+ } ;
493+ const ready = ( ) => {
494+ if ( tab . stopWaiting === null ) return ;
495+ // The manager's cleanup for a destroyed guest can still be queued behind another tab's attach.
496+ if ( webContents . isDestroyed ( ) ) return detach ( key ) ;
497+ tab . stopWaiting ( ) ;
498+ announce ( ) ;
499+ } ;
500+ const timeout = runFork (
501+ Effect . sleep ( FIRST_PAGE_TIMEOUT ) . pipe ( Effect . andThen ( Effect . sync ( ready ) ) ) ,
502+ ) ;
503+ tab . stopWaiting = ( ) => {
504+ tab . stopWaiting = null ;
505+ webContents . off ( "dom-ready" , ready ) ;
506+ webContents . off ( "did-stop-loading" , ready ) ;
507+ webContents . off ( "did-fail-load" , onFailLoad ) ;
508+ runFork ( Fiber . interrupt ( timeout ) ) ;
509+ } ;
510+ webContents . on ( "dom-ready" , ready ) ;
511+ webContents . on ( "did-stop-loading" , ready ) ;
512+ webContents . on ( "did-fail-load" , onFailLoad ) ;
465513 } ;
466514
467515 const setShown = ( key : DesktopBrowserTabKey , shown : boolean | undefined ) => {
@@ -472,7 +520,7 @@ export const make = Effect.gen(function* () {
472520 }
473521 if ( shownTabs . get ( id ) === shown ) return ;
474522 shownTabs . set ( id , shown ) ;
475- if ( tabs . has ( id ) ) emit ( { type : "shown" , ...key , shown } ) ;
523+ if ( tabs . get ( id ) ?. stopWaiting === null ) emit ( { type : "shown" , ...key , shown } ) ;
476524 } ;
477525
478526 const handleCommandLine = ( line : string ) =>
@@ -511,6 +559,8 @@ export const make = Effect.gen(function* () {
511559 [ ...tabs . values ( ) ] ,
512560 ( tab ) => {
513561 dropRelay ( tab ) ;
562+ // Announced once its first page is up.
563+ if ( tab . stopWaiting !== null ) return Effect . void ;
514564 const shown = shownTabs . get ( keyOf ( tab . key ) ) ;
515565 return PubSub . publish ( outbox , { type : "attached" , ...tab . key } ) . pipe (
516566 Effect . andThen (
0 commit comments