From 622921d7b16e09f89c3c9695cda374257f7be54d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Thu, 30 Apr 2026 15:18:36 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix:=20concurrency=20issue=20with=20multipl?= =?UTF-8?q?e=20bt=20switch=20unicode=20issues=20(try=20bt=20sync=20pull=20?= =?UTF-8?q?--window=201=C3=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/sync.rs | 28 ++++++++++++++++++++-------- src/traces.rs | 24 ++++++++++++++++++------ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/sync.rs b/src/sync.rs index 7271185..e04cf68 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -2612,16 +2612,20 @@ fn parse_duration_to_seconds(input: &str) -> Result { return Ok(seconds); } - let (num_str, unit) = trimmed.split_at(trimmed.len().saturating_sub(1)); + let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic()); + let (num_str, unit) = match suffix { + Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit), + None => (trimmed, 's'), + }; let value: u64 = num_str .trim() .parse() .with_context(|| format!("invalid duration '{input}'"))?; - let multiplier = match unit.to_ascii_lowercase().as_str() { - "s" => 1, - "m" => 60, - "h" => 60 * 60, - "d" => 60 * 60 * 24, + let multiplier = match unit.to_ascii_lowercase() { + 's' => 1, + 'm' => 60, + 'h' => 60 * 60, + 'd' => 60 * 60 * 24, _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), }; Ok(value.saturating_mul(multiplier)) @@ -4107,7 +4111,7 @@ fn collect_seen_roots_until_offset( let file = File::open(path).with_context(|| format!("failed to open {}", path.display()))?; let reader = BufReader::new(file); - for (line_index, line) in reader.lines().enumerate() { + for line in reader.lines() { if global_line_index >= line_offset { break 'files; } @@ -4117,7 +4121,7 @@ fn collect_seen_roots_until_offset( format!( "invalid JSON in {} at line {} while rebuilding trace resume state", path.display(), - line_index + 1 + global_line_index + 1 ) })?; if let Some(root_id) = row_root_span_id(&row) { @@ -4288,6 +4292,14 @@ fn spinner_bar(message: &str) -> ProgressBar { mod tests { use super::*; + #[test] + fn parse_duration_to_seconds_rejects_non_ascii_suffix_without_panicking() { + for input in ["1–", "1é", "1🙂"] { + let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix"); + assert!(err.to_string().contains("invalid duration")); + } + } + #[test] fn push_checkpoint_line_offset_advances_only_after_commit() { let mut state = diff --git a/src/traces.rs b/src/traces.rs index 07a7eee..599bae7 100644 --- a/src/traces.rs +++ b/src/traces.rs @@ -5015,16 +5015,20 @@ fn parse_duration_to_seconds(input: &str) -> Result { return Ok(seconds); } - let (num_str, unit) = trimmed.split_at(trimmed.len().saturating_sub(1)); + let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic()); + let (num_str, unit) = match suffix { + Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit), + None => (trimmed, 's'), + }; let value: u64 = num_str .trim() .parse() .with_context(|| format!("invalid duration '{input}'"))?; - let multiplier = match unit.to_ascii_lowercase().as_str() { - "s" => 1, - "m" => 60, - "h" => 60 * 60, - "d" => 60 * 60 * 24, + let multiplier = match unit.to_ascii_lowercase() { + 's' => 1, + 'm' => 60, + 'h' => 60 * 60, + 'd' => 60 * 60 * 24, _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), }; Ok(value.saturating_mul(multiplier)) @@ -6163,6 +6167,14 @@ mod tests { assert_eq!(parse_duration_to_seconds("1d").expect("days"), 86_400); } + #[test] + fn parse_duration_to_seconds_rejects_non_ascii_suffix_without_panicking() { + for input in ["1–", "1é", "1🙂"] { + let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix"); + assert!(err.to_string().contains("invalid duration")); + } + } + #[test] fn build_base_filter_clause_uses_window_or_since() { let from_window = build_base_filter_clause(None, "1h", Some("metadata.model IS NOT NULL")) From a7e308490dffa32d8e31331fe8690c4b83ed17b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 1 May 2026 20:52:56 +0000 Subject: [PATCH 2/5] fix: validate the rows `bt function pull` needs to show --- src/functions/pull.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions/pull.rs b/src/functions/pull.rs index 8f2e6de..3eaa75e 100644 --- a/src/functions/pull.rs +++ b/src/functions/pull.rs @@ -313,7 +313,7 @@ pub async fn run(base: BaseArgs, args: PullArgs) -> Result<()> { ); } }; - match resolve_project_names(&winners, projects) { + match resolve_project_names(&materializable, projects) { Ok(names) => names, Err(err) => { spinner.finish_and_clear(); From 57946dd3e7b7d24e8466f4441c94bac21d521595 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 1 May 2026 21:14:44 +0000 Subject: [PATCH 3/5] fix: limit argument not being passed to view next page --- src/traces.rs | 32 ++++++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/src/traces.rs b/src/traces.rs index 599bae7..6ee25d4 100644 --- a/src/traces.rs +++ b/src/traces.rs @@ -1028,6 +1028,7 @@ async fn run_logs_command(base: BaseArgs, client: ApiClient, args: LogsArgs) -> &object_ref_arg, next_cursor.as_deref(), profile_flag, + args.limit, ), }); println!("{}", serde_json::to_string_pretty(&payload)?); @@ -1191,6 +1192,7 @@ async fn run_trace_command(base: BaseArgs, client: ApiClient, args: TraceArgs) - &trace_id, next_cursor.as_deref(), profile_flag, + args.limit, ), }); println!("{}", serde_json::to_string_pretty(&payload)?); @@ -4844,16 +4846,22 @@ fn logs_hints( object_ref: &str, next_cursor: Option<&str>, profile: Option<&str>, + limit: usize, ) -> Vec { let mut hints = vec![ "Rows are truncated by preview_length; fetch a single span for full content.".to_string(), "Use --json to get machine-readable envelopes for agent workflows.".to_string(), "Write large responses to a file to preserve full output context.".to_string(), ]; + let limit_suffix = if limit != 50 { + format!(" --limit {limit}") + } else { + String::new() + }; if has_more { if let Some(cursor) = next_cursor { hints.push(format!( - "Next page: bt view logs{} --object-ref {object_ref} --cursor {cursor}", + "Next page: bt view logs{} --object-ref {object_ref} --cursor {cursor}{limit_suffix}", profile_flag_suffix(profile) )); } else { @@ -4871,6 +4879,7 @@ fn trace_hints( trace_id: &str, next_cursor: Option<&str>, profile: Option<&str>, + limit: usize, ) -> Vec { let mut hints = vec![ format!( @@ -4879,10 +4888,15 @@ fn trace_hints( ), "Write output to a file for long traces.".to_string(), ]; + let limit_suffix = if limit != 50 { + format!(" --limit {limit}") + } else { + String::new() + }; if has_more { if let Some(cursor) = next_cursor { hints.push(format!( - "Next page: bt view trace{} --object-ref {object_ref} --trace-id {trace_id} --cursor {cursor}", + "Next page: bt view trace{} --object-ref {object_ref} --trace-id {trace_id} --cursor {cursor}{limit_suffix}", profile_flag_suffix(profile) )); } else { @@ -4934,9 +4948,14 @@ fn print_logs_text( } println!("\nTruncated fields use preview_length={preview_length}."); if let Some(cursor) = next_cursor { + let limit_suffix = if limit != 50 { + format!(" --limit {limit}") + } else { + String::new() + }; println!("next_cursor: {cursor}"); println!( - "next: bt view logs{} --object-ref {object_ref} --cursor {cursor} --non-interactive", + "next: bt view logs{} --object-ref {object_ref} --cursor {cursor} --non-interactive{limit_suffix}", profile_flag_suffix(profile) ); } else { @@ -4980,9 +4999,14 @@ fn print_trace_text( object_ref ); if let Some(cursor) = next_cursor { + let limit_suffix = if limit != 50 { + format!(" --limit {limit}") + } else { + String::new() + }; println!("next_cursor: {cursor}"); println!( - "next: bt view trace{} --object-ref {} --trace-id {} --cursor {} --non-interactive", + "next: bt view trace{} --object-ref {} --trace-id {} --cursor {} --non-interactive{limit_suffix}", profile_flag_suffix(profile), object_ref, trace_id, From 256202fa4256efd47fcd8fb88620bf09ab345ab9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 1 May 2026 21:43:57 +0000 Subject: [PATCH 4/5] fix: clean up temp file on failed config save --- src/config/mod.rs | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/config/mod.rs b/src/config/mod.rs index a26cd33..ff5752c 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -149,17 +149,15 @@ pub fn load() -> Result { } pub fn save_file(path: &Path, config: &Config) -> Result<()> { - if let Some(parent) = path.parent() { - fs::create_dir_all(parent)?; - } + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + fs::create_dir_all(parent)?; let json = serde_json::to_string_pretty(config)?; - let temp_path = path.with_extension("tmp"); - let mut file = fs::File::create(&temp_path)?; - file.write_all(json.as_bytes())?; - file.write_all(b"\n")?; - file.sync_all()?; - fs::rename(&temp_path, path)?; + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(json.as_bytes())?; + tmp.write_all(b"\n")?; + tmp.as_file().sync_all()?; + tmp.persist(path)?; Ok(()) } From 3dab7fc192bdd6c74b55350f8053e8a9f78d987b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?C=C3=A9dric=20Halber?= Date: Fri, 1 May 2026 23:26:20 +0000 Subject: [PATCH 5/5] chore: deduplicate time parsing --- src/sync.rs | 37 +------------------------------- src/traces.rs | 45 +------------------------------------- src/utils/duration.rs | 50 +++++++++++++++++++++++++++++++++++++++++++ src/utils/mod.rs | 2 ++ 4 files changed, 54 insertions(+), 80 deletions(-) create mode 100644 src/utils/duration.rs diff --git a/src/sync.rs b/src/sync.rs index e04cf68..a6655e0 100644 --- a/src/sync.rs +++ b/src/sync.rs @@ -26,6 +26,7 @@ use crate::experiments::api::create_experiment; use crate::http::ApiClient; use crate::projects::api::{create_project, list_projects, Project}; use crate::ui::{animations_enabled, fuzzy_select, is_quiet}; +use crate::utils::parse_duration_to_seconds; const STATE_SCHEMA_VERSION: u32 = 1; const DEFAULT_PULL_LIMIT: usize = 100; @@ -2603,34 +2604,6 @@ fn build_root_spans_query( parts.join(" | ") } -fn parse_duration_to_seconds(input: &str) -> Result { - let trimmed = input.trim(); - if trimmed.is_empty() { - bail!("duration cannot be empty"); - } - if let Ok(seconds) = trimmed.parse::() { - return Ok(seconds); - } - - let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic()); - let (num_str, unit) = match suffix { - Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit), - None => (trimmed, 's'), - }; - let value: u64 = num_str - .trim() - .parse() - .with_context(|| format!("invalid duration '{input}'"))?; - let multiplier = match unit.to_ascii_lowercase() { - 's' => 1, - 'm' => 60, - 'h' => 60 * 60, - 'd' => 60 * 60 * 24, - _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), - }; - Ok(value.saturating_mul(multiplier)) -} - fn build_time_filter_clause(window: &str, extra_filter: Option<&str>) -> Result { let seconds = parse_duration_to_seconds(window)?; let time_clause = format!("created >= NOW() - INTERVAL {seconds} SECOND"); @@ -4292,14 +4265,6 @@ fn spinner_bar(message: &str) -> ProgressBar { mod tests { use super::*; - #[test] - fn parse_duration_to_seconds_rejects_non_ascii_suffix_without_panicking() { - for input in ["1–", "1é", "1🙂"] { - let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix"); - assert!(err.to_string().contains("invalid duration")); - } - } - #[test] fn push_checkpoint_line_offset_advances_only_after_commit() { let mut state = diff --git a/src/traces.rs b/src/traces.rs index 6ee25d4..2c04583 100644 --- a/src/traces.rs +++ b/src/traces.rs @@ -36,6 +36,7 @@ use crate::args::BaseArgs; use crate::auth::{self, login}; use crate::http::ApiClient; use crate::ui::{fuzzy_select, is_interactive, with_spinner}; +use crate::utils::parse_duration_to_seconds; const MAX_TRACE_SPANS: usize = 5000; const MAX_BTQL_PAGE_LIMIT: usize = 1000; @@ -5030,34 +5031,6 @@ fn print_span_text(item: Option<&Map>) { } } -fn parse_duration_to_seconds(input: &str) -> Result { - let trimmed = input.trim(); - if trimmed.is_empty() { - bail!("duration cannot be empty"); - } - if let Ok(seconds) = trimmed.parse::() { - return Ok(seconds); - } - - let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic()); - let (num_str, unit) = match suffix { - Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit), - None => (trimmed, 's'), - }; - let value: u64 = num_str - .trim() - .parse() - .with_context(|| format!("invalid duration '{input}'"))?; - let multiplier = match unit.to_ascii_lowercase() { - 's' => 1, - 'm' => 60, - 'h' => 60 * 60, - 'd' => 60 * 60 * 24, - _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), - }; - Ok(value.saturating_mul(multiplier)) -} - fn build_base_filter_clause( since: Option<&str>, window: &str, @@ -6183,22 +6156,6 @@ mod tests { } } - #[test] - fn parse_duration_to_seconds_supports_units() { - assert_eq!(parse_duration_to_seconds("90").expect("seconds"), 90); - assert_eq!(parse_duration_to_seconds("15m").expect("minutes"), 900); - assert_eq!(parse_duration_to_seconds("2h").expect("hours"), 7_200); - assert_eq!(parse_duration_to_seconds("1d").expect("days"), 86_400); - } - - #[test] - fn parse_duration_to_seconds_rejects_non_ascii_suffix_without_panicking() { - for input in ["1–", "1é", "1🙂"] { - let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix"); - assert!(err.to_string().contains("invalid duration")); - } - } - #[test] fn build_base_filter_clause_uses_window_or_since() { let from_window = build_base_filter_clause(None, "1h", Some("metadata.model IS NOT NULL")) diff --git a/src/utils/duration.rs b/src/utils/duration.rs new file mode 100644 index 0000000..e7cbccb --- /dev/null +++ b/src/utils/duration.rs @@ -0,0 +1,50 @@ +use anyhow::{bail, Context, Result}; + +pub fn parse_duration_to_seconds(input: &str) -> Result { + let trimmed = input.trim(); + if trimmed.is_empty() { + bail!("duration cannot be empty"); + } + if let Ok(seconds) = trimmed.parse::() { + return Ok(seconds); + } + + let suffix = trimmed.chars().last().filter(|ch| ch.is_ascii_alphabetic()); + let (num_str, unit) = match suffix { + Some(unit) => (&trimmed[..trimmed.len() - unit.len_utf8()], unit), + None => (trimmed, 's'), + }; + let value: u64 = num_str + .trim() + .parse() + .with_context(|| format!("invalid duration '{input}'"))?; + let multiplier = match unit.to_ascii_lowercase() { + 's' => 1, + 'm' => 60, + 'h' => 60 * 60, + 'd' => 60 * 60 * 24, + _ => bail!("invalid duration '{input}'. expected suffix s/m/h/d"), + }; + Ok(value.saturating_mul(multiplier)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn supports_units() { + assert_eq!(parse_duration_to_seconds("90").expect("seconds"), 90); + assert_eq!(parse_duration_to_seconds("15m").expect("minutes"), 900); + assert_eq!(parse_duration_to_seconds("2h").expect("hours"), 7_200); + assert_eq!(parse_duration_to_seconds("1d").expect("days"), 86_400); + } + + #[test] + fn rejects_non_ascii_suffix_without_panicking() { + for input in ["1–", "1é", "1🙂"] { + let err = parse_duration_to_seconds(input).expect_err("invalid unicode suffix"); + assert!(err.to_string().contains("invalid duration")); + } + } +} diff --git a/src/utils/mod.rs b/src/utils/mod.rs index 7a5b4da..dfb178e 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,7 +1,9 @@ +mod duration; mod fs_atomic; mod git; mod plurals; +pub use duration::parse_duration_to_seconds; pub use fs_atomic::write_text_atomic; pub use git::GitRepo; pub use plurals::pluralize;