diff --git a/hooks/backend_install.lua b/hooks/backend_install.lua index b17cb84..98c4d7d 100644 --- a/hooks/backend_install.lua +++ b/hooks/backend_install.lua @@ -47,9 +47,10 @@ function PLUGIN:BackendInstall(ctx) -- The delegated shiv task may itself run nested `mise install` for package -- dependencies. On macOS mise 2026.6.0, repeated/concurrent nested installs -- can race or fail while rebuilding runtime symlinks. Serialize this shared - -- delegated phase while keeping each package's SHIV_* install paths isolated. + -- delegated phase, but allow descendant shiv installs from the same package + -- install to reenter the lock. local install_lock_path = shiv_path .. ".install.lock" - with_lock(install_lock_path, "VFOX_SHIV_INSTALL_LOCK_MAX_ATTEMPTS", "shiv package install", function() + with_lock(install_lock_path, "VFOX_SHIV_INSTALL_LOCK_MAX_ATTEMPTS", "shiv package install", function(lock_owner) -- Sync remote sources.json into bundled shiv so it knows about new packages. -- Keep this inside the install lock because every package install writes the -- same bundled sources.json file. @@ -64,7 +65,7 @@ function PLUGIN:BackendInstall(ctx) local mise_bin = find_mise() local quoted_mise = Shell.quote(mise_bin) local quoted_shiv_path = Shell.quote(shiv_path) - local install_cmd = env_prefix .. shiv_mise_env() .. quoted_mise .. " -C " .. quoted_shiv_path .. + local install_cmd = env_prefix .. shiv_mise_env() .. install_lock_env(lock_owner) .. quoted_mise .. " -C " .. quoted_shiv_path .. " exec --no-deps -- " .. quoted_mise .. " -C " .. quoted_shiv_path .. " run --skip-tools -q install " .. Shell.quote(tool_spec) @@ -220,11 +221,22 @@ function numeric_parts(version) end --- Run a callback under a directory lock, reclaiming dead-holder locks. +--- +--- The shiv install task runs package-local `mise install` for dependencies. +--- Those dependency installs can include more shiv:* tools, so this lock must be +--- reentrant for descendants of the process that already holds it. The holder +--- writes an owner token into the lock directory and passes that token to nested +--- mise commands via VFOX_SHIV_INSTALL_LOCK_OWNER. --- @param lock_path string --- @param max_attempts_env string --- @param lock_name string --- @param callback function function with_lock(lock_path, max_attempts_env, lock_name, callback) + local inherited_owner = inherited_lock_owner(lock_path) + if inherited_owner then + return callback(inherited_owner) + end + local cmd = require("cmd") local parent_dir = lock_path:match("(.+)/[^/]+$") if parent_dir then @@ -254,7 +266,17 @@ function with_lock(lock_path, max_attempts_env, lock_name, callback) ) end - local ok, result = pcall(callback) + local owner = new_lock_owner() + local owner_ok, owner_err = pcall(cmd.exec, + "printf '%s' " .. Shell.quote(owner) .. " > " .. Shell.quote(lock_owner_path(lock_path))) + if not owner_ok then + Lock.release(lock_path) + error("Failed to write " .. lock_name .. " lock owner: " .. Errors.clean_error(tostring(owner_err))) + end + + local ok, result = pcall(function() + return callback(owner) + end) Lock.release(lock_path) if not ok then error(result) @@ -262,6 +284,49 @@ function with_lock(lock_path, max_attempts_env, lock_name, callback) return result end +--- Return the env prefix propagated to nested shiv dependency installs. +--- @param lock_owner string +--- @return string +function install_lock_env(lock_owner) + return "VFOX_SHIV_INSTALL_LOCK_OWNER=" .. Shell.quote(lock_owner) .. " MISE_JOBS=1 " +end + +--- Return the inherited lock owner when it matches the current lock directory. +--- @param lock_path string +--- @return string|nil +function inherited_lock_owner(lock_path) + local owner = trim(os.getenv("VFOX_SHIV_INSTALL_LOCK_OWNER") or "") + if owner == "" then + return nil + end + + local cmd = require("cmd") + local ok, current = pcall(cmd.exec, "cat " .. Shell.quote(lock_owner_path(lock_path)) .. " 2>/dev/null") + if ok and trim(current) == owner then + return owner + end + return nil +end + +--- Path of the owner token inside a lock directory. +--- @param lock_path string +--- @return string +function lock_owner_path(lock_path) + return lock_path .. "/owner" +end + +--- Create a best-effort unique owner token for reentrant install locking. +--- @return string +function new_lock_owner() + local cmd = require("cmd") + local ok, token = pcall(cmd.exec, + "printf '%s:%s:%s' \"$PPID\" \"$$\" \"$(date +%s%N 2>/dev/null || date +%s)\"") + if ok and token and trim(token) ~= "" then + return trim(token) + end + return tostring(os.time()) .. ":" .. tostring(math.random(1000000000)) +end + --- Sync the bundled shiv's sources.json with the remote version. --- Uses the same cached remote sources as backend_list_versions. --- Falls back silently if fetch fails (bundled sources still work). diff --git a/test/lua/backend_install_test.lua b/test/lua/backend_install_test.lua index 67c080a..efbe1db 100644 --- a/test/lua/backend_install_test.lua +++ b/test/lua/backend_install_test.lua @@ -49,6 +49,159 @@ test("dependency install reports both inherited and scrubbed failures", function end) end) +test("install lock env propagates owner and serializes nested mise", function() + load_backend_install({ exec = function() return "" end }) + + local env = install_lock_env("owner-token") + assert_contains(env, "VFOX_SHIV_INSTALL_LOCK_OWNER='owner-token'") + assert_contains(env, "MISE_JOBS=1 ") +end) + +test("with_lock passes owner token to callback", function() + local commands = {} + local cmd_module = { + exec = function(command) + table.insert(commands, command) + if command:find("^printf '%%s:%%s:%%s'") then + return "owner-token\n" + end + return "" + end, + } + load_backend_install(cmd_module) + + local callback_owner + local result + with_env({ VFOX_SHIV_INSTALL_LOCK_OWNER = false }, function() + result = with_lock("/tmp/vfox-shiv-install.lock", "VFOX_TEST_LOCK_MAX_ATTEMPTS", "test install", function(owner) + callback_owner = owner + return "done" + end) + end) + + assert_equal(result, "done") + assert_equal(callback_owner, "owner-token") + local log = table.concat(commands, "\n") + assert_contains(log, "mkdir '/tmp/vfox-shiv-install.lock' 2>/dev/null") + assert_contains(log, "printf '%s' 'owner-token' > '/tmp/vfox-shiv-install.lock/owner'") + assert_contains(log, "rm -rf '/tmp/vfox-shiv-install.lock'") +end) + +test("with_lock is reentrant for matching inherited owner", function() + local commands = {} + local cmd_module = { + exec = function(command) + table.insert(commands, command) + if command:find("^cat '/tmp/vfox%-shiv%-install%.lock/owner'") then + return "owner-token\n" + end + error("unexpected command: " .. command) + end, + } + load_backend_install(cmd_module) + + local callback_owner + local result + with_env({ VFOX_SHIV_INSTALL_LOCK_OWNER = "owner-token" }, function() + result = with_lock("/tmp/vfox-shiv-install.lock", "VFOX_TEST_LOCK_MAX_ATTEMPTS", "test install", function(owner) + callback_owner = owner + return "nested-done" + end) + end) + + assert_equal(result, "nested-done") + assert_equal(callback_owner, "owner-token") + assert_equal(#commands, 1) + assert_contains(commands[1], "cat '/tmp/vfox-shiv-install.lock/owner' 2>/dev/null") +end) + +test("with_lock allows nested callbacks from same owner", function() + local commands = {} + local cmd_module = { + exec = function(command) + table.insert(commands, command) + if command:find("^printf '%%s:%%s:%%s'") then + return "owner-token\n" + end + if command:find("^cat '/tmp/vfox%-shiv%-install%.lock/owner'") then + return "owner-token\n" + end + return "" + end, + } + load_backend_install(cmd_module) + + local nested_owner + with_env({ VFOX_SHIV_INSTALL_LOCK_OWNER = false }, function() + with_lock("/tmp/vfox-shiv-install.lock", "VFOX_TEST_LOCK_MAX_ATTEMPTS", "test install", function(owner) + with_env({ VFOX_SHIV_INSTALL_LOCK_OWNER = owner }, function() + with_lock("/tmp/vfox-shiv-install.lock", "VFOX_TEST_LOCK_MAX_ATTEMPTS", "test install", function(owner_from_nested) + nested_owner = owner_from_nested + end) + end) + end) + end) + + assert_equal(nested_owner, "owner-token") + local log = table.concat(commands, "\n") + assert_contains(log, "cat '/tmp/vfox-shiv-install.lock/owner' 2>/dev/null") + assert_contains(log, "rm -rf '/tmp/vfox-shiv-install.lock'") +end) + +test("BackendInstall lets delegated dependency installs reenter lock", function() + local nested_owner + local saw_delegated_install = false + local commands = {} + local cmd_module = { + exec = function(command) + table.insert(commands, command) + if command == "command -v mise" then + return "/bin/mise\n" + end + if command:find("^git %-C '/tmp/shiv' describe") then + return "v0.3.2\n" + end + if command:find("^printf '%%s:%%s:%%s'") then + return "owner-token\n" + end + if command:find("^cat '/tmp/shiv%.install%.lock/owner'") then + return "owner-token\n" + end + if command:find("run %-%-skip%-tools %-q install 'emails@v0%.6%.2'") then + saw_delegated_install = true + assert_contains(command, "VFOX_SHIV_INSTALL_LOCK_OWNER='owner-token'") + assert_contains(command, "MISE_JOBS=1") + with_env({ VFOX_SHIV_INSTALL_LOCK_OWNER = "owner-token" }, function() + with_lock("/tmp/shiv.install.lock", "VFOX_TEST_LOCK_MAX_ATTEMPTS", "test install", function(owner) + nested_owner = owner + end) + end) + return "" + end + return "" + end, + } + local previous_file = package.loaded["file"] + package.loaded["file"] = { + exists = function(path) + return path == "/tmp/shiv/.git/HEAD" or path == "/tmp/shiv/.vfox-shiv-deps-ready" + end, + } + load_backend_install(cmd_module) + + with_env({ VFOX_SHIV_PATH = "/tmp/shiv", VFOX_SHIV_INSTALL_LOCK_OWNER = false }, function() + PLUGIN:BackendInstall({ tool = "emails", version = "0.6.2", install_path = "/tmp/install" }) + end) + package.loaded["file"] = previous_file + + assert_truthy(saw_delegated_install) + assert_equal(nested_owner, "owner-token") + local log = table.concat(commands, "\n") + assert_contains(log, "printf '%s' 'owner-token' > '/tmp/shiv.install.lock/owner'") + assert_contains(log, "cat '/tmp/shiv.install.lock/owner' 2>/dev/null") + assert_contains(log, "rm -rf '/tmp/shiv.install.lock'") +end) + test("numeric minor install ref resolves to newest matching patch", function() load_backend_install({ exec = function() return "" end })