|
|
@@ -119,12 +119,49 @@ pub(crate) struct BenchLogReq {
|
|
|
pub lines: Option<usize>,
|
|
|
}
|
|
|
|
|
|
-/// `GET /api/bench/log?recipe=<name>` — the newest bench log for the recipe;
|
|
|
-/// the UI polls this in the bench window to show the harness early.
|
|
|
+/// The newest bench **job** for a recipe (running preferred, else the latest
|
|
|
+/// by start time). The bench window follows this job, not "whatever log the
|
|
|
+/// directory has": the previous campaign's log is older than the job and must
|
|
|
+/// never be shown as the current run.
|
|
|
+fn latest_bench_job(app: &AppState, recipe: &str) -> Option<super::jobs::Job> {
|
|
|
+ let jobs = app.jobs.lock().unwrap();
|
|
|
+ jobs.iter()
|
|
|
+ .filter(|j| j.kind == "bench" && j.recipe.as_deref() == Some(recipe))
|
|
|
+ .max_by_key(|j| j.started)
|
|
|
+ .cloned()
|
|
|
+}
|
|
|
+
|
|
|
+/// Pick the log file belonging to a job: only entries written at/after the
|
|
|
+/// job's start (a few seconds of slack for clock granularity) qualify.
|
|
|
+fn pick_log(entries: &[(u64, std::path::PathBuf)], cutoff: u64) -> Option<std::path::PathBuf> {
|
|
|
+ entries
|
|
|
+ .iter()
|
|
|
+ .filter(|(mtime, _)| *mtime + 5 >= cutoff)
|
|
|
+ .max_by_key(|(mtime, _)| *mtime)
|
|
|
+ .map(|(_, p)| p.clone())
|
|
|
+}
|
|
|
+
|
|
|
+/// `GET /api/bench/log?recipe=<name>` — the log of the recipe's **current**
|
|
|
+/// bench job, tailed for the bench window.
|
|
|
+///
|
|
|
+/// The window used to read the newest `.log` in `reports/`, so right after a
|
|
|
+/// launch it displayed the *previous* campaign (and stopped polling because
|
|
|
+/// that run had finished). The job's start time is the cutoff now; while the
|
|
|
+/// harness has not created its file yet the response is `waiting`, and the
|
|
|
+/// window keeps polling instead of declaring the run finished.
|
|
|
pub(crate) async fn bench_log(
|
|
|
app: State<AppState>,
|
|
|
Query(req): Query<BenchLogReq>,
|
|
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
|
|
+ let job = latest_bench_job(&app, &req.recipe);
|
|
|
+ let running = job
|
|
|
+ .as_ref()
|
|
|
+ .is_some_and(|j| j.status == super::jobs::JobStatus::Running);
|
|
|
+ let status = job
|
|
|
+ .as_ref()
|
|
|
+ .map(|j| format!("{:?}", j.status).to_lowercase());
|
|
|
+ let message = job.as_ref().map(|j| j.message.clone());
|
|
|
+ let cutoff = job.as_ref().map(|j| j.started).unwrap_or(0);
|
|
|
let file =
|
|
|
crate::config::recipe_file(&req.recipe, &app.config.recipe_sources).ok_or_else(|| {
|
|
|
(
|
|
|
@@ -132,37 +169,46 @@ pub(crate) async fn bench_log(
|
|
|
format!("recipe '{}' not found", req.recipe),
|
|
|
)
|
|
|
})?;
|
|
|
- let dir = file.parent().map(|p| p.join("reports"));
|
|
|
- let Some(dir) = dir else {
|
|
|
- return Err((
|
|
|
+ let dir = file.parent().map(|p| p.join("reports")).ok_or_else(|| {
|
|
|
+ (
|
|
|
StatusCode::NOT_FOUND,
|
|
|
"recipe has no reports dir".to_string(),
|
|
|
- ));
|
|
|
- };
|
|
|
- let mut entries = tokio::fs::read_dir(&dir)
|
|
|
- .await
|
|
|
- .map_err(|e| (StatusCode::NOT_FOUND, format!("no reports dir: {e}")))?;
|
|
|
- let mut latest: Option<(std::time::SystemTime, std::path::PathBuf)> = None;
|
|
|
- while let Some(entry) = entries
|
|
|
- .next_entry()
|
|
|
- .await
|
|
|
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
|
- {
|
|
|
- let path = entry.path();
|
|
|
- if path.extension() != Some(std::ffi::OsStr::new("log")) {
|
|
|
- continue;
|
|
|
- }
|
|
|
- let md = entry
|
|
|
- .metadata()
|
|
|
- .await
|
|
|
- .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
|
- let m = md.modified().unwrap_or(std::time::SystemTime::UNIX_EPOCH);
|
|
|
- if latest.as_ref().is_none_or(|(old, _)| m > *old) {
|
|
|
- latest = Some((m, path));
|
|
|
+ )
|
|
|
+ })?;
|
|
|
+ let mut entries: Vec<(u64, std::path::PathBuf)> = Vec::new();
|
|
|
+ if let Ok(mut rd) = tokio::fs::read_dir(&dir).await {
|
|
|
+ while let Ok(Some(entry)) = rd.next_entry().await {
|
|
|
+ let path = entry.path();
|
|
|
+ if path.extension() != Some(std::ffi::OsStr::new("log")) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ let Ok(md) = entry.metadata().await else {
|
|
|
+ continue;
|
|
|
+ };
|
|
|
+ let mtime = md
|
|
|
+ .modified()
|
|
|
+ .ok()
|
|
|
+ .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
|
|
|
+ .map(|d| d.as_secs())
|
|
|
+ .unwrap_or(0);
|
|
|
+ entries.push((mtime, path));
|
|
|
}
|
|
|
}
|
|
|
- let Some((_, path)) = latest else {
|
|
|
- return Err((StatusCode::NOT_FOUND, "no bench log yet".to_string()));
|
|
|
+ let picked = pick_log(&entries, cutoff);
|
|
|
+ let Some(path) = picked else {
|
|
|
+ // Job started but the harness has not created its log yet (the bench
|
|
|
+ // waits for API readiness first). Not an error — the window shows
|
|
|
+ // "waiting" and keeps polling.
|
|
|
+ return Ok(Json(serde_json::json!({
|
|
|
+ "waiting": true,
|
|
|
+ "previous": false,
|
|
|
+ "path": serde_json::Value::Null,
|
|
|
+ "lines": Vec::<String>::new(),
|
|
|
+ "job": job.as_ref().map(|j| j.id.clone()),
|
|
|
+ "running": running,
|
|
|
+ "status": status,
|
|
|
+ "message": message,
|
|
|
+ })));
|
|
|
};
|
|
|
let text = tokio::fs::read_to_string(&path)
|
|
|
.await
|
|
|
@@ -170,20 +216,47 @@ pub(crate) async fn bench_log(
|
|
|
let n = req.lines.unwrap_or(160).clamp(20, 1000);
|
|
|
let all: Vec<&str> = text.lines().collect();
|
|
|
let lines = all[all.len().saturating_sub(n)..].to_vec();
|
|
|
- let (job, running) = {
|
|
|
- let jobs = app.jobs.lock().unwrap();
|
|
|
- let j = jobs
|
|
|
- .iter()
|
|
|
- .find(|j| j.kind == "bench" && j.recipe.as_deref() == Some(req.recipe.as_str()));
|
|
|
- (
|
|
|
- j.map(|j| j.id.clone()),
|
|
|
- j.is_some_and(|j| j.status == super::jobs::JobStatus::Running),
|
|
|
- )
|
|
|
- };
|
|
|
Ok(Json(serde_json::json!({
|
|
|
+ "waiting": false,
|
|
|
+ // No job at all: the pane shows the *previous* campaign's log, and the
|
|
|
+ // window says so instead of pretending it is the current run.
|
|
|
+ "previous": job.is_none(),
|
|
|
"path": path.display().to_string(),
|
|
|
"lines": lines,
|
|
|
- "job": job,
|
|
|
+ "job": job.as_ref().map(|j| j.id.clone()),
|
|
|
"running": running,
|
|
|
+ "status": status,
|
|
|
+ "message": message,
|
|
|
})))
|
|
|
}
|
|
|
+
|
|
|
+#[cfg(test)]
|
|
|
+mod tests {
|
|
|
+ use super::pick_log;
|
|
|
+ use std::path::PathBuf;
|
|
|
+
|
|
|
+ fn entries() -> Vec<(u64, PathBuf)> {
|
|
|
+ vec![
|
|
|
+ (1_000, PathBuf::from("old.log")),
|
|
|
+ (2_000, PathBuf::from("older-but-newer.log")),
|
|
|
+ ]
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn an_older_log_is_not_mistaken_for_the_current_run() {
|
|
|
+ // the job started at 3000 — both files predate it
|
|
|
+ assert_eq!(pick_log(&entries(), 3_000), None);
|
|
|
+ }
|
|
|
+
|
|
|
+ #[test]
|
|
|
+ fn the_log_written_after_the_job_start_wins() {
|
|
|
+ let mut e = entries();
|
|
|
+ e.push((3_100, PathBuf::from("current.log")));
|
|
|
+ assert_eq!(pick_log(&e, 3_000), Some(PathBuf::from("current.log")));
|
|
|
+ // a file created in the same second as the job counts (slack)
|
|
|
+ assert_eq!(
|
|
|
+ pick_log(&[(2_998, PathBuf::from("same-second.log"))], 3_000),
|
|
|
+ Some(PathBuf::from("same-second.log"))
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|