#10398

Colocated snapshot can overwrite a concurrent `git add`

Openhewigovensopened 15d ago

Description

In a colocated repo, jj status can undo staging from a concurrent git add, even when both commands succeed. Working-tree contents remain intact.

jj_lib::git::update_intent_to_add reads .git/index before taking index.lock. If Git commits an updated index between that read and jj's write, jj overwrites it with its older copy.

This differs from #7530's immediate lock failure. Open PR #10160 proposes waiting for the lock, but still reads the index before acquiring it, so the lost-update race remains. This ordering is also present on main at df92b83 (checked on 2026-10-15)

Background: We found this in JayJay, built upon jj-lib. Our retry-only workaround for lock failures passed focused tests, but a regression checking preservation of another writer's staged entry failed 3/200 times under CPU load: the entry became empty intent-to-add. Retrying reloads the index, but still leaves a gap between reading it and taking the lock.

Possible fix: Compute the tree diff first, acquire index.lock with bounded backoff, then read, update, and write the index through that same lock. Invalidate the cache-tree when changing entries, as jj-lib already does. This is JayJay's workaround. We kept retries for reset_head, which intentionally rebuilds the index from the working-copy commit rather than preserving its staged entries.

Steps to Reproduce the Problem

  1. Save the script below as repro.sh. It races a snapshot of a new file with staging another file, and counts lost updates only when both commands exit successfully.
  2. Set MAX_DELAY_MS to roughly the duration of jj status in the test repo (default 60; jj status took about 50 ms here).
  3. Run bash repro.sh.
#!/usr/bin/env bash
set -u
rounds=${ROUNDS:-300}
max_delay_ms=${MAX_DELAY_MS:-60}

cd "$(mktemp -d)"
jj git init --colocate repo >/dev/null 2>&1
cd repo
echo base > base.txt
jj commit -m base >/dev/null 2>&1

lost=0 jj_failed=0 git_failed=0
for i in $(seq "$rounds"); do
  echo "v$i" > staged.txt
  echo "$i" > "new-$i.txt" # gives the snapshot an intent-to-add entry to write
  jj status >/dev/null 2>&1 &
  jj_pid=$!
  sleep "$(printf '0.%03d' $((RANDOM % max_delay_ms)))"
  if git add staged.txt 2>/dev/null; then git_status=0; else git_status=$?; git_failed=$((git_failed + 1)); fi
  if wait "$jj_pid"; then jj_status=0; else jj_status=$?; jj_failed=$((jj_failed + 1)); fi
  if [ "$git_status" = 0 ] && [ "$jj_status" = 0 ]; then
    staged=$(git show :staged.txt 2>/dev/null)
    if [ "$staged" != "v$i" ]; then
      lost=$((lost + 1))
      echo "round $i: staged v$i, index now has '${staged}' (jj=$jj_status git=$git_status)"
    fi
  fi
done
echo "rounds=$rounds lost_updates=$lost jj_failures=$jj_failed git_failures=$git_failed"

Expected Behavior

Every git add that succeeds stays in the index: lost_updates=0. With jj status replaced by true, the script reports 0 lost updates in 300 rounds.

Actual Behavior

7 lost updates in 300 rounds. Both commands exited 0 in every lost round; for example, round 57 staged v57, but the index held v56. That run also had 22 jj failures and 2 Git failures (the #7530 symptom), counted separately.

Specifications

  • Platform: macOS (Darwin 27.0, Apple silicon)
  • Version: jj 0.46.0, Git 2.56.0

Activity

1
kg29015d ago

Ill reproduce and analyse this