Skip to content

Commit 022063d

Browse files
Fix hosted to vendored takeover onto a newer patch (#1105) (#1713)
* Start fix for #1105 Assisted-by: Claude Code:claude-opus-5-5 * Vendor over a hosted pin at an older patch After a hosted scan with patch A and a `uv sync` or `poetry install`, the venv holds A's patched bytes. When a newer patch B for the same release arrived, `scan --mode vendored` / `vendor` dropped the package as "no installed package found on disk" and exited 1, leaving the project hosted on A, while the dry run previewed success. The installed copy is recognised as a superseded install only through the vendor ledger. A live hosted pin at another patch uuid now counts too (for a sole release variant, since the pin names none), so the takeover restores the upstream entry and vendors B from the pristine artifact. Fixes #1105 Assisted-by: Claude Code:claude-opus-5-5 * Cover the requirements.txt lane of #1105 The issue also reproduces in the uv pip requirements.txt lane, which goes through the same check, so the regression test runs it too. Assisted-by: Claude Code:claude-opus-5-5 * Count any hosted pin as a superseded PyPI install A venv synced from an older hosted patch can sit under a lock a later hosted scan already moved to the patch being vendored, and a package can carry hosted pins at two uuids in different files. Treat any live hosted pin as the superseded install, keep the rule to PyPI so gems still refuse a platform gem from their installed dir, and give the download planner the same hosted check as the vendor loop. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0b9169b commit 022063d

2 files changed

Lines changed: 108 additions & 5 deletions

File tree

‎crates/socket-patch-cli/src/commands/vendor.rs‎

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2537,15 +2537,25 @@ impl StagedSource {
25372537
/// neither the pristine release nor this patch's output. The re-vendor
25382538
/// then takes the pristine artifact from the lock / registry / service, as
25392539
/// a lock-only checkout does, instead of reporting it not installed.
2540+
///
2541+
/// A PyPI install under a live hosted pin (`hosted_pinned`) is the same
2542+
/// story after a hosted scan (#1105): `uv sync` / `poetry install` from
2543+
/// the hosted lock installs a hosted patch's bytes, whichever generation
2544+
/// the pin names now. The pin names no release variant, so it only answers
2545+
/// for a sole candidate; gems keep their installed dir, which the gem
2546+
/// backend reads to refuse a platform gem.
25402547
fn superseded_install(
25412548
ledger: &VendorState,
25422549
candidate: &str,
25432550
record: &PatchRecord,
25442551
sole_candidate: bool,
2552+
hosted_pinned: bool,
25452553
) -> bool {
25462554
lookup_entry_kv(&ledger.entries, candidate).is_some_and(|(key, entry)| {
25472555
entry.uuid != record.uuid && (sole_candidate || key == candidate)
2548-
})
2556+
}) || (sole_candidate
2557+
&& hosted_pinned
2558+
&& matches!(Ecosystem::from_purl(candidate), Some(Ecosystem::Pypi)))
25492559
}
25502560

25512561
#[allow(clippy::too_many_arguments)]
@@ -2605,8 +2615,13 @@ async fn plan_service_downloads(
26052615
if !variant_matches_installed(Some(
26062616
&verify_file_patch(dir, file, info).await.status,
26072617
)) {
2608-
if !superseded_install(ledger, candidate, record, candidates.len() == 1)
2609-
{
2618+
if !superseded_install(
2619+
ledger,
2620+
candidate,
2621+
record,
2622+
candidates.len() == 1,
2623+
takeover_blocked(candidate),
2624+
) {
26102625
continue;
26112626
}
26122627
source_path = &uninstalled;
@@ -3093,8 +3108,13 @@ pub(crate) async fn vendor_records_reusing(
30933108
.await
30943109
.status,
30953110
)) {
3096-
if !superseded_install(&state, candidate, record, candidates.len() == 1)
3097-
{
3111+
if !superseded_install(
3112+
&state,
3113+
candidate,
3114+
record,
3115+
candidates.len() == 1,
3116+
hosted_pin_of(candidate).is_some(),
3117+
) {
30983118
continue;
30993119
}
31003120
candidate_source = PackageSource::Installed(&uninstalled);
@@ -4909,6 +4929,28 @@ mod plan_gate_tests {
49094929
}
49104930
}
49114931

4932+
/// #1105: a live hosted pin marks a PyPI install superseded (a hosted
4933+
/// sync installed a hosted patch's bytes), so the takeover vendors the
4934+
/// record instead of reporting it not installed. The pin names no
4935+
/// variant, so only a sole candidate counts, and gems keep their
4936+
/// installed dir for the platform-gem refusal.
4937+
#[test]
4938+
fn hosted_pin_marks_a_pypi_install_superseded() {
4939+
let ledger = VendorState::default();
4940+
let pypi = "pkg:pypi/six@1.16.0";
4941+
let b = record(UUID_B);
4942+
assert!(superseded_install(&ledger, pypi, &b, true, true));
4943+
assert!(!superseded_install(&ledger, pypi, &b, false, true));
4944+
assert!(!superseded_install(&ledger, pypi, &b, true, false));
4945+
assert!(!superseded_install(
4946+
&ledger,
4947+
"pkg:gem/nokogiri@1.16.0",
4948+
&b,
4949+
true,
4950+
true
4951+
));
4952+
}
4953+
49124954
/// The download plan runs every record through its backend's own gate:
49134955
/// a package the backend refuses before its first service call is
49144956
/// never planned, wherever it sits in the loop order. Here the refused

‎crates/socket-patch-cli/tests/mode_migration_pypi.rs‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -995,6 +995,67 @@ async fn poetry_revendors_to_a_superseding_patch_over_a_patched_venv() {
995995
);
996996
}
997997

998+
/// #1105: a project hosted at patch A whose venv was then synced from the
999+
/// hosted lock (`uv sync`, `poetry install`, `uv pip install -r`), so six
1000+
/// holds A's patched bytes, moves to vendored at the superseding patch B.
1001+
/// The installed copy matches neither the pristine release nor B's
1002+
/// output; it used to be dropped as `package_not_installed` (exit 1, the
1003+
/// project left hosted on A) while `scan --mode vendored --dry-run`
1004+
/// previewed success. The takeover must restore the upstream entry and
1005+
/// vendor B.
1006+
#[tokio::test]
1007+
async fn hosted_to_vendored_takes_a_superseding_patch_over_a_hosted_venv() {
1008+
let lanes: [(&str, StageFn); 3] = [
1009+
("uv", stage_uv_with_sibling),
1010+
("poetry", stage_poetry),
1011+
("requirements", stage_requirements),
1012+
];
1013+
for (lane, stage) in lanes {
1014+
let (_tmp, root) = project();
1015+
let files = stage(&root);
1016+
let server = MockServer::start().await;
1017+
let hosted_url = mount_hosted_api(&server, true).await;
1018+
let (code, env) = hosted_scan(&root, &server);
1019+
assert_eq!(code, 0, "{lane}: hosted scan at A: {env:#}");
1020+
assert!(
1021+
files.iter().any(|f| std::fs::read_to_string(root.join(f))
1022+
.unwrap()
1023+
.contains(&hosted_url)),
1024+
"{lane}: hosted mode pins A: {env:#}"
1025+
);
1026+
1027+
let venv = root.join("../hosted-venv");
1028+
venv_installed_from_patch_a(&venv);
1029+
stage_manifest_with(&root, UUID_B, PATCHED_B);
1030+
prebuilt_common::mount_project(&server, &root).await;
1031+
mount_pypi_json(&server).await;
1032+
let uri = server.uri();
1033+
let pypi = format!("{uri}/pypi");
1034+
let extra = [
1035+
("VIRTUAL_ENV", venv.to_str().unwrap()),
1036+
("SOCKET_PYPI_JSON_API", pypi.as_str()),
1037+
];
1038+
let args = ["vendor", "--patch-server-url", &uri, "--vendor-url", &uri];
1039+
1040+
let (code, env) = run_cli(&root, &args, &extra);
1041+
assert_eq!(code, 0, "{lane}: takeover onto B: {env:#}");
1042+
assert!(
1043+
!env.to_string().contains("package_not_installed"),
1044+
"{lane}: the installed copy is not reported missing: {env:#}"
1045+
);
1046+
let wired: String = files
1047+
.iter()
1048+
.map(|f| std::fs::read_to_string(root.join(f)).unwrap())
1049+
.collect();
1050+
assert!(
1051+
wired.contains(&format!(".socket/vendor/pypi/{UUID_B}/"))
1052+
&& !wired.contains(&hosted_url)
1053+
&& !wired.contains(UUID),
1054+
"{lane}: wired to vendored B, hosted A gone:\n{wired}"
1055+
);
1056+
}
1057+
}
1058+
9981059
const UV_LOCK: &str = r#"version = 1
9991060
revision = 2
10001061
requires-python = ">=3.9"

0 commit comments

Comments
 (0)