@@ -140,7 +140,7 @@ class GeckodriverHttpDriver implements IDriver {
140140 this . webSocketUrl = webSocketUrl ;
141141 }
142142
143- static async connect ( marionettePort : number ) : Promise < GeckodriverHttpDriver > {
143+ static async connect ( marionettePort : number , marionetteHost = '127.0.0.1' ) : Promise < GeckodriverHttpDriver > {
144144 // Find geckodriver binary via selenium-manager
145145 const path = await import ( 'node:path' ) ;
146146 const { execFileSync } = await import ( 'node:child_process' ) ;
@@ -176,10 +176,31 @@ class GeckodriverHttpDriver implements IDriver {
176176 }
177177 logDebug ( `Using geckodriver: ${ geckodriverPath } ` ) ;
178178
179+ // Clear any stale Marionette session before geckodriver connects.
180+ // Marionette allows only one session; a stale one blocks new connections.
181+ try {
182+ const net = await import ( 'node:net' ) ;
183+ await new Promise < void > ( ( resolve ) => {
184+ const sock = net . createConnection ( marionettePort , marionetteHost , ( ) => {
185+ sock . once ( 'data' , ( ) => {
186+ // Got the greeting. Send DeleteSession command.
187+ const cmd = JSON . stringify ( [ 0 , 0 , 'Marionette:DeleteSession' , { } ] ) ;
188+ sock . write ( `${ cmd . length } :${ cmd } ` ) ;
189+ // Wait briefly for the response, then close regardless.
190+ setTimeout ( ( ) => { sock . destroy ( ) ; resolve ( ) ; } , 500 ) ;
191+ } ) ;
192+ } ) ;
193+ sock . on ( 'error' , ( ) => resolve ( ) ) ;
194+ setTimeout ( ( ) => { sock . destroy ( ) ; resolve ( ) ; } , 2000 ) ;
195+ } ) ;
196+ } catch {
197+ // Ignore: no stale session or Marionette not reachable
198+ }
199+
179200 // Use --port=0 to let the OS assign a free port atomically (geckodriver ≥0.34.0)
180201 const gd = spawn (
181202 geckodriverPath ,
182- [ '--connect-existing' , '--marionette-port' , String ( marionettePort ) , '--port' , '0' ] ,
203+ [ '--connect-existing' , '--marionette-host' , marionetteHost , '--marionette- port', String ( marionettePort ) , '--port' , '0' ] ,
183204 { stdio : [ 'ignore' , 'pipe' , 'pipe' ] }
184205 ) ;
185206
@@ -223,7 +244,14 @@ class GeckodriverHttpDriver implements IDriver {
223244 throw new Error ( `Failed to create session: ${ JSON . stringify ( json ) } ` ) ;
224245 }
225246
226- const wsUrl = json . value . capabilities . webSocketUrl as string | undefined ;
247+ let wsUrl = json . value . capabilities . webSocketUrl as string | undefined ;
248+ logDebug ( `Session capabilities webSocketUrl: ${ wsUrl ?? 'not present' } , marionetteHost: ${ marionetteHost } ` ) ;
249+ if ( wsUrl && marionetteHost !== '127.0.0.1' ) {
250+ // Rewrite the URL to connect through the remote host / tunnel.
251+ const parsed = new URL ( wsUrl ) ;
252+ parsed . hostname = marionetteHost ;
253+ wsUrl = parsed . toString ( ) ;
254+ }
227255 if ( wsUrl ) {
228256 logDebug ( `BiDi WebSocket URL: ${ wsUrl } ` ) ;
229257 } else {
@@ -445,12 +473,18 @@ class GeckodriverHttpDriver implements IDriver {
445473 this . gdProcess . kill ( ) ;
446474 }
447475
448- /** Kill the geckodriver process without closing Firefox */
449- kill ( ) : void {
476+ /** Kill the geckodriver process without closing Firefox.
477+ * Deletes the session first so Marionette accepts new connections. */
478+ async kill ( ) : Promise < void > {
450479 if ( this . bidiConnection ) {
451480 ( this . bidiConnection . socket as unknown as WebSocket ) . close ( ) ;
452481 this . bidiConnection = null ;
453482 }
483+ try {
484+ await this . cmd ( 'DELETE' , '' ) ;
485+ } catch {
486+ // ignore
487+ }
454488 this . gdProcess . kill ( ) ;
455489 }
456490
@@ -467,12 +501,17 @@ class GeckodriverHttpDriver implements IDriver {
467501 ) ;
468502 }
469503
470- const ws = new WebSocket ( this . webSocketUrl ) ;
504+ // Suppress the Origin header: Firefox's Remote Agent rejects origins
505+ // not in --remote-allow-origins. Node's ws doesn't send Origin by
506+ // default, but Bun's implementation may. Explicitly omit it.
507+ const ws = new WebSocket ( this . webSocketUrl , { headers : { } } as any ) ;
471508 await new Promise < void > ( ( resolve , reject ) => {
472509 ws . on ( 'open' , resolve ) ;
473- ws . on ( 'error' , reject ) ;
510+ ws . on ( 'error' , ( e : any ) => {
511+ const msg = e ?. message || e ?. error ?. message || e ?. error || e ?. type || JSON . stringify ( e ) || String ( e ) ;
512+ reject ( new Error ( `BiDi WS to ${ this . webSocketUrl } : ${ msg } ` ) ) ;
513+ } ) ;
474514 } ) ;
475- logDebug ( 'BiDi WebSocket connected' ) ;
476515
477516 let cmdId = 0 ;
478517 const subscribe = async ( event : string , contexts ?: string [ ] ) : Promise < void > => {
@@ -572,7 +611,8 @@ export class FirefoxCore {
572611 // We bypass selenium-webdriver because its BiDi auto-upgrade hangs
573612 // when used with geckodriver's --connect-existing mode.
574613 const port = this . options . marionettePort ?? 2828 ;
575- this . driver = await GeckodriverHttpDriver . connect ( port ) ;
614+ const host = this . options . marionetteHost ?? '127.0.0.1' ;
615+ this . driver = await GeckodriverHttpDriver . connect ( port , host ) ;
576616 } else {
577617 // Set up output file for capturing Firefox stdout/stderr
578618 if ( this . options . logFile ) {
@@ -709,7 +749,7 @@ export class FirefoxCore {
709749 */
710750 reset ( ) : void {
711751 if ( this . driver && this . options . connectExisting && 'kill' in this . driver ) {
712- ( this . driver as { kill ( ) : void } ) . kill ( ) ;
752+ ( this . driver as { kill ( ) : Promise < void > } ) . kill ( ) ;
713753 }
714754 this . driver = null ;
715755 this . currentContextId = null ;
@@ -831,7 +871,7 @@ export class FirefoxCore {
831871 async close ( ) : Promise < void > {
832872 if ( this . driver ) {
833873 if ( this . options . connectExisting && 'kill' in this . driver ) {
834- ( this . driver as { kill ( ) : void } ) . kill ( ) ;
874+ await ( this . driver as { kill ( ) : Promise < void > } ) . kill ( ) ;
835875 } else if ( 'quit' in this . driver ) {
836876 await ( this . driver as { quit ( ) : Promise < void > } ) . quit ( ) ;
837877 }
0 commit comments