diff --git a/mcpp.toml b/mcpp.toml index f016cfc..0bfd66c 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -1,7 +1,7 @@ [package] namespace = "mcpplibs" name = "xpkg" -version = "0.0.49" +version = "0.0.50" description = "C++23 reference implementation of the xpkg V2 spec (multi-arch)" license = "Apache-2.0" repo = "https://github.com/openxlings/libxpkg" diff --git a/src/lua-stdlib/xim/libxpkg/elfpatch.lua b/src/lua-stdlib/xim/libxpkg/elfpatch.lua index 4e9996f..aa5070d 100644 --- a/src/lua-stdlib/xim/libxpkg/elfpatch.lua +++ b/src/lua-stdlib/xim/libxpkg/elfpatch.lua @@ -608,17 +608,37 @@ function M.closure_lib_paths(opt) or (_RUNTIME and (_RUNTIME.runtime_deps_list or _RUNTIME.deps_list)) or {} local deps_exports = _RUNTIME and _RUNTIME.deps_exports or {} + -- Two sources, both already ABSOLUTE and both decided by the resolver: + -- what the dep declared (deps_exports), else what the resolver filled in + -- by convention (resolved_deps.libdirs). + -- + -- What used to be here is gone: a branch that took the dep's NAME, asked + -- pkginfo to find it again, and tried {lib64, lib} against whatever came + -- back. That was a second, independent resolution — and with two versions + -- of one package installed it answered differently from the one that + -- chose the interpreter, producing a binary whose loader and libc came + -- from different payloads. It segfaults before main, and the error names + -- a GLIBC_PRIVATE symbol rather than anything about versions. + -- + -- The convention itself did not go away; it moved to the single place + -- that is entitled to apply it. See + -- xlings/.agents/docs/2026-08-05-dependency-resolution-single-source.md + local resolved = (_RUNTIME and type(_RUNTIME.resolved_deps) == "table") + and _RUNTIME.resolved_deps or {} for _, dep_spec in ipairs(deps_list) do local declared = deps_exports[dep_spec] + local rec = resolved[dep_spec] if declared and declared.libdirs and #declared.libdirs > 0 then for _, d in ipairs(declared.libdirs) do _push(d) end - else + elseif rec and rec.libdirs and #rec.libdirs > 0 then + for _, d in ipairs(rec.libdirs) do _push(d) end + elseif _LIBXPKG_MODULES and _LIBXPKG_MODULES.pkginfo then + -- Only a client that predates resolved_deps reaches this. Kept so + -- an older xlings keeps working, and warned so the degraded path + -- is never silent. local dep_name = dep_spec:gsub("@.*", ""):gsub("^.+:", "") local dep_version = dep_spec:find("@", 1, true) and dep_spec:match("@(.+)") or nil - local dep_dir - if _LIBXPKG_MODULES and _LIBXPKG_MODULES.pkginfo then - dep_dir = _LIBXPKG_MODULES.pkginfo.dep_install_dir(dep_name, dep_version) - end + local dep_dir = _LIBXPKG_MODULES.pkginfo.dep_install_dir(dep_name, dep_version) if dep_dir then for _, sub in ipairs({"lib64", "lib"}) do local libdir = path.join(dep_dir, sub) diff --git a/src/lua-stdlib/xim/libxpkg/pkginfo.lua b/src/lua-stdlib/xim/libxpkg/pkginfo.lua index 22b2454..56b4d3c 100644 --- a/src/lua-stdlib/xim/libxpkg/pkginfo.lua +++ b/src/lua-stdlib/xim/libxpkg/pkginfo.lua @@ -192,10 +192,61 @@ local function _resolve_dep_via_xvm(dep_name, dep_version) return nil end +-- The resolver's record for a dependency, if this client sends one. +-- +-- type(), not truthiness: an unknown _RUNTIME field is nil here, but the same +-- probe written as `if _RUNTIME.resolved_deps then` on a module proxy is true +-- everywhere — a trap this repo has fallen into twice (subos.env, +-- xim.pkgindex.sysroot). +-- +-- Matched by spec first, because that is the key; then by bare name, because +-- callers reach this function from several directions and not all of them +-- still have the original spec string in hand. +function M.resolved_dep(dep_name, dep_version) + local t = _RUNTIME and _RUNTIME.resolved_deps + if type(t) ~= "table" then return nil end + if dep_version and dep_version ~= "" then + local exact = t[dep_name .. "@" .. dep_version] + if exact then return exact end + end + local _, bare = _parse_namespace(dep_name) + for spec, rec in pairs(t) do + local sname = spec:gsub("@.*", "") + local _, sbare = _parse_namespace(sname) + if sname == dep_name or sbare == bare then return rec end + end + return nil +end + +-- Where a dependency actually lives. +-- +-- The resolver already decided this. Everything below the first branch is a +-- SECOND answer to a question that has one — kept only for callers with no +-- install context (tool scripts, offline queries), and noisy on purpose so +-- that "we guessed" is never silent. +-- +-- Two independent answers is exactly how a binary ends up with its INTERP +-- from one glibc and its RUNPATH from another, which segfaults before main +-- with no diagnostic. See +-- xlings/.agents/docs/2026-08-05-dependency-resolution-single-source.md function M.dep_install_dir(dep_name, dep_version) + local rec = M.resolved_dep(dep_name, dep_version) + if rec and rec.install_dir and rec.install_dir ~= "" then + return rec.install_dir + end + local result = _resolve_dep_via_scan(dep_name, dep_version) - if result then return result end - return _resolve_dep_via_xvm(dep_name, dep_version) + if not result then + result = _resolve_dep_via_xvm(dep_name, dep_version) + end + local log = _get_log() + if log and _RUNTIME and _RUNTIME.install_dir then + -- Inside an install, a miss means the client predates resolved_deps. + -- Outside one there is nothing to miss, so no warning. + log.warn("dep_install_dir(%s): no resolver record, fell back to a " + .. "scan -> %s", tostring(dep_name), tostring(result)) + end + return result end function M.install_dir(pkgname, pkgversion) diff --git a/src/xpkg-executor.cppm b/src/xpkg-executor.cppm index 4d58841..6424fe1 100644 --- a/src/xpkg-executor.cppm +++ b/src/xpkg-executor.cppm @@ -22,6 +22,26 @@ struct DepExport { std::string abi; // e.g. "linux-x86_64-glibc" }; +// One runtime dependency, as the RESOLVER settled it — not as the recipe +// spelled it. +// +// This is the record that makes "which version is this dependency" have one +// answer. `DepExport` below cannot serve: it carries only what a dep +// explicitly declared, so a dep that declared nothing is absent from it, and +// absence was defined to mean "fall back to convention" — which is a second +// answerer wearing a different hat. Every runtime dep appears here, declared +// or not. +struct ResolvedDep { + std::string spec; // the recipe's own text, e.g. "xim:glibc@>=2.38" + std::string name; // canonical, e.g. "xim:glibc" + std::string version; // what it resolved TO, e.g. "2.44" + std::string install_dir; // absolute payload directory — the authority + std::vector libdirs; // absolute; convention-filled when the + // dep declared none, so no consumer + // has to re-derive it + std::string source; // why this one: "plan" | "pinned-active" | ... +}; + struct ExecutionContext { std::string pkg_name, version, platform, arch; fs::path install_file, install_dir; @@ -38,6 +58,10 @@ struct ExecutionContext { // deps that actually declare exports show up; missing entries mean // "this dep declared nothing — fall back to convention". std::unordered_map deps_exports; + // Keyed by the same spec string as deps_exports, but TOTAL: every runtime + // dep is here whether or not it declared exports. Empty only when the + // client predates it — libxpkg then degrades to scanning and says so. + std::unordered_map resolved_deps; // The current package's own exports (rule 2 in the predicate trigger). DepExport self_exports; std::string subos_sysrootdir; @@ -641,6 +665,22 @@ void inject_context(lua::State* L, const mcpplibs::xpkg::ExecutionContext& ctx) } lua::setfield(L, -2, "deps_exports"); + // resolved_deps: { [spec] = { name, version, install_dir, libdirs, source } } + // Total, unlike deps_exports. A hook that finds a dep missing from HERE is + // running on a client that does not send it, not looking at a dep that + // declared nothing — the two used to be indistinguishable. + lua::newtable(L); + for (auto& [dep_spec, r] : ctx.resolved_deps) { + lua::newtable(L); + set_string_field(L, "name", r.name); + set_string_field(L, "version", r.version); + set_string_field(L, "install_dir", r.install_dir); + set_string_field(L, "source", r.source); + push_string_array(r.libdirs, "libdirs"); + lua::setfield(L, -2, dep_spec.c_str()); + } + lua::setfield(L, -2, "resolved_deps"); + // self_exports: same shape as a single deps_exports entry. Empty // strings/arrays when the current package didn't declare exports. lua::newtable(L);