Skip to content

Commit e41be10

Browse files
authored
fix: handle dashboard cmd config BOM (#145)
1 parent 0adb5db commit e41be10

2 files changed

Lines changed: 253 additions & 22 deletions

File tree

src-tauri/src/backend/launch.rs

Lines changed: 226 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -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
3033
const DEFAULT_DASHBOARD_HOST: &str = "127.0.0.1";
3134
const DEFAULT_DASHBOARD_PORT: &str = "6185";
3235
const 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)]
3543
struct 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+
228329
fn 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(|| {

src-tauri/src/desktop_settings.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,28 @@ mod tests {
224224
root.join("data").join("desktop_state.json")
225225
}
226226

227+
struct EnvVarGuard {
228+
key: &'static str,
229+
previous: Option<String>,
230+
}
231+
232+
impl EnvVarGuard {
233+
fn clear(key: &'static str) -> Self {
234+
let previous = std::env::var(key).ok();
235+
std::env::remove_var(key);
236+
Self { key, previous }
237+
}
238+
}
239+
240+
impl Drop for EnvVarGuard {
241+
fn drop(&mut self) {
242+
match &self.previous {
243+
Some(value) => std::env::set_var(self.key, value),
244+
None => std::env::remove_var(self.key),
245+
}
246+
}
247+
}
248+
227249
fn settings(
228250
launch_at_login: bool,
229251
silent_launch: bool,
@@ -244,6 +266,7 @@ mod tests {
244266

245267
#[test]
246268
fn read_desktop_settings_maps_camel_case_fields() {
269+
let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV);
247270
let root = create_temp_case_dir("read");
248271
let path = state_path(&root);
249272
fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent");
@@ -261,6 +284,7 @@ mod tests {
261284

262285
#[test]
263286
fn read_desktop_settings_applies_field_defaults_for_missing_values() {
287+
let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV);
264288
let root = create_temp_case_dir("defaults");
265289
let path = state_path(&root);
266290
fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent");
@@ -284,6 +308,7 @@ mod tests {
284308

285309
#[test]
286310
fn write_desktop_setting_preserves_unknown_fields() {
311+
let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV);
287312
let root = create_temp_case_dir("preserve");
288313
let path = state_path(&root);
289314
fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent");
@@ -315,6 +340,7 @@ mod tests {
315340

316341
#[test]
317342
fn invalid_state_falls_back_to_defaults_and_write_resets_object() {
343+
let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV);
318344
let root = create_temp_case_dir("invalid");
319345
let path = state_path(&root);
320346
fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent");
@@ -339,6 +365,7 @@ mod tests {
339365

340366
#[test]
341367
fn invalid_state_is_rewritten_to_defaults_on_read() {
368+
let _root_guard = EnvVarGuard::clear(crate::ASTRBOT_ROOT_ENV);
342369
let root = create_temp_case_dir("invalid-read-reset");
343370
let path = state_path(&root);
344371
fs::create_dir_all(path.parent().expect("state parent")).expect("create state parent");

0 commit comments

Comments
 (0)