Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions crates/fbuild-build/tests/cache_survives_tar_extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -172,9 +172,10 @@ fn compiler_signature_survives_toolchain_path_change() {
let flags = vec!["-O2".to_string(), "-DFOO=1".to_string()];
let pre_flags = vec!["-Wall".to_string()];
let extra_flags = vec!["-I/some/include".to_string()];
let build_unflags: Vec<String> = Vec::new();

let sig_a = build_rebuild_signature(&path_a, &flags, &pre_flags, &extra_flags);
let sig_b = build_rebuild_signature(&path_b, &flags, &pre_flags, &extra_flags);
let sig_a = build_rebuild_signature(&path_a, &flags, &pre_flags, &extra_flags, &build_unflags);
let sig_b = build_rebuild_signature(&path_b, &flags, &pre_flags, &extra_flags, &build_unflags);

assert_eq!(
sig_a, sig_b,
Expand All @@ -184,7 +185,7 @@ fn compiler_signature_survives_toolchain_path_change() {

let alt_filename = if cfg!(windows) { "clang.exe" } else { "clang" };
let path_c = toolchain_a.path().join(alt_filename);
let sig_c = build_rebuild_signature(&path_c, &flags, &pre_flags, &extra_flags);
let sig_c = build_rebuild_signature(&path_c, &flags, &pre_flags, &extra_flags, &build_unflags);
assert_ne!(
sig_a, sig_c,
"build_rebuild_signature collapsed two different compilers to the same signature; \
Expand Down
18 changes: 17 additions & 1 deletion crates/fbuild-cli/src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,23 @@ pub enum DaemonAction {
/// Show lock status (project locks, serial sessions)
Locks,
/// Clear stale locks
ClearLocks,
ClearLocks {
/// Include serial sessions in stale lock cleanup
#[arg(long)]
serial: bool,
/// Close only sessions that the daemon can classify as stale
#[arg(long)]
stale: bool,
/// Target a serial session by port (for example COM11 or /dev/ttyUSB0)
#[arg(long)]
port: Option<String>,
/// Target a serial session by client id
#[arg(long = "client-id")]
client_id: Option<String>,
/// Close a targeted live serial session; requires --port or --client-id
#[arg(long)]
force: bool,
},
/// Show disk cache statistics
CacheStats,
/// Run disk cache garbage collection
Expand Down
102 changes: 96 additions & 6 deletions crates/fbuild-cli/src/cli/daemon_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,21 @@ pub async fn run_daemon(action: DaemonAction) -> fbuild_core::Result<()> {
DaemonAction::Locks => {
run_daemon_locks(&client).await?;
}
DaemonAction::ClearLocks => {
run_daemon_clear_locks(&client).await?;
DaemonAction::ClearLocks {
serial,
stale,
port,
client_id,
force,
} => {
let request = daemon_client::ClearLocksRequest {
serial,
stale,
port,
client_id,
force,
};
run_daemon_clear_locks(&client, request).await?;
}
Comment on lines +114 to 129

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== daemon_cmd.rs ==\n'
sed -n '90,150p' crates/fbuild-cli/src/cli/daemon_cmd.rs | cat -n

printf '\n== locks.rs ==\n'
sed -n '1,260p' crates/fbuild-daemon/src/handlers/locks.rs | cat -n

printf '\n== ClearLocksRequest references ==\n'
rg -n "ClearLocksRequest|session_matches_request|clear-locks|stale" crates/fbuild-cli crates/fbuild-daemon -S

Repository: FastLED/fbuild

Length of output: 28246


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== locks.rs (clear_locks section) ==\n'
sed -n '238,330p' crates/fbuild-daemon/src/handlers/locks.rs | cat -n

printf '\n== args.rs (ClearLocks action) ==\n'
sed -n '710,750p' crates/fbuild-cli/src/cli/args.rs | cat -n

printf '\n== daemon_client/types.rs (ClearLocksRequest) ==\n'
sed -n '300,335p' crates/fbuild-cli/src/daemon_client/types.rs | cat -n

printf '\n== clear_locks tests in locks.rs ==\n'
sed -n '440,535p' crates/fbuild-daemon/src/handlers/locks.rs | cat -n

Repository: FastLED/fbuild

Length of output: 10294


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== daemon_client clear_locks methods ==\n'
sed -n '530,565p' crates/fbuild-cli/src/daemon_client.rs | cat -n

printf '\n== CLI docs around clear-locks ==\n'
sed -n '720,745p' crates/fbuild-cli/src/cli/args.rs | cat -n

printf '\n== MCP tool clear_stale_locks ==\n'
sed -n '210,240p' crates/fbuild-cli/src/mcp/tools.rs | cat -n

Repository: FastLED/fbuild

Length of output: 4095


Make --stale imply serial cleanup here. ClearLocksRequest only matches stale serial sessions when both serial and stale are set and neither --port nor --client-id is present, so fbuild daemon clear-locks --stale still returns no matching serial sessions found. Set serial: serial || stale at this call site so the flag behaves as advertised.

Proposed fix
             let request = daemon_client::ClearLocksRequest {
-                serial,
+                serial: serial || stale,
                 stale,
                 port,
                 client_id,
                 force,
             };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
DaemonAction::ClearLocks {
serial,
stale,
port,
client_id,
force,
} => {
let request = daemon_client::ClearLocksRequest {
serial,
stale,
port,
client_id,
force,
};
run_daemon_clear_locks(&client, request).await?;
}
DaemonAction::ClearLocks {
serial,
stale,
port,
client_id,
force,
} => {
let request = daemon_client::ClearLocksRequest {
serial: serial || stale,
stale,
port,
client_id,
force,
};
run_daemon_clear_locks(&client, request).await?;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fbuild-cli/src/cli/daemon_cmd.rs` around lines 114 - 129, Make
`--stale` imply serial cleanup in `DaemonAction::ClearLocks` within
`daemon_cmd.rs`: the current `ClearLocksRequest` construction passes `serial`
and `stale` independently, so `run_daemon_clear_locks` still misses stale serial
sessions when only `--stale` is set. Update the request building so the `serial`
field is derived from `serial || stale` while leaving the rest of the
`ClearLocksRequest` fields unchanged, ensuring `fbuild daemon clear-locks
--stale` reaches the serial-session cleanup path as intended.

DaemonAction::CacheStats => {
run_daemon_cache_stats(&client).await?;
Expand Down Expand Up @@ -347,9 +360,64 @@ pub async fn run_daemon_locks(client: &DaemonClient) -> fbuild_core::Result<()>
for lock in &status.port_locks {
let state = if lock.is_held { "HELD" } else { "FREE" };
let writer = lock.writer_client_id.as_deref().unwrap_or("none");
let owner = lock.owner_client_id.as_deref().unwrap_or("none");
output::result(format!(
" {} [{}] open={} writer={} readers={}",
lock.port, state, lock.is_open, writer, lock.reader_count
" {} [{}] open={} baud={} owner={} writer={} readers={} age={} last_activity={} rx={} tx={}",
lock.port,
state,
lock.is_open,
lock.baud_rate,
owner,
writer,
lock.reader_count,
format_age_seconds(lock.session_age_seconds),
format_age_seconds(lock.last_activity_age_seconds),
lock.total_bytes_read,
lock.total_bytes_written
));
if let Some(age) = lock.last_read_age_seconds {
output::result(format!(" last_read: {}", format_age_seconds(age)));
}
if let Some(age) = lock.last_write_age_seconds {
output::result(format!(" last_write: {}", format_age_seconds(age)));
}
for client in &lock.clients {
let pid = client
.pid
.map(|pid| pid.to_string())
.unwrap_or_else(|| "unknown".to_string());
let alive = client
.process_alive
.map(|alive| if alive { "alive" } else { "dead" })
.unwrap_or("unknown");
output::result(format!(
" client {} pid={} ({})",
client.client_id, pid, alive
));
if let Some(exe) = &client.exe {
output::result(format!(" exe: {}", exe));
}
if let Some(cwd) = &client.cwd {
output::result(format!(" cwd: {}", cwd));
}
if let Some(argv) = &client.argv {
if !argv.is_empty() {
output::result(format!(" argv: {}", argv.join(" ")));
}
}
}
}
}

if !status.pending_serial_attaches.is_empty() {
output::result("Pending Serial Attaches:");
for pending in &status.pending_serial_attaches {
output::result(format!(
" #{} port={} client={} age={}",
pending.id,
pending.port.as_deref().unwrap_or("unknown"),
pending.client_id.as_deref().unwrap_or("unknown"),
format_age_seconds(pending.age_seconds)
));
}
}
Expand All @@ -375,17 +443,35 @@ pub async fn run_daemon_locks(client: &DaemonClient) -> fbuild_core::Result<()>
Ok(())
}

pub async fn run_daemon_clear_locks(client: &DaemonClient) -> fbuild_core::Result<()> {
pub async fn run_daemon_clear_locks(
client: &DaemonClient,
request: daemon_client::ClearLocksRequest,
) -> fbuild_core::Result<()> {
if !client.health().await {
output::result("daemon is not running");
return Ok(());
}

let result = client.clear_locks().await?;
let result = client.clear_locks_with(&request).await?;
output::result(&result.message);
if result.cleared_count > 0 {
output::result(format!("Cleared {} lock(s)", result.cleared_count));
}
if result.cleared_project_count > 0 {
output::result(format!(
"Cleared {} project lock(s)",
result.cleared_project_count
));
}
if result.cleared_serial_count > 0 {
output::result(format!(
"Cleared serial sessions: {}",
result.cleared_serial_sessions.join(", ")
));
}
for refusal in result.refused {
output::warn(refusal);
}
Comment on lines +446 to +474

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Refusal reasons are printed twice.

The daemon's message field already embeds joined refused reasons when any exist (e.g. "Cleared {n} lock(s); {refused.join(\"; \")}", per the clear_locks handler shown in context). This function prints result.message first, then loops over result.refused again, printing each reason a second time via output::warn. Users will see the same refusal text twice for the same run.

♻️ Proposed fix (only warn separately; keep message concise)
-    let result = client.clear_locks_with(&request).await?;
-    output::result(&result.message);
-    if result.cleared_count > 0 {
+    let result = client.clear_locks_with(&request).await?;
+    if result.cleared_count > 0 {
+        output::result(&result.message);
         output::result(format!("Cleared {} lock(s)", result.cleared_count));
+    } else {
+        output::result(&result.message);
     }

Alternatively, have the daemon return a message that omits the per-item refusal text and rely solely on the refused list for details.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/fbuild-cli/src/cli/daemon_cmd.rs` around lines 446 - 474, The refusal
reasons are being emitted twice in run_daemon_clear_locks because result.message
already includes the joined refused text and the loop over result.refused warns
each item again. Update the output in run_daemon_clear_locks so refusals are
shown only once: either keep output::result(result.message) and remove the
per-item warning loop, or make the daemon-side clear_locks response message
concise and rely on the refused list for details. Use the run_daemon_clear_locks
and clear_locks handler behavior to keep the formatting consistent.

Ok(())
}

Expand Down Expand Up @@ -688,3 +774,7 @@ pub fn format_uptime(seconds: f64) -> String {
format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
}
}

fn format_age_seconds(seconds: f64) -> String {
format_uptime(seconds.max(0.0))
}
9 changes: 9 additions & 0 deletions crates/fbuild-cli/src/daemon_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,9 +546,18 @@ impl DaemonClient {

/// Clear stale locks on the daemon.
pub async fn clear_locks(&self) -> fbuild_core::Result<ClearLocksResponse> {
self.clear_locks_with(&ClearLocksRequest::default()).await
}

/// Clear stale locks on the daemon with targeting options.
pub async fn clear_locks_with(
&self,
request: &ClearLocksRequest,
) -> fbuild_core::Result<ClearLocksResponse> {
let resp = self
.client
.post(format!("{}/api/locks/clear", self.base_url))
.json(request)
.timeout(fbuild_core::time::MEDIUM_HTTP_TIMEOUT)
.send()
.await
Expand Down
86 changes: 86 additions & 0 deletions crates/fbuild-cli/src/daemon_client/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,8 @@ pub struct LockStatusResponse {
pub success: bool,
pub port_locks: Vec<PortLockInfo>,
pub project_locks: Vec<ProjectLockInfo>,
#[serde(default)]
pub pending_serial_attaches: Vec<PendingSerialAttachInfo>,
pub stale_locks: Vec<String>,
}

Expand All @@ -237,8 +239,70 @@ pub struct PortLockInfo {
#[allow(dead_code)]
pub holder_description: Option<String>,
pub is_open: bool,
#[serde(default)]
pub owner_client_id: Option<String>,
pub writer_client_id: Option<String>,
pub reader_count: usize,
#[serde(default)]
#[allow(dead_code)]
pub reader_client_ids: Vec<String>,
#[serde(default)]
pub baud_rate: u32,
#[serde(default)]
#[allow(dead_code)]
pub started_at: f64,
#[serde(default)]
pub session_age_seconds: f64,
#[serde(default)]
#[allow(dead_code)]
pub last_activity_at: f64,
#[serde(default)]
pub last_activity_age_seconds: f64,
#[serde(default)]
#[allow(dead_code)]
pub last_read_at: Option<f64>,
#[serde(default)]
pub last_read_age_seconds: Option<f64>,
#[serde(default)]
#[allow(dead_code)]
pub last_write_at: Option<f64>,
#[serde(default)]
pub last_write_age_seconds: Option<f64>,
#[serde(default)]
pub total_bytes_read: u64,
#[serde(default)]
pub total_bytes_written: u64,
#[serde(default)]
pub clients: Vec<SerialClientLockInfo>,
}

#[derive(Debug, Deserialize)]
pub struct SerialClientLockInfo {
pub client_id: String,
#[serde(default)]
pub pid: Option<u32>,
#[serde(default)]
pub process_alive: Option<bool>,
#[serde(default)]
pub exe: Option<String>,
#[serde(default)]
pub cwd: Option<String>,
#[serde(default)]
pub argv: Option<Vec<String>>,
}

#[derive(Debug, Deserialize)]
pub struct PendingSerialAttachInfo {
pub id: u64,
#[serde(default)]
pub client_id: Option<String>,
#[serde(default)]
pub port: Option<String>,
#[serde(default)]
#[allow(dead_code)]
pub started_at: f64,
#[serde(default)]
pub age_seconds: f64,
}

#[derive(Debug, Deserialize)]
Expand All @@ -247,11 +311,33 @@ pub struct ProjectLockInfo {
pub is_held: bool,
}

#[derive(Debug, Default, Serialize)]
pub struct ClearLocksRequest {
#[serde(default)]
pub serial: bool,
#[serde(default)]
pub stale: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub client_id: Option<String>,
#[serde(default)]
pub force: bool,
}

#[derive(Debug, Deserialize)]
pub struct ClearLocksResponse {
#[allow(dead_code)]
pub success: bool,
pub cleared_count: usize,
#[serde(default)]
pub cleared_project_count: usize,
#[serde(default)]
pub cleared_serial_count: usize,
#[serde(default)]
pub cleared_serial_sessions: Vec<String>,
#[serde(default)]
pub refused: Vec<String>,
pub message: String,
}

Expand Down
Loading
Loading