Skip to main content

max / makenotwork

bento: test multi-target fan-out and the build rollup Every runner test drove exactly one target, so start_build's JoinSet fan-out and finalize_build's rollup were uncovered. Run two targets where one fails: each lands its own terminal row, the succeeding one collects its artifact rather than being torn down with its sibling, the build finalizes failed, and the latest-wins slots are reaped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-22 18:11 UTC
Commit: c517da440e56996173bb2e5225826e347111b50d
Parent: fc70c26
1 file changed, +170 insertions, -0 deletions
@@ -631,6 +631,176 @@ repo = "{}"
631 631 assert!(std::fs::read_to_string(&log).unwrap().contains("compiling"));
632 632 }
633 633
634 + /// Multi-target fan-out, which is what the daemon exists for: every other
635 + /// runner test drives exactly one target, so nothing covered `start_build`'s
636 + /// `JoinSet` fan-out or `finalize_build`'s rollup. One target fails and one
637 + /// succeeds — the failure must not abort its sibling, both must land their
638 + /// own terminal row, and the build must finalize `failed` because any failed
639 + /// target fails the build.
640 + #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
641 + async fn multi_target_fan_out_rolls_up_partial_failure() {
642 + let tmp = tempfile::tempdir().unwrap();
643 + let root = tmp.path();
644 +
645 + let repo = root.join("app");
646 + std::fs::create_dir_all(repo.join("src-tauri")).unwrap();
647 + std::fs::write(
648 + repo.join("src-tauri/tauri.conf.json"),
649 + r#"{"version":"0.0.1"}"#,
650 + )
651 + .unwrap();
652 + std::fs::create_dir_all(repo.join("dist/recipes")).unwrap();
653 +
654 + // Linux succeeds and collects a real artifact. The artifact is the proof
655 + // this target ran to completion rather than being torn down when its
656 + // sibling failed.
657 + std::fs::write(
658 + repo.join("dist/recipes/linux.rhai"),
659 + r#"
660 + step("build");
661 + let v = version_of("demo");
662 + sh_ok("fw13", "mkdir -p REPO/out && echo bin > REPO/out/demo.bin");
663 + step("collect");
664 + collect("fw13", "REPO/out/demo.bin", "demo", v);
665 + "#
666 + .replace("REPO", repo.to_str().unwrap()),
667 + )
668 + .unwrap();
669 + // Windows fails in its first step.
670 + std::fs::write(
671 + repo.join("dist/recipes/windows.rhai"),
672 + r#"
673 + step("build");
674 + sh_ok("winbox", "echo nope 1>&2; exit 1");
675 + "#,
676 + )
677 + .unwrap();
678 +
679 + let cfg = Config::for_tests(root);
680 + let pool = crate::db::open(&cfg.db_path).await.unwrap();
681 + std::fs::write(
682 + repo.join("bento.toml"),
683 + "targets = [\"linux/x86_64\", \"windows/x86_64\"]\n",
684 + )
685 + .unwrap();
686 + // Two hosts so each target resolves its own, mirroring the real
687 + // per-architecture topology.
688 + let topo = Topology::from_str_for_tests(&format!(
689 + r#"
690 + [[host]]
691 + name = "fw13"
692 + ssh = "local"
693 + targets = ["linux/x86_64"]
694 +
695 + [[host]]
696 + name = "winbox"
697 + ssh = "local"
698 + targets = ["windows/x86_64"]
699 +
700 + [app.demo]
701 + repo = "{}"
702 + "#,
703 + repo.display()
704 + ))
705 + .unwrap();
706 +
707 + let executors = Arc::new(crate::state::build_executors(&topo));
708 + let syncs = Arc::new(crate::state::build_syncs(&topo));
709 + let state = AppState {
710 + pool: pool.clone(),
711 + topo: Arc::new(topo),
712 + cfg: Arc::new(cfg),
713 + prom: crate::metrics::test_handle(),
714 + events: crate::events::channel(),
715 + ota: Arc::new(OtaRegistry::standard("https://makenot.work")),
716 + executors,
717 + syncs,
718 + active: Arc::new(Mutex::new(HashMap::new())),
719 + api_token: None,
720 + };
721 +
722 + let build_id = start_build(
723 + state.clone(),
724 + AppId::new("demo"),
725 + Version::parse("0.0.1").unwrap(),
726 + vec![
727 + "linux/x86_64".parse().unwrap(),
728 + "windows/x86_64".parse().unwrap(),
729 + ],
730 + )
731 + .await
732 + .unwrap();
733 +
734 + // Poll the build row, not the target rows: the build is terminal only
735 + // once finalize_build has joined every task and stamped it.
736 + let mut build_status = String::new();
737 + for _ in 0..200 {
738 + build_status = sqlx::query_scalar("SELECT status FROM builds WHERE id = ?")
739 + .bind(build_id)
740 + .fetch_one(&pool)
741 + .await
742 + .unwrap();
743 + if build_status != "running" {
744 + break;
745 + }
746 + tokio::time::sleep(std::time::Duration::from_millis(50)).await;
747 + }
748 +
749 + let runs: Vec<(String, String)> = sqlx::query_as(
750 + "SELECT target, status FROM target_runs WHERE build_id = ? ORDER BY target",
751 + )
752 + .bind(build_id)
753 + .fetch_all(&pool)
754 + .await
755 + .unwrap();
756 + assert_eq!(
757 + runs,
758 + vec![
759 + ("linux/x86_64".to_string(), "ok".to_string()),
760 + ("windows/x86_64".to_string(), "failed".to_string()),
761 + ],
762 + "each target lands its own terminal row; one failing does not take the other down",
763 + );
764 +
765 + // The surviving target finished its work, not merely its row.
766 + assert!(
767 + state.cfg.dist_root.join("demo/0.0.1/demo.bin").exists(),
768 + "the succeeding target ran to completion and collected its artifact",
769 + );
770 +
771 + // The failure is attributed to the target that failed, and only it.
772 + let err: Option<String> = sqlx::query_scalar(
773 + "SELECT error FROM target_runs WHERE build_id = ? AND target = 'windows/x86_64'",
774 + )
775 + .bind(build_id)
776 + .fetch_one(&pool)
777 + .await
778 + .unwrap();
779 + assert!(
780 + err.is_some_and(|e| !e.is_empty()),
781 + "a failed target records why",
782 + );
783 +
784 + assert_eq!(
785 + build_status, "failed",
786 + "any failed target fails the build; a partial release must not read as ok",
787 + );
788 + let finished: Option<String> =
789 + sqlx::query_scalar("SELECT finished_at FROM builds WHERE id = ?")
790 + .bind(build_id)
791 + .fetch_one(&pool)
792 + .await
793 + .unwrap();
794 + assert!(finished.is_some(), "finalize_build stamps the finish time");
795 +
796 + // finalize_build reaps its own latest-wins slots, so nothing is left
797 + // in flight to block a later build of the same targets.
798 + assert!(
799 + state.active.lock().await.is_empty(),
800 + "finalize_build reaps the slots it owned",
801 + );
802 + }
803 +
634 804 /// Run 2 S5: a publish whose version is not strictly newer than the latest
635 805 /// already published for the same (app, target, channel) is refused — an
636 806 /// older build cannot republish over a live newer release.