1- //! Starting a bot's browser (spec 21.2): find Edge, run it without a window
2- //! in a job of its own, and read where its DevTools listen.
1+ //! Starting a bot's browser (spec 21.2): run it without a window in a job
2+ //! of its own, and read where its DevTools listen.
33
4- use std:: io;
5- use std:: path:: { Path , PathBuf } ;
6- use std:: process:: { Child , Command , Stdio } ;
4+ use std:: io:: { self , BufRead as _, BufReader } ;
5+ use std:: path:: Path ;
6+ use std:: process:: { Child , ChildStderr , Command , Stdio } ;
7+ use std:: sync:: { Arc , Mutex , mpsc} ;
78use std:: time:: { Duration , Instant } ;
89
910use tracing:: debug;
1011
1112use super :: BrowserError ;
1213use crate :: platform:: { self , ProcessJob } ;
1314
14- /// How long Edge may take to write `DevToolsActivePort`.
15- const START_TIMEOUT : Duration = Duration :: from_secs ( 10 ) ;
15+ /// How long the browser may take to write `DevToolsActivePort`. Usually
16+ /// under a second; Edge's first start on a new Linux machine took over 20.
17+ const START_TIMEOUT : Duration = Duration :: from_secs ( 30 ) ;
1618const PORT_FILE : & str = "DevToolsActivePort" ;
19+ /// Longest stderr line passed on as the reason a browser stopped.
20+ const REASON_MAX : usize = 300 ;
1721
1822/// The browser's process tree. Dropping it kills every process in it.
1923pub struct BrowserProcess {
@@ -35,26 +39,6 @@ impl Drop for BrowserProcess {
3539 }
3640}
3741
38- /// The browser to run: `configured`, or the Edge that comes with Windows.
39- pub fn find ( configured : & str ) -> Option < PathBuf > {
40- let configured = configured. trim ( ) ;
41- if !configured. is_empty ( ) {
42- let path = PathBuf :: from ( configured) ;
43- return path. is_file ( ) . then_some ( path) ;
44- }
45- [ "ProgramFiles(x86)" , "ProgramFiles" , "LOCALAPPDATA" ]
46- . iter ( )
47- . filter_map ( std:: env:: var_os)
48- . map ( |base| {
49- PathBuf :: from ( base)
50- . join ( "Microsoft" )
51- . join ( "Edge" )
52- . join ( "Application" )
53- . join ( "msedge.exe" )
54- } )
55- . find ( |path| path. is_file ( ) )
56- }
57-
5842/// Starts `program` with the profile in `profile` and returns it with the
5943/// DevTools WebSocket address.
6044pub async fn launch (
@@ -88,20 +72,16 @@ pub async fn launch(
8872 . envs ( env. iter ( ) . map ( |( name, value) | ( name, value) ) )
8973 . stdin ( Stdio :: null ( ) )
9074 . stdout ( Stdio :: null ( ) )
91- . stderr ( Stdio :: null ( ) ) ;
92- #[ cfg( windows) ]
93- {
94- use std:: os:: windows:: process:: CommandExt as _;
95- const CREATE_NO_WINDOW : u32 = 0x0800_0000 ;
96- command. creation_flags ( CREATE_NO_WINDOW ) ;
97- }
75+ . stderr ( Stdio :: piped ( ) ) ;
76+ ProcessJob :: prepare ( & mut command) ;
9877 let job = ProcessJob :: new ( ) ?;
99- let child = command. spawn ( ) ?;
100- # [ cfg ( windows ) ]
101- {
102- use std :: os :: windows :: io :: AsRawHandle as _ ;
103- job . assign ( child . as_raw_handle ( ) ) ? ;
78+ let mut child = command. spawn ( ) ?;
79+ if let Err ( err ) = job . assign ( & child ) {
80+ let _ = child . kill ( ) ;
81+ let _ = child . wait ( ) ;
82+ return Err ( err . into ( ) ) ;
10483 }
84+ let said = child. stderr . take ( ) . map ( StartLog :: read) ;
10585 let mut process = BrowserProcess { child, job } ;
10686 debug ! ( pid = process. child. id( ) , "browser: started" ) ;
10787
@@ -114,19 +94,94 @@ pub async fn launch(
11494 return Ok ( ( process, url) ) ;
11595 }
11696 if process. child . try_wait ( ) ?. is_some ( ) {
117- return Err ( BrowserError :: Start (
118- "the browser closed right after starting" . to_owned ( ) ,
119- ) ) ;
97+ // Its helpers may still hold stderr open until the group dies.
98+ process. kill ( ) ;
99+ if let Some ( said) = & said {
100+ let _ = said. ended . recv_timeout ( Duration :: from_secs ( 1 ) ) ;
101+ }
102+ return Err ( BrowserError :: Start ( format ! (
103+ "the browser closed right after starting{}" ,
104+ StartLog :: reason( said. as_ref( ) )
105+ ) ) ) ;
120106 }
121107 if started. elapsed ( ) > START_TIMEOUT {
122- return Err ( BrowserError :: Start (
123- "the browser did not start in time" . to_owned ( ) ,
124- ) ) ;
108+ return Err ( BrowserError :: Start ( format ! (
109+ "the browser did not start in time{}" ,
110+ StartLog :: reason( said. as_ref( ) )
111+ ) ) ) ;
125112 }
126113 tokio:: time:: sleep ( Duration :: from_millis ( 50 ) ) . await ;
127114 }
128115}
129116
117+ /// What the browser writes to stderr, kept as the line that best says why
118+ /// it failed to start (a missing sandbox on Linux, say). Nothing of it goes
119+ /// to the log.
120+ struct StartLog {
121+ lines : Arc < Mutex < Said > > ,
122+ /// Hears when stderr ends.
123+ ended : mpsc:: Receiver < ( ) > ,
124+ }
125+
126+ #[ derive( Default ) ]
127+ struct Said {
128+ best : Option < String > ,
129+ last : Option < String > ,
130+ }
131+
132+ impl StartLog {
133+ fn read ( stderr : ChildStderr ) -> Self {
134+ let lines = Arc :: new ( Mutex :: new ( Said :: default ( ) ) ) ;
135+ let ( end, ended) = mpsc:: channel ( ) ;
136+ let shared = Arc :: clone ( & lines) ;
137+ let spawned = std:: thread:: Builder :: new ( )
138+ . name ( "browser-stderr" . into ( ) )
139+ . spawn ( move || {
140+ for line in BufReader :: new ( stderr) . lines ( ) {
141+ let Ok ( line) = line else { break } ;
142+ let text: String = log_text ( & line) . chars ( ) . take ( REASON_MAX ) . collect ( ) ;
143+ if text. is_empty ( ) {
144+ continue ;
145+ }
146+ let lower = line. to_ascii_lowercase ( ) ;
147+ let mut said = shared. lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
148+ let sandbox = lower. contains ( "sandbox" ) || lower. contains ( "namespace" ) ;
149+ if sandbox || ( said. best . is_none ( ) && lower. contains ( ":fatal:" ) ) {
150+ said. best = Some ( text. clone ( ) ) ;
151+ }
152+ said. last = Some ( text) ;
153+ }
154+ let _ = end. send ( ( ) ) ;
155+ } ) ;
156+ if spawned. is_err ( ) {
157+ debug ! ( "browser: cannot read its stderr" ) ;
158+ }
159+ Self { lines, ended }
160+ }
161+
162+ /// ` (the line)`, or nothing when the browser said nothing.
163+ fn reason ( log : Option < & Self > ) -> String {
164+ let Some ( log) = log else {
165+ return String :: new ( ) ;
166+ } ;
167+ let said = log. lines . lock ( ) . unwrap_or_else ( |p| p. into_inner ( ) ) ;
168+ said. best
169+ . as_ref ( )
170+ . or ( said. last . as_ref ( ) )
171+ . map ( |line| format ! ( " ({line})" ) )
172+ . unwrap_or_default ( )
173+ }
174+ }
175+
176+ /// A Chromium log line without its `[pid:tid:time:LEVEL:file.cc:123]` prefix.
177+ fn log_text ( line : & str ) -> String {
178+ let text = match line. strip_prefix ( '[' ) {
179+ Some ( rest) => rest. split_once ( "] " ) . map_or ( line, |( _, text) | text) ,
180+ None => line,
181+ } ;
182+ text. trim ( ) . to_owned ( )
183+ }
184+
130185/// `DevToolsActivePort` holds the port and the browser's WebSocket path, one
131186/// per line. The file may be read half-written.
132187fn devtools_url ( text : & str ) -> Option < String > {
@@ -153,7 +208,13 @@ mod tests {
153208 }
154209
155210 #[ test]
156- fn a_configured_browser_must_exist ( ) {
157- assert_eq ! ( find( r"C:\nowhere\msedge.exe" ) , None ) ;
211+ fn a_log_line_loses_its_prefix ( ) {
212+ assert_eq ! (
213+ log_text(
214+ "[11:11:1001/173604.487235:ERROR:zygote_host_impl_linux.cc:129] No usable sandbox!"
215+ ) ,
216+ "No usable sandbox!"
217+ ) ;
218+ assert_eq ! ( log_text( " plain words " ) , "plain words" ) ;
158219 }
159220}
0 commit comments