|
6 | 6 | //! Run with: `uv run soldr cargo test -p fbuild-build --test avr_build -- --ignored` |
7 | 7 |
|
8 | 8 | use std::fs; |
9 | | -use std::path::PathBuf; |
| 9 | +use std::io::Cursor; |
| 10 | +use std::path::{Path, PathBuf}; |
| 11 | + |
| 12 | +use filetime::{set_file_mtime, FileTime}; |
| 13 | +use tar::{Archive, Builder}; |
10 | 14 |
|
11 | 15 | use fbuild_build::{BuildOrchestrator, BuildParams}; |
12 | 16 | use fbuild_core::BuildProfile; |
@@ -294,3 +298,255 @@ void loop() { |
294 | 298 | size.total_flash, size.total_ram, result.build_time_secs |
295 | 299 | ); |
296 | 300 | } |
| 301 | + |
| 302 | +const UNO_PLATFORMIO_INI: &str = |
| 303 | + "[env:uno]\nplatform = atmelavr\nboard = uno\nframework = arduino\n"; |
| 304 | + |
| 305 | +const UNO_BLINK_INO: &str = "\ |
| 306 | +void setup() { |
| 307 | + pinMode(13, OUTPUT); |
| 308 | +} |
| 309 | +
|
| 310 | +void loop() { |
| 311 | + digitalWrite(13, HIGH); |
| 312 | + delay(1000); |
| 313 | + digitalWrite(13, LOW); |
| 314 | + delay(1000); |
| 315 | +} |
| 316 | +"; |
| 317 | + |
| 318 | +fn scaffold_uno_blink(project_dir: &Path) { |
| 319 | + fs::write(project_dir.join("platformio.ini"), UNO_PLATFORMIO_INI).unwrap(); |
| 320 | + let src_dir = project_dir.join("src"); |
| 321 | + fs::create_dir_all(&src_dir).unwrap(); |
| 322 | + fs::write(src_dir.join("blink.ino"), UNO_BLINK_INO).unwrap(); |
| 323 | +} |
| 324 | + |
| 325 | +fn uno_build_params(project_dir: &Path, build_dir: PathBuf, clean: bool) -> BuildParams { |
| 326 | + BuildParams { |
| 327 | + project_dir: project_dir.to_path_buf(), |
| 328 | + env_name: "uno".to_string(), |
| 329 | + clean, |
| 330 | + profile: BuildProfile::Release, |
| 331 | + build_dir, |
| 332 | + verbose: false, |
| 333 | + jobs: None, |
| 334 | + generate_compiledb: false, |
| 335 | + compiledb_only: false, |
| 336 | + log_sender: None, |
| 337 | + symbol_analysis: false, |
| 338 | + symbol_analysis_path: None, |
| 339 | + no_timestamp: false, |
| 340 | + src_dir: None, |
| 341 | + pio_env: Default::default(), |
| 342 | + extra_build_flags: Vec::new(), |
| 343 | + watch_set_cache: None, |
| 344 | + } |
| 345 | +} |
| 346 | + |
| 347 | +fn tar_directory(root: &Path) -> Vec<u8> { |
| 348 | + let mut builder = Builder::new(Vec::new()); |
| 349 | + builder.follow_symlinks(false); |
| 350 | + builder.append_dir_all("proj", root).unwrap(); |
| 351 | + builder.into_inner().unwrap() |
| 352 | +} |
| 353 | + |
| 354 | +fn untar_into(bytes: &[u8], dest: &Path) { |
| 355 | + fs::create_dir_all(dest).unwrap(); |
| 356 | + let mut archive = Archive::new(Cursor::new(bytes)); |
| 357 | + archive.set_preserve_mtime(false); |
| 358 | + archive.unpack(dest).unwrap(); |
| 359 | +} |
| 360 | + |
| 361 | +fn stomp_mtimes(root: &Path, mtime: FileTime) { |
| 362 | + for entry in walkdir::WalkDir::new(root).into_iter().flatten() { |
| 363 | + if entry.file_type().is_file() { |
| 364 | + // Best-effort: some restored files (e.g. read-only artifacts on Windows) |
| 365 | + // may refuse mtime updates; the test's correctness does not depend on every |
| 366 | + // file being stomped, only on enough source-tree files having mtimes that |
| 367 | + // differ from the originals. |
| 368 | + let _ = set_file_mtime(entry.path(), mtime); |
| 369 | + } |
| 370 | + } |
| 371 | +} |
| 372 | + |
| 373 | +fn fingerprint_path(project_dir: &Path) -> PathBuf { |
| 374 | + project_dir.join(".fbuild/build/uno/release/build_fingerprint.json") |
| 375 | +} |
| 376 | + |
| 377 | +/// RAII guard for an env var: sets it on construction, restores the previous |
| 378 | +/// value on drop. Test-only helper so the env var leak does not pollute other |
| 379 | +/// tests that share this process. |
| 380 | +struct EnvVarGuard { |
| 381 | + key: &'static str, |
| 382 | + previous: Option<std::ffi::OsString>, |
| 383 | +} |
| 384 | + |
| 385 | +impl EnvVarGuard { |
| 386 | + fn set(key: &'static str, value: &str) -> Self { |
| 387 | + let previous = std::env::var_os(key); |
| 388 | + std::env::set_var(key, value); |
| 389 | + Self { key, previous } |
| 390 | + } |
| 391 | +} |
| 392 | + |
| 393 | +impl Drop for EnvVarGuard { |
| 394 | + fn drop(&mut self) { |
| 395 | + match &self.previous { |
| 396 | + Some(value) => std::env::set_var(self.key, value), |
| 397 | + None => std::env::remove_var(self.key), |
| 398 | + } |
| 399 | + } |
| 400 | +} |
| 401 | + |
| 402 | +/// End-to-end regression gate for #147: build → tar → restore at a different parent → |
| 403 | +/// rebuild must hit the warm fast path. Integration-level companion to the hermetic |
| 404 | +/// unit test in `tests/cache_survives_tar_extract.rs`. |
| 405 | +/// |
| 406 | +/// Failure modes this catches that the unit test cannot: |
| 407 | +/// * Orchestrator state persisted outside the watch set (e.g., a side file the |
| 408 | +/// fast-path predicate forgot about) that gets invalidated by tar-extract. |
| 409 | +/// * Fast-path predicate bugs that pass the per-layer unit tests but reject a |
| 410 | +/// legitimately-cached `BuildResult`. |
| 411 | +/// * Absolute paths baked into a build artifact that the fast-path check actually |
| 412 | +/// reads (compile DB, build_fingerprint.json) but the unit test does not cover. |
| 413 | +/// |
| 414 | +/// `FBUILD_NO_ZCCACHE=1` is set for this test on purpose: the zccache layer has its |
| 415 | +/// own fingerprint-state machinery that may not survive a tar-restore on every |
| 416 | +/// platform, and that concern is already covered by |
| 417 | +/// `zccache_hit_across_workspace_rename.rs`. This test isolates the fbuild-owned |
| 418 | +/// fast-path predicate (build_fingerprint.json + watch-set stamps) -- which is the |
| 419 | +/// machinery the #147 fix actually changed. |
| 420 | +/// |
| 421 | +/// Gated `#[ignore]` because it downloads avr-gcc + Arduino-AVR core (cached globally |
| 422 | +/// after first run, but still adds 30s+ to first invocation). |
| 423 | +#[test] |
| 424 | +#[ignore] |
| 425 | +fn cache_survives_tar_extract_uno() { |
| 426 | + let _no_zccache = EnvVarGuard::set("FBUILD_NO_ZCCACHE", "1"); |
| 427 | + |
| 428 | + let tmp_a = tempfile::TempDir::new().unwrap(); |
| 429 | + let proj_a = tmp_a.path().join("proj"); |
| 430 | + fs::create_dir_all(&proj_a).unwrap(); |
| 431 | + scaffold_uno_blink(&proj_a); |
| 432 | + |
| 433 | + let orchestrator = fbuild_build::avr::orchestrator::AvrOrchestrator; |
| 434 | + |
| 435 | + let cold_result = orchestrator |
| 436 | + .build(&uno_build_params( |
| 437 | + &proj_a, |
| 438 | + proj_a.join(".fbuild/build"), |
| 439 | + true, |
| 440 | + )) |
| 441 | + .expect("cold AVR build should succeed"); |
| 442 | + assert!(cold_result.success, "cold build should report success"); |
| 443 | + assert!( |
| 444 | + !cold_result.message.contains("reused cached artifacts"), |
| 445 | + "cold build hit fast path unexpectedly; test setup invariant broken: {}", |
| 446 | + cold_result.message, |
| 447 | + ); |
| 448 | + assert!( |
| 449 | + fingerprint_path(&proj_a).exists(), |
| 450 | + "cold build did not persist build_fingerprint.json at {} -- \ |
| 451 | + orchestrator never reached persist_fast_path_success.", |
| 452 | + fingerprint_path(&proj_a).display() |
| 453 | + ); |
| 454 | + let cold_hex = fs::read( |
| 455 | + cold_result |
| 456 | + .firmware_path |
| 457 | + .as_ref() |
| 458 | + .expect("cold build should produce hex"), |
| 459 | + ) |
| 460 | + .unwrap(); |
| 461 | + let cold_time = cold_result.build_time_secs; |
| 462 | + eprintln!("cold build: {:.2}s", cold_time); |
| 463 | + |
| 464 | + // Sanity gate: a same-project warm rebuild MUST hit the fast path before we |
| 465 | + // bother testing the tar-extract case. If this asserts, the test is failing |
| 466 | + // because of an orchestrator/fast-path bug unrelated to tar restoration. |
| 467 | + let same_project_warm = orchestrator |
| 468 | + .build(&uno_build_params( |
| 469 | + &proj_a, |
| 470 | + proj_a.join(".fbuild/build"), |
| 471 | + false, |
| 472 | + )) |
| 473 | + .expect("same-project warm build should succeed"); |
| 474 | + assert!( |
| 475 | + same_project_warm |
| 476 | + .message |
| 477 | + .contains("reused cached artifacts"), |
| 478 | + "same-project warm rebuild did not hit fast path -- the regression is in the \ |
| 479 | + fast-path predicate itself, not in tar-restore handling. message: {}", |
| 480 | + same_project_warm.message, |
| 481 | + ); |
| 482 | + eprintln!( |
| 483 | + "same-project warm: {:.2}s (fast-path hit confirmed)", |
| 484 | + same_project_warm.build_time_secs |
| 485 | + ); |
| 486 | + |
| 487 | + let tarball = tar_directory(&proj_a); |
| 488 | + |
| 489 | + let tmp_b = tempfile::TempDir::new().unwrap(); |
| 490 | + let relocation_root = tmp_b.path().join("nested").join("run-b").join("deeper"); |
| 491 | + fs::create_dir_all(&relocation_root).unwrap(); |
| 492 | + untar_into(&tarball, &relocation_root); |
| 493 | + let proj_b = relocation_root.join("proj"); |
| 494 | + assert!( |
| 495 | + proj_b.join("src/blink.ino").exists(), |
| 496 | + "tar restore left no src/blink.ino at {}", |
| 497 | + proj_b.display() |
| 498 | + ); |
| 499 | + assert!( |
| 500 | + proj_b.join(".fbuild/build").exists(), |
| 501 | + "tar restore left no .fbuild/build/ at {}", |
| 502 | + proj_b.display() |
| 503 | + ); |
| 504 | + assert_ne!( |
| 505 | + proj_a.parent(), |
| 506 | + proj_b.parent(), |
| 507 | + "test setup invariant: restored project must live under a different parent path" |
| 508 | + ); |
| 509 | + |
| 510 | + stomp_mtimes(&proj_b, FileTime::from_unix_time(1_577_836_800, 0)); // 2020-01-01 UTC |
| 511 | + |
| 512 | + let warm_result = orchestrator |
| 513 | + .build(&uno_build_params( |
| 514 | + &proj_b, |
| 515 | + proj_b.join(".fbuild/build"), |
| 516 | + false, |
| 517 | + )) |
| 518 | + .expect("warm AVR build (post tar-extract) should succeed"); |
| 519 | + assert!(warm_result.success, "warm build should report success"); |
| 520 | + assert!( |
| 521 | + warm_result.message.contains("reused cached artifacts"), |
| 522 | + "warm build did NOT hit fast path -- this is the regression #147 was supposed to \ |
| 523 | + prevent. message: {}", |
| 524 | + warm_result.message, |
| 525 | + ); |
| 526 | + |
| 527 | + let warm_hex = fs::read( |
| 528 | + warm_result |
| 529 | + .firmware_path |
| 530 | + .as_ref() |
| 531 | + .expect("warm build should still report a hex path"), |
| 532 | + ) |
| 533 | + .unwrap(); |
| 534 | + assert_eq!( |
| 535 | + cold_hex, warm_hex, |
| 536 | + "warm build returned a different firmware.hex than the cold build; \ |
| 537 | + fast-path artifacts diverged across tar-restore." |
| 538 | + ); |
| 539 | + |
| 540 | + let warm_time = warm_result.build_time_secs; |
| 541 | + eprintln!( |
| 542 | + "warm build (post tar-extract relocation): {:.2}s (cold was {:.2}s)", |
| 543 | + warm_time, cold_time |
| 544 | + ); |
| 545 | + assert!( |
| 546 | + warm_time < cold_time, |
| 547 | + "warm build ({:.2}s) was not faster than cold build ({:.2}s); fast-path is not \ |
| 548 | + actually short-circuiting the compile/link stack.", |
| 549 | + warm_time, |
| 550 | + cold_time, |
| 551 | + ); |
| 552 | +} |
0 commit comments