@@ -2,8 +2,11 @@ use std::{
22 env,
33 ffi:: { OsStr , OsString } ,
44 fs:: { self , OpenOptions } ,
5+ io,
56 path:: Path ,
67 process:: { Command , Stdio } ,
8+ thread,
9+ time:: Duration ,
710} ;
811
912#[ cfg( target_os = "windows" ) ]
@@ -30,6 +33,11 @@ const DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV: &str = "DASHBOARD_SKIP_DEFAULT_P
3033const DEFAULT_DASHBOARD_HOST : & str = "127.0.0.1" ;
3134const DEFAULT_DASHBOARD_PORT : & str = "6185" ;
3235const CMD_CONFIG_RELATIVE_PATH : & str = "data/cmd_config.json" ;
36+ const CMD_CONFIG_READ_RETRY_ATTEMPTS : usize = 5 ;
37+ #[ cfg( not( test) ) ]
38+ const CMD_CONFIG_READ_RETRY_DELAY : Duration = Duration :: from_millis ( 100 ) ;
39+ #[ cfg( test) ]
40+ const CMD_CONFIG_READ_RETRY_DELAY : Duration = Duration :: from_millis ( 1 ) ;
3341
3442#[ derive( Debug , Default ) ]
3543struct CmdDashboardConfig {
@@ -200,31 +208,124 @@ fn read_cmd_dashboard_config(
200208 return CmdDashboardConfig :: default ( ) ;
201209 }
202210
203- let raw = match fs:: read_to_string ( & config_path) {
204- Ok ( raw) => raw,
205- Err ( error) => {
206- log ( & format ! (
207- "failed to read cmd_config {}: {}" ,
208- config_path. display( ) ,
209- error
210- ) ) ;
211- return CmdDashboardConfig :: default ( ) ;
212- }
213- } ;
214- let parsed: CmdConfigFile = match serde_json:: from_str ( & raw ) {
211+ let parsed = match read_cmd_config_file_with_retry ( & config_path) {
215212 Ok ( parsed) => parsed,
216213 Err ( error) => {
217- log ( & format ! (
218- "failed to parse cmd_config {}: {}" ,
219- config_path. display( ) ,
220- error
221- ) ) ;
214+ match error {
215+ CmdConfigError :: Read ( error) => log ( & format ! (
216+ "failed to read cmd_config {}: {}" ,
217+ config_path. display( ) ,
218+ error
219+ ) ) ,
220+ CmdConfigError :: Parse { error, .. } => log ( & format ! (
221+ "failed to parse cmd_config {}: {}" ,
222+ config_path. display( ) ,
223+ error
224+ ) ) ,
225+ }
222226 return CmdDashboardConfig :: default ( ) ;
223227 }
224228 } ;
225229 CmdDashboardConfig :: from_file_config ( parsed, log)
226230}
227231
232+ #[ derive( Debug ) ]
233+ enum CmdConfigError {
234+ Read ( io:: Error ) ,
235+ Parse {
236+ error : serde_json:: Error ,
237+ is_empty : bool ,
238+ is_nonempty_eof : bool ,
239+ } ,
240+ }
241+
242+ fn is_retryable_cmd_config_io_error ( error : & io:: Error ) -> bool {
243+ matches ! (
244+ error. kind( ) ,
245+ io:: ErrorKind :: NotFound
246+ | io:: ErrorKind :: Interrupted
247+ | io:: ErrorKind :: WouldBlock
248+ | io:: ErrorKind :: TimedOut
249+ )
250+ }
251+
252+ fn should_retry_cmd_config_read ( error : & CmdConfigError ) -> bool {
253+ match error {
254+ CmdConfigError :: Read ( error) => is_retryable_cmd_config_io_error ( error) ,
255+ CmdConfigError :: Parse {
256+ is_empty,
257+ is_nonempty_eof,
258+ ..
259+ } => {
260+ // Empty files and truncated JSON can occur while cmd_config is being rewritten.
261+ // Whitespace-only files are non-empty invalid JSON, so treat them as misconfiguration.
262+ * is_empty || * is_nonempty_eof
263+ }
264+ }
265+ }
266+
267+ fn describe_cmd_config_error ( error : & CmdConfigError ) -> String {
268+ match error {
269+ CmdConfigError :: Read ( error) => format ! ( "read failed: {error}" ) ,
270+ CmdConfigError :: Parse { error, .. } => format ! ( "parse failed: {error}" ) ,
271+ }
272+ }
273+
274+ fn read_cmd_config_file_once ( config_path : & Path ) -> Result < CmdConfigFile , CmdConfigError > {
275+ let mut raw = fs:: read_to_string ( config_path) . map_err ( CmdConfigError :: Read ) ?;
276+ if raw. starts_with ( '\u{feff}' ) {
277+ raw. remove ( 0 ) ;
278+ }
279+ match serde_json:: from_str ( & raw ) {
280+ Ok ( parsed) => Ok ( parsed) ,
281+ Err ( error) => {
282+ let is_empty = raw. is_empty ( ) ;
283+ let is_nonempty_eof = error. is_eof ( ) && !raw. trim ( ) . is_empty ( ) ;
284+ Err ( CmdConfigError :: Parse {
285+ error,
286+ is_empty,
287+ is_nonempty_eof,
288+ } )
289+ }
290+ }
291+ }
292+
293+ fn read_cmd_config_file_with_retry ( config_path : & Path ) -> Result < CmdConfigFile , CmdConfigError > {
294+ read_cmd_config_file_with_retry_and_hook ( config_path, |_| { } )
295+ }
296+
297+ fn read_cmd_config_file_with_retry_and_hook < F > (
298+ config_path : & Path ,
299+ mut after_retryable_error : F ,
300+ ) -> Result < CmdConfigFile , CmdConfigError >
301+ where
302+ F : FnMut ( usize ) ,
303+ {
304+ let mut attempt = 1 ;
305+ loop {
306+ match read_cmd_config_file_once ( config_path) {
307+ Ok ( config) => return Ok ( config) ,
308+ Err ( error) => {
309+ if !should_retry_cmd_config_read ( & error)
310+ || attempt >= CMD_CONFIG_READ_RETRY_ATTEMPTS
311+ {
312+ return Err ( error) ;
313+ }
314+ append_desktop_log ( & format ! (
315+ "retrying cmd_config read {}/{} for {}: {}" ,
316+ attempt,
317+ CMD_CONFIG_READ_RETRY_ATTEMPTS ,
318+ config_path. display( ) ,
319+ describe_cmd_config_error( & error)
320+ ) ) ;
321+ after_retryable_error ( attempt - 1 ) ;
322+ thread:: sleep ( CMD_CONFIG_READ_RETRY_DELAY ) ;
323+ attempt += 1 ;
324+ }
325+ }
326+ }
327+ }
328+
228329fn should_skip_default_password_auth (
229330 has_explicit_skip_auth : bool ,
230331 effective_host : Option < & OsStr > ,
@@ -430,11 +531,11 @@ mod tests {
430531 use std:: os:: unix:: ffi:: OsStringExt ;
431532
432533 use super :: {
433- configure_desktop_dashboard_environment, sanitize_packaged_python_environment ,
434- ASTRBOT_DASHBOARD_HOST_ENV , ASTRBOT_DASHBOARD_PORT_ENV ,
435- ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV , CMD_CONFIG_RELATIVE_PATH ,
436- DASHBOARD_HOST_ENV , DASHBOARD_PORT_ENV , DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV ,
437- DEFAULT_DASHBOARD_HOST , DEFAULT_DASHBOARD_PORT ,
534+ configure_desktop_dashboard_environment, read_cmd_config_file_with_retry_and_hook ,
535+ sanitize_packaged_python_environment , ASTRBOT_DASHBOARD_HOST_ENV ,
536+ ASTRBOT_DASHBOARD_PORT_ENV , ASTRBOT_DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV ,
537+ CMD_CONFIG_RELATIVE_PATH , DASHBOARD_HOST_ENV , DASHBOARD_PORT_ENV ,
538+ DASHBOARD_SKIP_DEFAULT_PASSWORD_AUTH_ENV , DEFAULT_DASHBOARD_HOST , DEFAULT_DASHBOARD_PORT ,
438539 } ;
439540
440541 static ENV_TEST_LOCK : Mutex < ( ) > = Mutex :: new ( ( ) ) ;
@@ -624,6 +725,109 @@ mod tests {
624725 } ) ;
625726 }
626727
728+ #[ test]
729+ fn configure_desktop_dashboard_environment_accepts_utf8_bom_cmd_config ( ) {
730+ with_clean_dashboard_env ( || {
731+ let root = tempfile:: tempdir ( ) . expect ( "temp root" ) ;
732+ write_cmd_config (
733+ root. path ( ) ,
734+ "\u{feff} {\" dashboard\" :{\" host\" :\" 0.0.0.0\" ,\" port\" :6185}}" ,
735+ ) ;
736+ let mut command = Command :: new ( "sh" ) ;
737+
738+ configure_desktop_dashboard_environment ( & mut command, Some ( root. path ( ) ) , & mut |_| { } ) ;
739+
740+ assert_eq ! (
741+ get_command_env_value( & command, DASHBOARD_HOST_ENV ) ,
742+ Some ( Some ( "0.0.0.0" . to_string( ) ) )
743+ ) ;
744+ assert_eq ! (
745+ get_command_env_value( & command, DASHBOARD_PORT_ENV ) ,
746+ Some ( Some ( "6185" . to_string( ) ) )
747+ ) ;
748+ } ) ;
749+ }
750+
751+ #[ test]
752+ fn configure_desktop_dashboard_environment_retries_transient_empty_cmd_config ( ) {
753+ with_clean_dashboard_env ( || {
754+ let root = tempfile:: tempdir ( ) . expect ( "temp root" ) ;
755+ write_cmd_config ( root. path ( ) , "" ) ;
756+ let config_path = root. path ( ) . join ( CMD_CONFIG_RELATIVE_PATH ) ;
757+ let mut retry_count = 0 ;
758+
759+ let config = read_cmd_config_file_with_retry_and_hook ( & config_path, |attempt| {
760+ assert_eq ! ( attempt, 0 ) ;
761+ retry_count += 1 ;
762+ fs:: write (
763+ & config_path,
764+ r#"{"dashboard":{"host":"0.0.0.0","port":6185}}"# ,
765+ )
766+ . expect ( "write completed cmd config" ) ;
767+ } )
768+ . expect ( "retry reads completed cmd config" ) ;
769+ let parsed = super :: CmdDashboardConfig :: from_file_config ( config, & mut |_| { } ) ;
770+
771+ assert_eq ! ( retry_count, 1 ) ;
772+ assert_eq ! ( parsed. host. as_deref( ) , Some ( "0.0.0.0" ) ) ;
773+ assert_eq ! ( parsed. port. as_deref( ) , Some ( "6185" ) ) ;
774+ } ) ;
775+ }
776+
777+ #[ test]
778+ fn configure_desktop_dashboard_environment_retries_transient_missing_cmd_config ( ) {
779+ with_clean_dashboard_env ( || {
780+ let root = tempfile:: tempdir ( ) . expect ( "temp root" ) ;
781+ write_cmd_config ( root. path ( ) , r#"{"dashboard":{"host":"127.0.0.1"}}"# ) ;
782+ let config_path = root. path ( ) . join ( CMD_CONFIG_RELATIVE_PATH ) ;
783+ fs:: remove_file ( & config_path) . expect ( "remove cmd config during rewrite" ) ;
784+ let mut retry_count = 0 ;
785+
786+ let config = read_cmd_config_file_with_retry_and_hook ( & config_path, |attempt| {
787+ assert_eq ! ( attempt, 0 ) ;
788+ retry_count += 1 ;
789+ fs:: write (
790+ & config_path,
791+ r#"{"dashboard":{"host":"0.0.0.0","port":6185}}"# ,
792+ )
793+ . expect ( "write completed cmd config" ) ;
794+ } )
795+ . expect ( "retry reads completed cmd config" ) ;
796+ let parsed = super :: CmdDashboardConfig :: from_file_config ( config, & mut |_| { } ) ;
797+
798+ assert_eq ! ( retry_count, 1 ) ;
799+ assert_eq ! ( parsed. host. as_deref( ) , Some ( "0.0.0.0" ) ) ;
800+ assert_eq ! ( parsed. port. as_deref( ) , Some ( "6185" ) ) ;
801+ } ) ;
802+ }
803+
804+ #[ test]
805+ fn configure_desktop_dashboard_environment_does_not_retry_whitespace_only_cmd_config ( ) {
806+ with_clean_dashboard_env ( || {
807+ let root = tempfile:: tempdir ( ) . expect ( "temp root" ) ;
808+ write_cmd_config ( root. path ( ) , "\n \n " ) ;
809+ let config_path = root. path ( ) . join ( CMD_CONFIG_RELATIVE_PATH ) ;
810+ let mut retry_count = 0 ;
811+ let mut command = Command :: new ( "sh" ) ;
812+
813+ configure_desktop_dashboard_environment ( & mut command, Some ( root. path ( ) ) , & mut |_| { } ) ;
814+ let result = read_cmd_config_file_with_retry_and_hook ( & config_path, |_| {
815+ retry_count += 1 ;
816+ } ) ;
817+
818+ assert_eq ! (
819+ get_command_env_value( & command, DASHBOARD_HOST_ENV ) ,
820+ Some ( Some ( DEFAULT_DASHBOARD_HOST . to_string( ) ) )
821+ ) ;
822+ assert_eq ! (
823+ get_command_env_value( & command, DASHBOARD_PORT_ENV ) ,
824+ Some ( Some ( DEFAULT_DASHBOARD_PORT . to_string( ) ) )
825+ ) ;
826+ assert ! ( result. is_err( ) ) ;
827+ assert_eq ! ( retry_count, 0 ) ;
828+ } ) ;
829+ }
830+
627831 #[ test]
628832 fn configure_desktop_dashboard_environment_env_overrides_cmd_config ( ) {
629833 with_clean_dashboard_env ( || {
0 commit comments