From 0f69bbb6708085711b41ca843374f4d23897edb8 Mon Sep 17 00:00:00 2001 From: Jacob Heider Date: Wed, 22 Jul 2026 14:12:49 -0400 Subject: [PATCH] Allow multi-version openssl/abseil alongside unicode Generalize the unicode.org hydrate special-case so parallel-installable ABI lines (openssl 1.1 vs 3, abseil LTS namespaces) can coexist in one graph instead of failing constraint intersection. --- src/plumbing/hydrate.test.ts | 140 +++++++++++++++++++++++++++++++++-- src/plumbing/hydrate.ts | 70 +++++++++++++++--- 2 files changed, 194 insertions(+), 16 deletions(-) diff --git a/src/plumbing/hydrate.test.ts b/src/plumbing/hydrate.test.ts index b165508..67fffdb 100644 --- a/src/plumbing/hydrate.test.ts +++ b/src/plumbing/hydrate.test.ts @@ -1,8 +1,15 @@ -import { assert, assertEquals, assertRejects } from "@std/assert" -import { describe, it } from "@std/testing/bdd" +import { assert, assertEquals, assertRejects, assertThrows } from "@std/assert" +// pin: import-map bare `@std/testing/bdd` does not resolve under current Deno +import { describe, it } from "jsr:@std/testing@1.0.3/bdd" import { PackageRequirement } from "../types.ts" import * as semver from "../utils/semver.ts" -import hydrate from "./hydrate.ts" +import hydrate, { MULTI_VERSION_PROJECTS } from "./hydrate.ts" + +function constraintSet(pkgs: PackageRequirement[], project: string) { + return new Set( + pkgs.filter(p => p.project === project).map(p => p.constraint.toString()) + ) +} describe("hydrate()", () => { it("hydrates.1", async function() { @@ -99,8 +106,7 @@ describe("hydrate()", () => { } }) - const unicodes = rv.pkgs.filter(x => x.project === 'unicode.org') - const constraints = new Set(unicodes.map(x => x.constraint.toString())) + const constraints = constraintSet(rv.pkgs, 'unicode.org') assertEquals(constraints.size, 2) assert(constraints.has("^71")) assert(constraints.has("^73")) @@ -126,4 +132,128 @@ describe("hydrate()", () => { await assertRejects(() => rv) }) + + it("hydrates.multi-version.allowlist", function() { + assert(MULTI_VERSION_PROJECTS.has('unicode.org')) + assert(MULTI_VERSION_PROJECTS.has('openssl.org')) + assert(MULTI_VERSION_PROJECTS.has('abseil.io')) + assert(!MULTI_VERSION_PROJECTS.has('nodejs.org')) + }) + + it("hydrates.openssl.org", async function() { + // python locks ^1.1; cryptography needs ^3 — must coexist + const pkgs = [ + { project: 'python.org', constraint: new semver.Range('*') }, + { project: 'cryptography.io', constraint: new semver.Range('*') } + ] + + const rv = await hydrate(pkgs, (pkg: PackageRequirement, _dry: boolean) => { + if (pkg.project === 'python.org') { + return Promise.resolve([ + { project: 'openssl.org', constraint: new semver.Range('^1.1') } + ]) + } else if (pkg.project === 'cryptography.io') { + return Promise.resolve([ + { project: 'openssl.org', constraint: new semver.Range('^3') } + ]) + } else { + return Promise.resolve([]) + } + }) + + const constraints = constraintSet(rv.pkgs, 'openssl.org') + assertEquals(constraints.size, 2) + assert(constraints.has("^1.1")) + assert(constraints.has("^3")) + // the two lines remain disjoint + assertThrows(() => + semver.intersect(new semver.Range("^1.1"), new semver.Range("^3")) + ) + }) + + it("hydrates.abseil.io", async function() { + const pkgs = [ + { project: 'github.com/google/re2', constraint: new semver.Range('*') }, + { project: 'grpc.io', constraint: new semver.Range('*') } + ] + + const rv = await hydrate(pkgs, (pkg: PackageRequirement, _dry: boolean) => { + if (pkg.project === 'github.com/google/re2') { + return Promise.resolve([ + { project: 'abseil.io', constraint: new semver.Range('^20250127') } + ]) + } else if (pkg.project === 'grpc.io') { + return Promise.resolve([ + { project: 'abseil.io', constraint: new semver.Range('>=20250512') } + ]) + } else { + return Promise.resolve([]) + } + }) + + const constraints = constraintSet(rv.pkgs, 'abseil.io') + assertEquals(constraints.size, 2) + assert(constraints.has("^20250127")) + assert(constraints.has(">=20250512")) + }) + + it("hydrates.multi-version.dry-input", async function() { + // explicit +openssl^1.1 +openssl^3 + const pkgs = [ + { project: 'openssl.org', constraint: new semver.Range('^1.1') }, + { project: 'openssl.org', constraint: new semver.Range('^3') } + ] + + const rv = await hydrate(pkgs, () => Promise.resolve([])) + + const constraints = constraintSet(rv.pkgs, 'openssl.org') + assertEquals(constraints.size, 2) + assert(constraints.has("^1.1")) + assert(constraints.has("^3")) + assertEquals(constraintSet(rv.dry, 'openssl.org').size, 2) + }) + + it("hydrates.multi-version.three-way", async function() { + // cryptography first so ^3 is the graph node; both ^1.1 merge into one additional + const pkgs = [ + { project: 'cryptography.io', constraint: new semver.Range('*') }, + { project: 'python.org', constraint: new semver.Range('*') }, + { project: 'curl.se', constraint: new semver.Range('*') } + ] + + const rv = await hydrate(pkgs, (pkg: PackageRequirement, _dry: boolean) => { + if (pkg.project === 'python.org' || pkg.project === 'curl.se') { + return Promise.resolve([ + { project: 'openssl.org', constraint: new semver.Range('^1.1') } + ]) + } else if (pkg.project === 'cryptography.io') { + return Promise.resolve([ + { project: 'openssl.org', constraint: new semver.Range('^3') } + ]) + } else { + return Promise.resolve([]) + } + }) + + const constraints = constraintSet(rv.pkgs, 'openssl.org') + assertEquals(constraints.size, 2) + assert(constraints.has("^1.1")) + assert(constraints.has("^3")) + }) + + it("hydrates.multi-version.dry-condense-compatible", async function() { + const pkgs = [ + { project: 'openssl.org', constraint: new semver.Range('^1.1') }, + { project: 'openssl.org', constraint: new semver.Range('>=1.1.1') } + ] + + const rv = await hydrate(pkgs, () => Promise.resolve([])) + + // compatible ranges collapse to a single openssl line (still on 1.x) + const openssles = rv.pkgs.filter(p => p.project === 'openssl.org') + assertEquals(openssles.length, 1) + assertThrows(() => + semver.intersect(openssles[0].constraint, new semver.Range("^3")) + ) + }) }) diff --git a/src/plumbing/hydrate.ts b/src/plumbing/hydrate.ts index dc1f471..0174491 100644 --- a/src/plumbing/hydrate.ts +++ b/src/plumbing/hydrate.ts @@ -12,6 +12,18 @@ const { isArray } = is_what //FIXME actually we are not refining the constraints currently //TODO we are not actually restricting subsequent asks, eg. deno^1 but then deno^1.2 +/// Projects whose distinct version lines are parallel-installable (different +/// sonames / ICU majors / abseil LTS namespaces). When constraints cannot +/// intersect we keep multiple nodes instead of failing the graph. +/// +/// - unicode.org: ICU major ABI (see pantry#4104, pkgx#899) +/// - openssl.org: libssl.so.1.1 vs libssl.so.3 +/// - abseil.io: LTS inline-namespace + soversion (20250127 vs 20250512, …) +export const MULTI_VERSION_PROJECTS = new Set([ + "unicode.org", + "openssl.org", + "abseil.io", +]) interface ReturnValue { /// full list topologically sorted (ie dry + wet) @@ -55,14 +67,34 @@ export default async function hydrate( const initial_set = new Set(dry.map(x => x.project)) const stack: Node[] = [] - const additional_unicodes: semver.Range[] = [] + // extra constraints for multi-version projects that could not intersect + const additional: PackageRequirement[] = [] + + const pushAdditional = (pkg: PackageRequirement) => { + for (const existing of additional.filter(p => p.project === pkg.project)) { + try { + existing.constraint = semver.intersect(existing.constraint, pkg.constraint) + return + } catch { + // try next sibling line + } + } + additional.push(pkg) + } // Starting the DFS loop for each package in the dry list for (const pkg of dry) { let new_node = graph[pkg.project] if (new_node) { - // Intersect constraints for existing nodes - new_node.pkg.constraint = semver.intersect(new_node.pkg.constraint, pkg.constraint) + try { + new_node.pkg.constraint = semver.intersect(new_node.pkg.constraint, pkg.constraint) + } catch (e) { + if (MULTI_VERSION_PROJECTS.has(pkg.project)) { + pushAdditional(pkg) + continue + } + throw e + } } else { new_node = new Node(pkg) graph[pkg.project] = new_node @@ -86,11 +118,9 @@ export default async function hydrate( // Intersect constraints child_node.pkg.constraint = semver.intersect(child_node.pkg.constraint, dep.constraint) } catch (e) { - if (dep.project == 'unicode.org') { - // we handle unicode.org for now to allow situations like: - // https://github.com/pkgxdev/pantry/issues/4104 - // https://github.com/pkgxdev/pkgx/issues/899 - additional_unicodes.push(dep.constraint) + if (MULTI_VERSION_PROJECTS.has(dep.project)) { + // keep both version lines; bottles/rpaths disambiguate at runtime + pushAdditional(dep) } else { throw e } @@ -111,8 +141,7 @@ export default async function hydrate( .sort((a, b) => b.count() - a.count()) .map(({pkg}) => pkg) - // see above explanation - pkgs.push(...additional_unicodes.map(constraint => ({ project: "unicode.org", constraint }))) + pkgs.push(...additional) //TODO strictly we need to record precisely the bootstrap version constraint const bootstrap_required = new Set(pkgs.compact(({project}) => bootstrap.has(project) && project)) @@ -130,7 +159,26 @@ function condense(pkgs: PackageRequirement[]) { for (const pkg of pkgs) { const found = out.find(x => x.project === pkg.project) if (found) { - found.constraint = semver.intersect(found.constraint, pkg.constraint) + try { + found.constraint = semver.intersect(found.constraint, pkg.constraint) + } catch (e) { + if (MULTI_VERSION_PROJECTS.has(pkg.project)) { + // merge into a later non-intersecting sibling if possible + let merged = false + for (const sibling of out.filter(p => p.project === pkg.project).slice(1)) { + try { + sibling.constraint = semver.intersect(sibling.constraint, pkg.constraint) + merged = true + break + } catch { + // try next + } + } + if (!merged) out.push(pkg) + } else { + throw e + } + } } else { out.push(pkg) }