From 66e5edc168d7e2361cc40eec0c46d7f478ef2a30 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:17:11 -0400 Subject: [PATCH 1/4] chore(hooks): install dependencies at session start and worktree entry Fresh cloud clones and fresh git worktrees start without node_modules. A committed SessionStart hook and a PostToolUse hook on EnterWorktree run install-deps.sh, which loads nvm and runs npm ci only when the checkout has no node_modules, or when the hook's own last install used a different package-lock.json. Co-Authored-By: Claude Opus 5.5 --- .claude/hooks/install-deps.sh | 202 ++++++++++++++++++++++++++++++++++ .claude/settings.json | 28 +++++ .gitignore | 9 ++ AGENTS.md | 4 + 4 files changed, 243 insertions(+) create mode 100755 .claude/hooks/install-deps.sh create mode 100644 .claude/settings.json diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh new file mode 100755 index 0000000..9f3514e --- /dev/null +++ b/.claude/hooks/install-deps.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Ensures the current checkout's dependencies are installed. Registered on +# SessionStart and on PostToolUse for EnterWorktree, because fresh cloud clones +# and fresh git worktrees start without node_modules. +set -euo pipefail + +# Logs go to stderr because SessionStart hook stdout enters the model's context. +log() { echo "[install-deps] $*" >&2; } + +# A hook's non-interactive shell never sources .bashrc, so nvm must be loaded +# here; without it, npm can resolve to a system Node (cloud images ship +# Node 22 at /opt/node22). Sourcing nvm.sh activates the default alias. +# Cloud setup scripts run with HOME=/root, hence the second candidate. +for dir in "${HOME}/.nvm" /root/.nvm; do + if [[ -s "${dir}/nvm.sh" ]]; then + export NVM_DIR="${dir}" + # shellcheck disable=SC1091 + . "${dir}/nvm.sh" + break + fi +done + +# The payload cwd follows the session into a worktree; CLAUDE_PROJECT_DIR +# stays at the original project root, so it is only the fallback. +payload_cwd="$(node -e 'let d="";process.stdin.on("data",(c)=>d+=c).on("end",()=>process.stdout.write(JSON.parse(d).cwd??""))' 2>/dev/null || true)" +checkout="$(git -C "${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}" rev-parse --show-toplevel 2>/dev/null)" || { + log "no git checkout resolved from '${payload_cwd:-}' — skipping" + exit 0 +} + +# The marker, stamp, and lock all live in .claude/. A worktree entered from a +# commit that predates this hook has no .claude/, and every mkdir lock attempt +# would fail there as if another session held the lock. +mkdir -p "${checkout}/.claude" + +# The marker survives an interrupted npm ci (a hook-timeout kill included), so +# a partial node_modules is retried instead of trusted. It lives outside +# node_modules because npm ci deletes that directory before installing. +marker="${checkout}/.claude/.install-deps-incomplete" + +# The stamp records which package-lock.json the hook's own last install used, +# so a stamped checkout reinstalls after a pull changes the lockfile. An +# unstamped checkout (node_modules installed by the developer, not the hook) +# is trusted as-is — the hook must never wipe an install it does not own. +stamp="${checkout}/.claude/.install-deps-lockhash" +lockfile_hash="$(git -C "${checkout}" hash-object package-lock.json 2>/dev/null || true)" + +if [[ -d "${checkout}/node_modules" && ! -f "${marker}" ]]; then + stamped="$(cat "${stamp}" 2>/dev/null || true)" + if [[ -z "${stamped}" || "${stamped}" == "${lockfile_hash}" ]]; then + log "node_modules present in ${checkout} — nothing to do" + exit 0 + fi + log "package-lock.json changed since the hook's last install in ${checkout} — reinstalling" +fi + +# A hook killed by timeout can leave the marker even though its orphaned npm ci +# finished the install. A tree that passes npm ls is complete — clear the +# marker and stamp it instead of rebuilding it. +if [[ -d "${checkout}/node_modules" && -f "${marker}" ]]; then + if npm --prefix "${checkout}" ls --depth=0 >/dev/null 2>&1; then + rm -f "${marker}" + # The orphaned install was still the hook's own, so it is stamped like the + # normal success path. Leaving it unstamped would disable the + # lockfile-change guard for this checkout forever. + if [[ -n "${lockfile_hash}" ]]; then + printf '%s\n' "${lockfile_hash}" > "${stamp}" + fi + log "marker left by an interrupted hook but the dependency tree in ${checkout} is complete — clearing" + exit 0 + fi +fi + +# mkdir is the portable atomic lock (flock is Linux-only). The lock records its +# owner's pid. +# - A live owner means a concurrent install is running, so this session waits +# for it rather than race npm ci. +# - A dead owner (hook killed mid-install) is taken over immediately, so the +# marker's retry is never blocked behind an orphaned lock. +lock="${checkout}/.claude/.install-deps.lock" +if mkdir "${lock}" 2>/dev/null; then + echo "$$" > "${lock}/pid" +else + owner="$(cat "${lock}/pid" 2>/dev/null || true)" + if [[ -n "${owner}" ]] && kill -0 "${owner}" 2>/dev/null; then + # Waiting for the live install lets this session start with dependencies + # instead of racing a tree npm ci is actively rewriting. The 480s bound + # sits well inside the 600s hook timeout in settings.json. + log "another session (pid ${owner}) is installing in ${checkout} — waiting for it" + waited=0 + while kill -0 "${owner}" 2>/dev/null && ((waited < 480)); do + sleep 5 + waited=$((waited + 5)) + done + if [[ -d "${checkout}/node_modules" && ! -f "${marker}" ]]; then + log "concurrent install finished in ${checkout} — nothing to do" + exit 0 + fi + if ((waited >= 480)); then + log "concurrent install still running after ${waited}s in ${checkout} — skipping" + exit 0 + fi + log "concurrent installer (pid ${owner}) died without finishing in ${checkout}" + fi + + # Another session may have taken over the lock while this one waited. + # Without this re-read, a completed takeover (claim already removed) is + # invisible, this session's claim succeeds, and two installs run at once. + owner="$(cat "${lock}/pid" 2>/dev/null || true)" + if [[ -n "${owner}" ]] && kill -0 "${owner}" 2>/dev/null; then + log "another session (pid ${owner}) took over the install in ${checkout} — skipping" + exit 0 + fi + + # The claim token makes the takeover exclusive, because rm-then-mkdir alone + # is not atomic. Without it, a second taker's rm could delete the first + # taker's fresh lock and both would install. The claim records its taker's + # pid so a killed takeover is reclaimed immediately. The age check covers + # only a pidless claim, whose taker died between mkdir and the pid write. + claim="${lock}.claim" + claim_is_stale() { + local claim_owner + claim_owner="$(cat "${claim}/pid" 2>/dev/null || true)" + if [[ -n "${claim_owner}" ]]; then + ! kill -0 "${claim_owner}" 2>/dev/null + else + [[ -n "$(find "${claim}" -maxdepth 0 -mmin +1 2>/dev/null)" ]] + fi + } + + # The second mkdir attempt covers a stale claim this session just cleared — + # mkdir stays the atomic arbiter both times, so two clearers still produce + # exactly one taker. + if ! mkdir "${claim}" 2>/dev/null; then + if claim_is_stale; then + log "clearing a stale takeover claim in ${checkout}" + rm -rf "${claim}" + fi + if ! mkdir "${claim}" 2>/dev/null; then + log "another session is taking over the stale lock in ${checkout} — skipping" + exit 0 + fi + fi + echo "$$" > "${claim}/pid" + log "install lock owner (pid ${owner:-unknown}) is gone — taking over" + rm -rf "${lock}" + if ! mkdir "${lock}" 2>/dev/null; then + rm -rf "${claim}" + log "lost the lock to a newly arrived session — skipping" + exit 0 + fi + echo "$$" > "${lock}/pid" + rm -rf "${claim}" +fi + +owned_pid="$$" +cleanup() { + # A hook killed while its npm ci keeps running leaves the lock in place. The + # lock's pid targets the live npm process, and the takeover logic above + # cleans up once npm exits. + if [[ -n "${install_pid:-}" ]] && kill -0 "${install_pid}" 2>/dev/null; then + return + fi + + # After this hook's npm exits, a takeover may have replaced the lock. + # Removing that lock would unlock a third session against the second's live + # install, so only a lock this hook still owns is released. + if [[ "$(cat "${lock}/pid" 2>/dev/null)" == "${owned_pid}" ]]; then + rm -rf "${lock}" + fi +} +trap cleanup EXIT + +cd "${checkout}" +touch "${marker}" + +# Honor the checkout's .nvmrc when that Node is already installed; otherwise +# stay on the default alias — a hook must never download a Node version. +command -v nvm >/dev/null 2>&1 && nvm use >/dev/null 2>&1 || true +log "installing dependencies in ${checkout} (node $(node --version 2>/dev/null || echo unknown))" + +# npm's stdout goes to stderr too, because SessionStart hook stdout enters the +# model's context. +npm ci >&2 & +install_pid=$! + +# The lock's liveness target becomes the npm process itself. If the hook shell +# is killed but its npm ci survives, the lock stays respected until the process +# actually mutating node_modules is gone. +echo "${install_pid}" > "${lock}/pid" +owned_pid="${install_pid}" + +if wait "${install_pid}"; then + rm -f "${marker}" + if [[ -n "${lockfile_hash}" ]]; then + printf '%s\n' "${lockfile_hash}" > "${stamp}" + fi + log "install complete" +else + log "npm ci failed — the session continues without dependencies; the marker forces a retry next session" +fi +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..5ecbadb --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,28 @@ +{ + "hooks": { + "SessionStart": [ + { + "matcher": "startup|resume", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/.claude/hooks/install-deps.sh", + "timeout": 600 + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "EnterWorktree", + "hooks": [ + { + "type": "command", + "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/.claude/hooks/install-deps.sh", + "timeout": 600 + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index a4c6665..a2f49c3 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,12 @@ dist/ # Symlinks into the private Obsidian vault (local dev only) CLAUDE.local.md TASKS.md + +# .claude/ is local state except the committed session hooks. A trailing-slash +# directory ignore would block the re-includes, because git never descends +# into an ignored directory. +.claude/* +!.claude/settings.json +!.claude/hooks/ +.claude/hooks/* +!.claude/hooks/install-deps.sh diff --git a/AGENTS.md b/AGENTS.md index 7919310..2399024 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,10 @@ its callers, and pre-existing bugs in traced code are valid findings. ## Structure ```text +.claude/ # committed Claude Code session hooks (the rest of .claude/ is gitignored) + settings.json # SessionStart + PostToolUse(EnterWorktree) → install-deps.sh + hooks/ + install-deps.sh # loads nvm, runs npm ci when a fresh clone or worktree has no node_modules action.yml # action metadata — inputs/outputs, runs.using: docker Dockerfile # multi-stage: build (tsc) → slim runtime fixtures/ # test fixtures (event payloads, sample diff, LLM responses) From 0ddb9e5825dd4eabef075cd1513ba5f05736f1c3 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:23:37 -0400 Subject: [PATCH 2/4] fix(hooks): close three install-lock races - Re-read the lock owner after the takeover claim is held, so a session never removes a lock another session just took over. - Run the interrupted-install recovery only while holding the lock, so a half-written tree from a still-running npm ci is never stamped as complete. - Re-read an empty lock pid after one second, so a lock whose pid write is still in flight is not treated as abandoned. Co-Authored-By: Claude Opus 5.5 --- .claude/hooks/install-deps.sh | 68 +++++++++++++++++++++-------------- 1 file changed, 42 insertions(+), 26 deletions(-) diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh index 9f3514e..196fb37 100755 --- a/.claude/hooks/install-deps.sh +++ b/.claude/hooks/install-deps.sh @@ -54,23 +54,6 @@ if [[ -d "${checkout}/node_modules" && ! -f "${marker}" ]]; then log "package-lock.json changed since the hook's last install in ${checkout} — reinstalling" fi -# A hook killed by timeout can leave the marker even though its orphaned npm ci -# finished the install. A tree that passes npm ls is complete — clear the -# marker and stamp it instead of rebuilding it. -if [[ -d "${checkout}/node_modules" && -f "${marker}" ]]; then - if npm --prefix "${checkout}" ls --depth=0 >/dev/null 2>&1; then - rm -f "${marker}" - # The orphaned install was still the hook's own, so it is stamped like the - # normal success path. Leaving it unstamped would disable the - # lockfile-change guard for this checkout forever. - if [[ -n "${lockfile_hash}" ]]; then - printf '%s\n' "${lockfile_hash}" > "${stamp}" - fi - log "marker left by an interrupted hook but the dependency tree in ${checkout} is complete — clearing" - exit 0 - fi -fi - # mkdir is the portable atomic lock (flock is Linux-only). The lock records its # owner's pid. # - A live owner means a concurrent install is running, so this session waits @@ -82,6 +65,16 @@ if mkdir "${lock}" 2>/dev/null; then echo "$$" > "${lock}/pid" else owner="$(cat "${lock}/pid" 2>/dev/null || true)" + + # A live session writes its pid right after its mkdir. An empty pid file + # means that write is still in flight, or its writer died between the two + # steps. Without this re-read, two sessions starting together would treat + # the in-flight lock as abandoned and both run npm ci. + if [[ -z "${owner}" ]]; then + sleep 1 + owner="$(cat "${lock}/pid" 2>/dev/null || true)" + fi + if [[ -n "${owner}" ]] && kill -0 "${owner}" 2>/dev/null; then # Waiting for the live install lets this session start with dependencies # instead of racing a tree npm ci is actively rewriting. The 480s bound @@ -103,15 +96,6 @@ else log "concurrent installer (pid ${owner}) died without finishing in ${checkout}" fi - # Another session may have taken over the lock while this one waited. - # Without this re-read, a completed takeover (claim already removed) is - # invisible, this session's claim succeeds, and two installs run at once. - owner="$(cat "${lock}/pid" 2>/dev/null || true)" - if [[ -n "${owner}" ]] && kill -0 "${owner}" 2>/dev/null; then - log "another session (pid ${owner}) took over the install in ${checkout} — skipping" - exit 0 - fi - # The claim token makes the takeover exclusive, because rm-then-mkdir alone # is not atomic. Without it, a second taker's rm could delete the first # taker's fresh lock and both would install. The claim records its taker's @@ -142,6 +126,18 @@ else fi fi echo "$$" > "${claim}/pid" + + # Holding the claim excludes every other taker, so the lock's owner is + # re-read only now. A session that finished its own takeover after this one + # first read the lock has released its claim and installs under a live pid. + # Removing that lock would start a second npm ci in the same node_modules. + owner="$(cat "${lock}/pid" 2>/dev/null || true)" + if [[ -n "${owner}" ]] && kill -0 "${owner}" 2>/dev/null; then + rm -rf "${claim}" + log "another session (pid ${owner}) took over the install in ${checkout} — skipping" + exit 0 + fi + log "install lock owner (pid ${owner:-unknown}) is gone — taking over" rm -rf "${lock}" if ! mkdir "${lock}" 2>/dev/null; then @@ -171,6 +167,26 @@ cleanup() { } trap cleanup EXIT +# A hook killed by timeout can leave the marker even though its orphaned npm ci +# finished the install. A tree that passes npm ls is complete, so this clears +# the marker and stamps the tree instead of rebuilding it. The check runs only +# while holding the lock, because an orphaned npm ci that is still writing +# holds the lock, and its half-written tree can pass npm ls. +if [[ -d "${checkout}/node_modules" && -f "${marker}" ]]; then + if npm --prefix "${checkout}" ls --depth=0 >/dev/null 2>&1; then + rm -f "${marker}" + + # The orphaned install was still the hook's own, so it is stamped like the + # normal success path. Leaving it unstamped would disable the + # lockfile-change guard for this checkout forever. + if [[ -n "${lockfile_hash}" ]]; then + printf '%s\n' "${lockfile_hash}" > "${stamp}" + fi + log "marker left by an interrupted hook but the dependency tree in ${checkout} is complete — clearing" + exit 0 + fi +fi + cd "${checkout}" touch "${marker}" From 707e5da6f24614e08b3176034afc0c9de58fff12 Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:28:01 -0400 Subject: [PATCH 3/4] fix(hooks): keep install state in the git dir and check the marker's lockfile - The marker, stamp, and lock move from .claude/ to the checkout's git directory, which is per-worktree and never tracked, so a checkout whose .gitignore predates the hook never shows them as untracked files. - The marker now holds the lockfile hash its install used, and interrupted-install recovery runs only when that hash matches the current lockfile, so a tree built from an older lockfile is reinstalled instead of stamped. Co-Authored-By: Claude Opus 5.5 --- .claude/hooks/install-deps.sh | 34 ++++++++++++++++++++-------------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh index 196fb37..2e246bb 100755 --- a/.claude/hooks/install-deps.sh +++ b/.claude/hooks/install-deps.sh @@ -28,21 +28,22 @@ checkout="$(git -C "${payload_cwd:-${CLAUDE_PROJECT_DIR:-.}}" rev-parse --show-t exit 0 } -# The marker, stamp, and lock all live in .claude/. A worktree entered from a -# commit that predates this hook has no .claude/, and every mkdir lock attempt -# would fail there as if another session held the lock. -mkdir -p "${checkout}/.claude" +# The marker, stamp, and lock live in the checkout's git directory, which is +# per-worktree and never tracked. A .claude/ location would surface them as +# untracked files in any checkout whose .gitignore predates this hook. +state_dir="$(git -C "${checkout}" rev-parse --absolute-git-dir)" # The marker survives an interrupted npm ci (a hook-timeout kill included), so -# a partial node_modules is retried instead of trusted. It lives outside -# node_modules because npm ci deletes that directory before installing. -marker="${checkout}/.claude/.install-deps-incomplete" +# a partial node_modules is retried instead of trusted. It holds the hash of +# the package-lock.json that install used, so recovery never stamps a tree +# built from an older lockfile. +marker="${state_dir}/install-deps-incomplete" # The stamp records which package-lock.json the hook's own last install used, # so a stamped checkout reinstalls after a pull changes the lockfile. An # unstamped checkout (node_modules installed by the developer, not the hook) # is trusted as-is — the hook must never wipe an install it does not own. -stamp="${checkout}/.claude/.install-deps-lockhash" +stamp="${state_dir}/install-deps-lockhash" lockfile_hash="$(git -C "${checkout}" hash-object package-lock.json 2>/dev/null || true)" if [[ -d "${checkout}/node_modules" && ! -f "${marker}" ]]; then @@ -60,7 +61,7 @@ fi # for it rather than race npm ci. # - A dead owner (hook killed mid-install) is taken over immediately, so the # marker's retry is never blocked behind an orphaned lock. -lock="${checkout}/.claude/.install-deps.lock" +lock="${state_dir}/install-deps.lock" if mkdir "${lock}" 2>/dev/null; then echo "$$" > "${lock}/pid" else @@ -169,10 +170,15 @@ trap cleanup EXIT # A hook killed by timeout can leave the marker even though its orphaned npm ci # finished the install. A tree that passes npm ls is complete, so this clears -# the marker and stamps the tree instead of rebuilding it. The check runs only -# while holding the lock, because an orphaned npm ci that is still writing -# holds the lock, and its half-written tree can pass npm ls. -if [[ -d "${checkout}/node_modules" && -f "${marker}" ]]; then +# the marker and stamps the tree instead of rebuilding it. +# - The check runs only while holding the lock, because an orphaned npm ci +# that is still writing holds the lock, and its half-written tree can pass +# npm ls. +# - The marker's lockfile hash must match the current one. A tree built from +# an older lockfile passes npm ls whenever package.json ranges still hold, +# and stamping it would hide the lockfile change forever. +marker_lockfile_hash="$(cat "${marker}" 2>/dev/null || true)" +if [[ -d "${checkout}/node_modules" && -f "${marker}" && "${marker_lockfile_hash}" == "${lockfile_hash}" ]]; then if npm --prefix "${checkout}" ls --depth=0 >/dev/null 2>&1; then rm -f "${marker}" @@ -188,7 +194,7 @@ if [[ -d "${checkout}/node_modules" && -f "${marker}" ]]; then fi cd "${checkout}" -touch "${marker}" +printf '%s\n' "${lockfile_hash}" > "${marker}" # Honor the checkout's .nvmrc when that Node is already installed; otherwise # stay on the default alias — a hook must never download a Node version. From 944d3c5f3e361bdb6994e65fdd4ed4d0afff459d Mon Sep 17 00:00:00 2001 From: Tanisha Aberdeen <32620895+aliasunder@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:48:42 -0400 Subject: [PATCH 4/4] refactor(hooks): serialize installs with a kernel lock instead of stealing mkdir locks The pid-file lock needed a takeover protocol to recover from a dead owner, and every takeover built from rm and mkdir left a check-then-act gap where two sessions could both install. A flock on a lock file in the git directory replaces it. The kernel releases the lock when every holder exits, so there is nothing to steal. npm ci inherits the locked descriptor, so an orphaned install keeps the lock until it finishes. Perl provides the flock call on both macOS and Linux; without perl the hook installs unlocked and says so. Co-Authored-By: Claude Opus 5.5 --- .claude/hooks/install-deps.sh | 171 ++++++++++------------------------ 1 file changed, 47 insertions(+), 124 deletions(-) diff --git a/.claude/hooks/install-deps.sh b/.claude/hooks/install-deps.sh index 2e246bb..92da332 100755 --- a/.claude/hooks/install-deps.sh +++ b/.claude/hooks/install-deps.sh @@ -46,134 +46,66 @@ marker="${state_dir}/install-deps-incomplete" stamp="${state_dir}/install-deps-lockhash" lockfile_hash="$(git -C "${checkout}" hash-object package-lock.json 2>/dev/null || true)" -if [[ -d "${checkout}/node_modules" && ! -f "${marker}" ]]; then +dependencies_are_current() { + local stamped stamped="$(cat "${stamp}" 2>/dev/null || true)" - if [[ -z "${stamped}" || "${stamped}" == "${lockfile_hash}" ]]; then - log "node_modules present in ${checkout} — nothing to do" - exit 0 + if [[ ! -d "${checkout}/node_modules" || -f "${marker}" ]]; then + return 1 fi - log "package-lock.json changed since the hook's last install in ${checkout} — reinstalling" -fi -# mkdir is the portable atomic lock (flock is Linux-only). The lock records its -# owner's pid. -# - A live owner means a concurrent install is running, so this session waits -# for it rather than race npm ci. -# - A dead owner (hook killed mid-install) is taken over immediately, so the -# marker's retry is never blocked behind an orphaned lock. -lock="${state_dir}/install-deps.lock" -if mkdir "${lock}" 2>/dev/null; then - echo "$$" > "${lock}/pid" -else - owner="$(cat "${lock}/pid" 2>/dev/null || true)" - - # A live session writes its pid right after its mkdir. An empty pid file - # means that write is still in flight, or its writer died between the two - # steps. Without this re-read, two sessions starting together would treat - # the in-flight lock as abandoned and both run npm ci. - if [[ -z "${owner}" ]]; then - sleep 1 - owner="$(cat "${lock}/pid" 2>/dev/null || true)" - fi + [[ -z "${stamped}" || "${stamped}" == "${lockfile_hash}" ]] +} - if [[ -n "${owner}" ]] && kill -0 "${owner}" 2>/dev/null; then - # Waiting for the live install lets this session start with dependencies - # instead of racing a tree npm ci is actively rewriting. The 480s bound - # sits well inside the 600s hook timeout in settings.json. - log "another session (pid ${owner}) is installing in ${checkout} — waiting for it" - waited=0 - while kill -0 "${owner}" 2>/dev/null && ((waited < 480)); do - sleep 5 - waited=$((waited + 5)) - done - if [[ -d "${checkout}/node_modules" && ! -f "${marker}" ]]; then - log "concurrent install finished in ${checkout} — nothing to do" - exit 0 - fi - if ((waited >= 480)); then - log "concurrent install still running after ${waited}s in ${checkout} — skipping" - exit 0 - fi - log "concurrent installer (pid ${owner}) died without finishing in ${checkout}" - fi +if dependencies_are_current; then + log "node_modules present in ${checkout} — nothing to do" + exit 0 +fi - # The claim token makes the takeover exclusive, because rm-then-mkdir alone - # is not atomic. Without it, a second taker's rm could delete the first - # taker's fresh lock and both would install. The claim records its taker's - # pid so a killed takeover is reclaimed immediately. The age check covers - # only a pidless claim, whose taker died between mkdir and the pid write. - claim="${lock}.claim" - claim_is_stale() { - local claim_owner - claim_owner="$(cat "${claim}/pid" 2>/dev/null || true)" - if [[ -n "${claim_owner}" ]]; then - ! kill -0 "${claim_owner}" 2>/dev/null - else - [[ -n "$(find "${claim}" -maxdepth 0 -mmin +1 2>/dev/null)" ]] - fi - } - - # The second mkdir attempt covers a stale claim this session just cleared — - # mkdir stays the atomic arbiter both times, so two clearers still produce - # exactly one taker. - if ! mkdir "${claim}" 2>/dev/null; then - if claim_is_stale; then - log "clearing a stale takeover claim in ${checkout}" - rm -rf "${claim}" - fi - if ! mkdir "${claim}" 2>/dev/null; then - log "another session is taking over the stale lock in ${checkout} — skipping" - exit 0 - fi - fi - echo "$$" > "${claim}/pid" - - # Holding the claim excludes every other taker, so the lock's owner is - # re-read only now. A session that finished its own takeover after this one - # first read the lock has released its claim and installs under a live pid. - # Removing that lock would start a second npm ci in the same node_modules. - owner="$(cat "${lock}/pid" 2>/dev/null || true)" - if [[ -n "${owner}" ]] && kill -0 "${owner}" 2>/dev/null; then - rm -rf "${claim}" - log "another session (pid ${owner}) took over the install in ${checkout} — skipping" +# A kernel lock serializes installs across sessions. +# - The lock belongs to the file opened on fd 9, not to a process. npm ci +# inherits fd 9, so the lock stays held while an orphaned npm ci outlives a +# killed hook, and the kernel releases it once every holder exits. No stale +# lock is ever left for a later session to steal. +# - Perl's flock is the lock call available on both macOS, which lacks +# flock(1), and Linux, which lacks lockf(1). Perl locks this shell's fd 9, +# so the lock outlives the perl process. +# - The 480s wait sits well inside the 600s hook timeout in settings.json. +# Perl exits 75 when that wait times out. +exec 9>>"${state_dir}/install-deps.lock" +if command -v perl >/dev/null 2>&1; then + lock_status=0 + perl -MFcntl=:flock -e ' + open(my $lock, ">&=", 9) or exit 2; + exit 0 if flock($lock, LOCK_EX | LOCK_NB); + print STDERR "[install-deps] another session is installing in $ARGV[0] — waiting for it\n"; + $SIG{ALRM} = sub { exit 75 }; + alarm 480; + flock($lock, LOCK_EX) or exit 2; + exit 0; + ' "${checkout}" || lock_status=$? + + if ((lock_status == 75)); then + log "concurrent install still running after 480s in ${checkout} — skipping" exit 0 fi - - log "install lock owner (pid ${owner:-unknown}) is gone — taking over" - rm -rf "${lock}" - if ! mkdir "${lock}" 2>/dev/null; then - rm -rf "${claim}" - log "lost the lock to a newly arrived session — skipping" - exit 0 + if ((lock_status != 0)); then + log "could not take the install lock in ${checkout} (perl exit ${lock_status}) — installing without it" fi - echo "$$" > "${lock}/pid" - rm -rf "${claim}" +else + log "perl not found — installing in ${checkout} without the install lock" fi -owned_pid="$$" -cleanup() { - # A hook killed while its npm ci keeps running leaves the lock in place. The - # lock's pid targets the live npm process, and the takeover logic above - # cleans up once npm exits. - if [[ -n "${install_pid:-}" ]] && kill -0 "${install_pid}" 2>/dev/null; then - return - fi - - # After this hook's npm exits, a takeover may have replaced the lock. - # Removing that lock would unlock a third session against the second's live - # install, so only a lock this hook still owns is released. - if [[ "$(cat "${lock}/pid" 2>/dev/null)" == "${owned_pid}" ]]; then - rm -rf "${lock}" - fi -} -trap cleanup EXIT +# The session that held the lock may have just installed this lockfile. +if dependencies_are_current; then + log "another session finished the install in ${checkout} — nothing to do" + exit 0 +fi # A hook killed by timeout can leave the marker even though its orphaned npm ci # finished the install. A tree that passes npm ls is complete, so this clears # the marker and stamps the tree instead of rebuilding it. -# - The check runs only while holding the lock, because an orphaned npm ci -# that is still writing holds the lock, and its half-written tree can pass -# npm ls. +# - Holding the lock guarantees no orphaned npm ci is still writing, because +# that npm ci would still hold fd 9. # - The marker's lockfile hash must match the current one. A tree built from # an older lockfile passes npm ls whenever package.json ranges still hold, # and stamping it would hide the lockfile change forever. @@ -203,16 +135,7 @@ log "installing dependencies in ${checkout} (node $(node --version 2>/dev/null | # npm's stdout goes to stderr too, because SessionStart hook stdout enters the # model's context. -npm ci >&2 & -install_pid=$! - -# The lock's liveness target becomes the npm process itself. If the hook shell -# is killed but its npm ci survives, the lock stays respected until the process -# actually mutating node_modules is gone. -echo "${install_pid}" > "${lock}/pid" -owned_pid="${install_pid}" - -if wait "${install_pid}"; then +if npm ci >&2; then rm -f "${marker}" if [[ -n "${lockfile_hash}" ]]; then printf '%s\n' "${lockfile_hash}" > "${stamp}"