From e04cb59888962d0aee0ca53bf6ad9ad4a0a9850d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:23:56 +0000 Subject: [PATCH 1/3] Start fix for #1606, #1607 Assisted-by: Claude Code:claude-opus-5-5 From 5b2f74850b6187a2f05e60644d10e1aaa74acf1e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:32:35 +0000 Subject: [PATCH 2/3] Ask about each pinned patch once per rescan A hosted scan asked the patch API's reference endpoint about the same patches twice: once in the withdrawn-pin sweep, and again in the redirect. On a rescan those are the same uuids, so every hosted rescan paid for one extra network round trip. The sweep now keeps the answers it fetched and hands them to the redirect through the rollout gate. The redirect asks only about the selected uuids the sweep did not, and sends no request at all when the sweep covered them. A uuid the sweep asked about but the endpoint left out keeps reading as not found, as a second request would say. Refs #1606, #1607 Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 39 +++++-- .../socket-patch-cli/src/commands/scan/mod.rs | 17 ++- .../src/commands/scan/rollout.rs | 14 +++ .../src/commands/scan/withdrawn.rs | 63 ++++++++++- .../tests/in_process_scan_withdrawn.rs | 100 ++++++++++++++++++ 5 files changed, 222 insertions(+), 11 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 851ca40c5..1ce06256e 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -619,6 +619,9 @@ pub(super) async fn run_redirect( stage: &mut super::rollout::Stage, // Scan's pre-redirect lockfile discovery (see `rollout::Gate::prior`). prior: Option>, + // The withdrawn-pin sweep's reference answers (see + // `rollout::Gate::references`). + references: Option, // `--prune` / `--sync`, gated by the policy exactly as the human arm // gates it (`patches.enabled: false` writes nothing, the GC included): // only feeds the `redirect_prune_ignored` warning. @@ -685,7 +688,11 @@ pub(super) async fn run_redirect( &pairs, scan_result, npm_prior, - Some(super::rollout::Gate::new(stage, rows).with_prior(prior)), + Some( + super::rollout::Gate::new(stage, rows) + .with_prior(prior) + .with_references(references), + ), "scan", ) .await @@ -869,12 +876,30 @@ async fn run_redirect_selected_untracked( if !selected.is_empty() { let uuids: Vec = selected.iter().map(|(_, uuid)| uuid.clone()).collect(); - status.set(format!( - "Resolving hosted artifacts for {}...", - crate::ui::plural(uuids.len(), "patch", "patches") - )); - let fetched = api_client.fetch_registry_references(&uuids).await; - status.finish(); + // A rescan selects the uuids its lockfiles pin, which the + // withdrawn-pin sweep just asked about: ask only about the rest. + let known = rollout.as_mut().and_then(|gate| gate.references.take()); + let to_fetch = match &known { + Some(known) => known.unasked(&uuids), + None => uuids.clone(), + }; + let fetched = if to_fetch.is_empty() { + Ok(std::collections::HashMap::new()) + } else { + status.set(format!( + "Resolving hosted artifacts for {}...", + crate::ui::plural(to_fetch.len(), "patch", "patches") + )); + let fetched = api_client.fetch_registry_references(&to_fetch).await; + status.finish(); + fetched + }; + let fetched = fetched.map(|mut fetched| { + if let Some(known) = &known { + fetched.extend(known.answers_for(&uuids)); + } + fetched + }); let references = match fetched { Ok(r) => r, // A capped run whose every row is NEW: the failure affects only diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index 33e1c1cf6..dde90c4e2 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2644,7 +2644,7 @@ async fn run_scan( // longer serves, before the rewrite below re-pins any replacement. // Runs ahead of every "nothing found" exit: a package whose only patch // was withdrawn has no search results at all. - let withdrawn_sweep = if hosted && !args.common.is_global() && policy.writes_allowed() { + let mut withdrawn_sweep = if hosted && !args.common.is_global() && policy.writes_allowed() { let pins = withdrawn::pins_in_scope(&hosted_pin_list, &all_purls); Some(withdrawn::sweep(&args.common, &api_client, &pins).await) } else { @@ -2766,7 +2766,9 @@ async fn run_scan( // Hosted mode: the redirect engine records its events into this // envelope and prints it. if hosted { - if let Some(sweep) = withdrawn_sweep { + let mut references = None; + if let Some(mut sweep) = withdrawn_sweep { + references = sweep.take_references(); sweep.record_into(&mut env); } return run_redirect( @@ -2782,6 +2784,7 @@ async fn run_scan( batch_error_count > 0, &mut stage, prior_discovery, + references, prune, ) .await; @@ -3255,7 +3258,15 @@ async fn run_scan( &pairs, None, npm_crawl.as_ref(), - Some(rollout::Gate::new(&mut stage, rows).with_prior(prior_discovery)), + Some( + rollout::Gate::new(&mut stage, rows) + .with_prior(prior_discovery) + .with_references( + withdrawn_sweep + .as_mut() + .and_then(withdrawn::WithdrawnSweep::take_references), + ), + ), "scan", ) .await; diff --git a/crates/socket-patch-cli/src/commands/scan/rollout.rs b/crates/socket-patch-cli/src/commands/scan/rollout.rs index 97b614369..b5c37ac6c 100644 --- a/crates/socket-patch-cli/src/commands/scan/rollout.rs +++ b/crates/socket-patch-cli/src/commands/scan/rollout.rs @@ -47,6 +47,9 @@ pub(crate) struct Gate<'a> { /// (with the configured patch-server origins): the rewrite's /// attribution gate reuses it when nothing changed the project since. pub(crate) prior: Option>, + /// The withdrawn-pin sweep's reference answers: the redirect asks the + /// endpoint only about the selected uuids these do not cover. + pub(crate) references: Option, } /// Scan's discovery, made BEFORE the apply lock, with the paths it read. @@ -76,6 +79,7 @@ impl<'a> Gate<'a> { stage, rows, prior: None, + references: None, } } @@ -85,6 +89,16 @@ impl<'a> Gate<'a> { self } + /// This gate carrying the sweep's reference answers (see + /// [`Self::references`]). + pub(crate) fn with_references( + mut self, + references: Option, + ) -> Self { + self.references = references; + self + } + /// `(purl, uuid)` of every NEW row, for O(1) [`Self::is_new`] checks. pub(crate) fn new_keys(&self) -> HashSet<(String, String)> { self.rows diff --git a/crates/socket-patch-cli/src/commands/scan/withdrawn.rs b/crates/socket-patch-cli/src/commands/scan/withdrawn.rs index c5ccc120b..44ef7a36a 100644 --- a/crates/socket-patch-cli/src/commands/scan/withdrawn.rs +++ b/crates/socket-patch-cli/src/commands/scan/withdrawn.rs @@ -23,11 +23,16 @@ //! entitlement looks like. A `forbidden`, `pending_build` or `build_failed` //! answer keeps the pin too. If the reference lookup fails (the patch API //! is down, say), every pin is kept and the run warns. +//! +//! The answers are kept ([`WithdrawnSweep::take_references`]): on a rescan +//! the redirect selects the same uuids the lockfiles pin, and asks the +//! endpoint only about the ones the sweep did not (#1606, #1607). -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; use std::time::Duration; use socket_patch_core::api::client::ApiClient; +use socket_patch_core::api::types::PackageVendorResult; use socket_patch_core::patch::redirect::upstream::HostedPin; use socket_patch_core::utils::purl_key::PurlKey; @@ -60,9 +65,45 @@ pub(super) struct WithdrawnSweep { events: Vec, /// `(code, detail)` run-level warnings. warnings: Vec<(String, String)>, + /// The reference endpoint's answers, when the lookup succeeded. + references: Option, +} + +/// Reference lookups a run already made: the uuids it asked about and the +/// endpoint's answers. A uuid asked about but absent from `results` is one +/// the endpoint does not know (`not_found`), the same answer a second +/// request would give. +#[derive(Debug, Default)] +pub(crate) struct KnownReferences { + asked: BTreeSet, + results: HashMap, +} + +impl KnownReferences { + /// The uuids in `uuids` this lookup did not ask about. + pub(crate) fn unasked(&self, uuids: &[String]) -> Vec { + uuids + .iter() + .filter(|uuid| !self.asked.contains(*uuid)) + .cloned() + .collect() + } + + /// The known answers for `uuids`, as the reference fetch returns them. + pub(crate) fn answers_for(&self, uuids: &[String]) -> HashMap { + uuids + .iter() + .filter_map(|uuid| Some((uuid.clone(), self.results.get(uuid)?.clone()))) + .collect() + } } impl WithdrawnSweep { + /// The reference answers the sweep fetched, for the redirect to reuse. + pub(super) fn take_references(&mut self) -> Option { + self.references.take() + } + /// Fold the outcome into the scan envelope. pub(super) fn record_into(self, env: &mut Envelope) { for event in self.events { @@ -140,6 +181,10 @@ pub(super) async fn sweep( is_gone(status).then(|| (pin.clone(), status.clone())) }) .collect(); + out.references = Some(KnownReferences { + asked: uuids.into_iter().collect(), + results: references, + }); if gone.is_empty() { return out; } @@ -271,4 +316,20 @@ mod tests { assert!(pins_in_scope(&pins, ["pkg:npm/b@2.0.1"]).is_empty()); assert!(pins_in_scope(&pins, Vec::::new()).is_empty()); } + + #[test] + fn known_references_cover_only_the_uuids_asked_about() { + let granted: PackageVendorResult = + serde_json::from_value(serde_json::json!({ "status": "granted" })).unwrap(); + let known = KnownReferences { + // `u2` was asked about but the endpoint left it out. + asked: ["u1", "u2"].map(String::from).into(), + results: HashMap::from([("u1".to_string(), granted)]), + }; + let uuids = ["u1", "u2", "u3"].map(String::from); + assert_eq!(known.unasked(&uuids), ["u3"]); + let answers = known.answers_for(&uuids); + assert_eq!(answers.len(), 1); + assert_eq!(answers["u1"].status, "granted"); + } } diff --git a/crates/socket-patch-cli/tests/in_process_scan_withdrawn.rs b/crates/socket-patch-cli/tests/in_process_scan_withdrawn.rs index a4629eab5..865f49f5c 100644 --- a/crates/socket-patch-cli/tests/in_process_scan_withdrawn.rs +++ b/crates/socket-patch-cli/tests/in_process_scan_withdrawn.rs @@ -525,3 +525,103 @@ async fn the_envelope_reports_the_restored_pin() { assert_eq!(restored[0]["details"]["mode"], "hosted"); assert_eq!(lock(tmp.path()), pristine); } + +/// The uuids each reference request asked about, in request order. +async fn reference_uuids(server: &MockServer) -> Vec> { + server + .received_requests() + .await + .unwrap_or_default() + .iter() + .filter(|r| r.url.path().ends_with("/patches/package")) + .map(|r| { + let body: Value = serde_json::from_slice(&r.body).unwrap(); + let mut uuids: Vec = body["uuids"] + .as_array() + .unwrap() + .iter() + .map(|u| u.as_str().unwrap().to_string()) + .collect(); + uuids.sort(); + uuids + }) + .collect() +} + +/// A rescan of a pin whose patch is still offered asks the reference +/// endpoint about its uuid once: the redirect reuses the sweep's answer +/// instead of sending the same request again (#1606, #1607). +async fn rescan_asks_once(json: bool) { + let tmp = tempfile::tempdir().unwrap(); + let (_, wired) = wired_project(tmp.path()).await; + + let server = MockServer::start().await; + mock_search(&server, &[UUID]).await; + mock_references( + &server, + serde_json::json!({ UUID: granted(HOSTED_URL, PATCHED_SHA512) }), + ) + .await; + mock_view(&server, UUID).await; + std::env::set_var( + "SOCKET_NPM_REGISTRY", + format!("{}/npm-registry", server.uri()), + ); + let mut args = scan_args(tmp.path(), server.uri(), false); + args.common.json = json; + args.common.silent = !json; + let code = scan_run(args).await; + for var in ["SOCKET_NPM_REGISTRY", "SOCKET_DRY_RUN"] { + std::env::remove_var(var); + } + assert_eq!(code, 0); + + assert_eq!(lock(tmp.path()), wired, "the rescan keeps the pin"); + assert_eq!( + reference_uuids(&server).await, + vec![vec![UUID.to_string()]], + "one reference request for the pinned uuid, not one per phase" + ); +} + +#[tokio::test] +#[serial] +async fn a_json_rescan_asks_about_a_pinned_uuid_once() { + rescan_asks_once(true).await; +} + +#[tokio::test] +#[serial] +async fn a_human_rescan_asks_about_a_pinned_uuid_once() { + rescan_asks_once(false).await; +} + +/// A replacement patch the sweep never asked about is still looked up, and +/// only it: the withdrawn uuid's answer is reused. +#[tokio::test] +#[serial] +async fn the_redirect_asks_only_about_uuids_the_sweep_did_not() { + let tmp = tempfile::tempdir().unwrap(); + wired_project(tmp.path()).await; + + let server = MockServer::start().await; + mock_search(&server, &[UUID2]).await; + mock_references( + &server, + serde_json::json!({ + UUID: { "status": "withdrawn" }, + UUID2: granted(HOSTED_URL2, PATCHED_SHA512_2), + }), + ) + .await; + mock_view(&server, UUID2).await; + mock_npm_registry(&server).await; + assert_eq!(scan(tmp.path(), &server, false).await, 0); + + let wired = lock(tmp.path()); + assert!(wired.contains(HOSTED_URL2), "re-pinned:\n{wired}"); + assert_eq!( + reference_uuids(&server).await, + vec![vec![UUID.to_string()], vec![UUID2.to_string()]], + ); +} From 4fd3d54f36a44d7d0868af73e8b9a1985fbf8ae7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Oct 2026 16:45:00 +0000 Subject: [PATCH 3/3] Re-ask reference answers that may change Review fixes for the reused reference answers. The redirect now reuses only settled answers (granted, reused, withdrawn, not_found, forbidden) for uuids the sweep actually asked about. A pending build, an unknown status, or a uuid the response left out (a 404 chunk) is asked again, so a patch that finishes building mid-run is still pinned. Assisted-by: Claude Code:claude-opus-5-5 --- .../src/commands/scan/hosted.rs | 11 ++- .../socket-patch-cli/src/commands/scan/mod.rs | 16 ++-- .../src/commands/scan/withdrawn.rs | 87 +++++++++++-------- .../tests/in_process_scan_withdrawn.rs | 48 +++++++--- 4 files changed, 100 insertions(+), 62 deletions(-) diff --git a/crates/socket-patch-cli/src/commands/scan/hosted.rs b/crates/socket-patch-cli/src/commands/scan/hosted.rs index 1ce06256e..edbbc1f99 100644 --- a/crates/socket-patch-cli/src/commands/scan/hosted.rs +++ b/crates/socket-patch-cli/src/commands/scan/hosted.rs @@ -877,10 +877,11 @@ async fn run_redirect_selected_untracked( if !selected.is_empty() { let uuids: Vec = selected.iter().map(|(_, uuid)| uuid.clone()).collect(); // A rescan selects the uuids its lockfiles pin, which the - // withdrawn-pin sweep just asked about: ask only about the rest. + // withdrawn-pin sweep just asked about: ask only about the uuids it + // has no settled answer for. let known = rollout.as_mut().and_then(|gate| gate.references.take()); let to_fetch = match &known { - Some(known) => known.unasked(&uuids), + Some(known) => known.unknown(&uuids), None => uuids.clone(), }; let fetched = if to_fetch.is_empty() { @@ -894,9 +895,11 @@ async fn run_redirect_selected_untracked( status.finish(); fetched }; + // `build_candidates` looks up only the selected uuids, so the + // sweep's answers for pins outside the selection are inert. let fetched = fetched.map(|mut fetched| { - if let Some(known) = &known { - fetched.extend(known.answers_for(&uuids)); + if let Some(known) = known { + fetched.extend(known.into_answers()); } fetched }); diff --git a/crates/socket-patch-cli/src/commands/scan/mod.rs b/crates/socket-patch-cli/src/commands/scan/mod.rs index dde90c4e2..413869c28 100644 --- a/crates/socket-patch-cli/src/commands/scan/mod.rs +++ b/crates/socket-patch-cli/src/commands/scan/mod.rs @@ -2650,6 +2650,10 @@ async fn run_scan( } else { None }; + // The sweep's reference answers, reused by the hosted redirect. + let sweep_references = withdrawn_sweep + .as_mut() + .and_then(withdrawn::WithdrawnSweep::take_references); // Calculate patch counts let mut free_patches = 0usize; @@ -2766,9 +2770,7 @@ async fn run_scan( // Hosted mode: the redirect engine records its events into this // envelope and prints it. if hosted { - let mut references = None; - if let Some(mut sweep) = withdrawn_sweep { - references = sweep.take_references(); + if let Some(sweep) = withdrawn_sweep { sweep.record_into(&mut env); } return run_redirect( @@ -2784,7 +2786,7 @@ async fn run_scan( batch_error_count > 0, &mut stage, prior_discovery, - references, + sweep_references, prune, ) .await; @@ -3261,11 +3263,7 @@ async fn run_scan( Some( rollout::Gate::new(&mut stage, rows) .with_prior(prior_discovery) - .with_references( - withdrawn_sweep - .as_mut() - .and_then(withdrawn::WithdrawnSweep::take_references), - ), + .with_references(sweep_references), ), "scan", ) diff --git a/crates/socket-patch-cli/src/commands/scan/withdrawn.rs b/crates/socket-patch-cli/src/commands/scan/withdrawn.rs index 44ef7a36a..045d64951 100644 --- a/crates/socket-patch-cli/src/commands/scan/withdrawn.rs +++ b/crates/socket-patch-cli/src/commands/scan/withdrawn.rs @@ -24,9 +24,10 @@ //! answer keeps the pin too. If the reference lookup fails (the patch API //! is down, say), every pin is kept and the run warns. //! -//! The answers are kept ([`WithdrawnSweep::take_references`]): on a rescan -//! the redirect selects the same uuids the lockfiles pin, and asks the -//! endpoint only about the ones the sweep did not (#1606, #1607). +//! The settled answers are kept ([`WithdrawnSweep::take_references`]): on +//! a rescan the redirect selects the same uuids the lockfiles pin, and asks +//! the endpoint only about the ones the sweep has no answer for (#1606, +//! #1607). use std::collections::{BTreeSet, HashMap}; use std::time::Duration; @@ -69,32 +70,40 @@ pub(super) struct WithdrawnSweep { references: Option, } -/// Reference lookups a run already made: the uuids it asked about and the -/// endpoint's answers. A uuid asked about but absent from `results` is one -/// the endpoint does not know (`not_found`), the same answer a second -/// request would give. +/// Reference answers the sweep already has, for the redirect to reuse. +/// Only settled answers are kept: a `pending_build` (or any status this +/// build does not know) may have changed by the time the redirect runs, and +/// a uuid the response left out (a 404 chunk answers nothing) is asked +/// again rather than read as `not_found`. #[derive(Debug, Default)] -pub(crate) struct KnownReferences { - asked: BTreeSet, - results: HashMap, -} +pub(crate) struct KnownReferences(HashMap); impl KnownReferences { - /// The uuids in `uuids` this lookup did not ask about. - pub(crate) fn unasked(&self, uuids: &[String]) -> Vec { + /// The settled answers in `results` for the uuids in `asked` (an + /// answer the request did not ask for is not trusted). + fn settled(asked: &[String], mut results: HashMap) -> Self { + results.retain(|uuid, r| { + asked.contains(uuid) + && matches!( + r.status.as_str(), + "granted" | "reused" | "withdrawn" | "not_found" | "forbidden" + ) + }); + KnownReferences(results) + } + + /// The uuids in `uuids` with no known answer. + pub(crate) fn unknown(&self, uuids: &[String]) -> Vec { uuids .iter() - .filter(|uuid| !self.asked.contains(*uuid)) + .filter(|uuid| !self.0.contains_key(*uuid)) .cloned() .collect() } - /// The known answers for `uuids`, as the reference fetch returns them. - pub(crate) fn answers_for(&self, uuids: &[String]) -> HashMap { - uuids - .iter() - .filter_map(|uuid| Some((uuid.clone(), self.results.get(uuid)?.clone()))) - .collect() + /// The known answers, keyed by uuid as the reference fetch returns them. + pub(crate) fn into_answers(self) -> HashMap { + self.0 } } @@ -181,10 +190,7 @@ pub(super) async fn sweep( is_gone(status).then(|| (pin.clone(), status.clone())) }) .collect(); - out.references = Some(KnownReferences { - asked: uuids.into_iter().collect(), - results: references, - }); + out.references = Some(KnownReferences::settled(&uuids, references)); if gone.is_empty() { return out; } @@ -318,18 +324,27 @@ mod tests { } #[test] - fn known_references_cover_only_the_uuids_asked_about() { - let granted: PackageVendorResult = - serde_json::from_value(serde_json::json!({ "status": "granted" })).unwrap(); - let known = KnownReferences { - // `u2` was asked about but the endpoint left it out. - asked: ["u1", "u2"].map(String::from).into(), - results: HashMap::from([("u1".to_string(), granted)]), + fn only_settled_answers_are_known() { + let answer = |status: &str| -> PackageVendorResult { + serde_json::from_value(serde_json::json!({ "status": status })).unwrap() }; - let uuids = ["u1", "u2", "u3"].map(String::from); - assert_eq!(known.unasked(&uuids), ["u3"]); - let answers = known.answers_for(&uuids); - assert_eq!(answers.len(), 1); - assert_eq!(answers["u1"].status, "granted"); + // `left-out`: the response did not answer it (a 404 chunk, say). + let uuids = ["granted", "withdrawn", "building", "failed", "left-out"].map(String::from); + let known = KnownReferences::settled( + &uuids, + HashMap::from([ + ("granted".to_string(), answer("granted")), + ("withdrawn".to_string(), answer("withdrawn")), + ("building".to_string(), answer("pending_build")), + ("failed".to_string(), answer("build_failed")), + // Not asked for: dropped. + ("unasked".to_string(), answer("granted")), + ]), + ); + assert_eq!(known.unknown(&uuids), ["building", "failed", "left-out"]); + assert_eq!(known.unknown(&["unasked".to_string()]), ["unasked"]); + let mut answers: Vec<_> = known.into_answers().into_keys().collect(); + answers.sort(); + assert_eq!(answers, ["granted", "withdrawn"]); } } diff --git a/crates/socket-patch-cli/tests/in_process_scan_withdrawn.rs b/crates/socket-patch-cli/tests/in_process_scan_withdrawn.rs index 865f49f5c..c51f21319 100644 --- a/crates/socket-patch-cli/tests/in_process_scan_withdrawn.rs +++ b/crates/socket-patch-cli/tests/in_process_scan_withdrawn.rs @@ -68,11 +68,19 @@ fn scan_args(cwd: &Path, api_url: String, dry_run: bool) -> ScanArgs { /// Run an in-process hosted scan with the npm registry pointed at /// `server`, scrubbing the env toggles `run` mirrors into the process. async fn scan(cwd: &Path, server: &MockServer, dry_run: bool) -> i32 { + scan_with(cwd, server, dry_run, true).await +} + +/// [`scan`], with `--json` on or off (off runs `--silent`). +async fn scan_with(cwd: &Path, server: &MockServer, dry_run: bool, json: bool) -> i32 { std::env::set_var( "SOCKET_NPM_REGISTRY", format!("{}/npm-registry", server.uri()), ); - let code = scan_run(scan_args(cwd, server.uri(), dry_run)).await; + let mut args = scan_args(cwd, server.uri(), dry_run); + args.common.json = json; + args.common.silent = !json; + let code = scan_run(args).await; for var in ["SOCKET_NPM_REGISTRY", "SOCKET_DRY_RUN"] { std::env::remove_var(var); } @@ -563,18 +571,7 @@ async fn rescan_asks_once(json: bool) { ) .await; mock_view(&server, UUID).await; - std::env::set_var( - "SOCKET_NPM_REGISTRY", - format!("{}/npm-registry", server.uri()), - ); - let mut args = scan_args(tmp.path(), server.uri(), false); - args.common.json = json; - args.common.silent = !json; - let code = scan_run(args).await; - for var in ["SOCKET_NPM_REGISTRY", "SOCKET_DRY_RUN"] { - std::env::remove_var(var); - } - assert_eq!(code, 0); + assert_eq!(scan_with(tmp.path(), &server, false, json).await, 0); assert_eq!(lock(tmp.path()), wired, "the rescan keeps the pin"); assert_eq!( @@ -625,3 +622,28 @@ async fn the_redirect_asks_only_about_uuids_the_sweep_did_not() { vec![vec![UUID.to_string()], vec![UUID2.to_string()]], ); } + +/// An answer that may change within the run (`pending_build`) is asked +/// again by the redirect instead of reused. +#[tokio::test] +#[serial] +async fn a_pending_build_answer_is_asked_again() { + let tmp = tempfile::tempdir().unwrap(); + let (_, wired) = wired_project(tmp.path()).await; + + let server = MockServer::start().await; + mock_search(&server, &[UUID]).await; + mock_references( + &server, + serde_json::json!({ UUID: { "status": "pending_build" } }), + ) + .await; + mock_view(&server, UUID).await; + assert_eq!(scan(tmp.path(), &server, false).await, 0); + + assert_eq!(lock(tmp.path()), wired, "a building patch keeps its pin"); + assert_eq!( + reference_uuids(&server).await, + vec![vec![UUID.to_string()], vec![UUID.to_string()]], + ); +}