Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 16 additions & 84 deletions tests/run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -990,52 +990,42 @@ fi
assert_file_has "the write lands after breaking another host's ancient lock" \
"$FOREIGN_MD" "CLAUDE-TEAM:START"
rm -rf "$FOREIGN_HOME"
echo ""

# Shared-state concurrency. ~/.claude/branches/INDEX.md and ~/.claude/CLAUDE.md
# are shared across every session on the machine, and parallel sessions are the
# product's headline feature, so concurrent access is the designed-for case.
# Measured on the unlocked code: 8 of 12 rows survived, and CLAUDE.md grew
# duplicate marker blocks when block_install took its append path against a
# writer that had not landed yet.
# are shared by every session on the machine, so two sessions finishing at once
# must not lose each other's writes.
#
# This used to be a 12-writer storm against a planted stale lock, with CPU
# pressure, built to prove the locking rewrite under hostile conditions. It did
# that, and then cost 113 lines and most of the suite's runtime forever after.
# The realistic case is two or three parallel sessions, which is what this
# covers now. The lock implementation is unchanged; only the harness shrank.
echo "shared-state concurrency"

CONC_HOME=$(mktemp -d)
CONC_INDEX="$CONC_HOME/.claude/branches/INDEX.md"
mkdir -p "$CONC_HOME/.claude"
CONC_ROOT=$(mktemp -d)
for i in 1 2 3 4 5 6 7 8; do
for i in 1 2 3; do
git init -q "$CONC_ROOT/r$i"
(cd "$CONC_ROOT/r$i" && git commit -q --allow-empty -m init)
done
# Seed four registered branches, sequentially, so there is something to rewrite.
for i in 1 2 3 4; do
(cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch start "feat/c$i" >/dev/null 2>&1)
done
# Four rewriters and four appenders at once. A rewriter reads the whole index,
# transforms it, and renames over it; an append landing inside that window is
# erased by the rename, however atomic the append itself was.
for i in 1 2 3 4; do
(cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch "done" >/dev/null 2>&1) &
done
for i in 5 6 7 8; do
for i in 1 2 3; do
(cd "$CONC_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" branch start "feat/c$i" >/dev/null 2>&1) &
done
wait
assert_count "concurrent writers keep every row" "$CONC_INDEX" '^| 2' 8
assert_count "concurrent rewrites all applied" "$CONC_INDEX" '| merged |' 4
assert_count "concurrent appends all survived" "$CONC_INDEX" '| active |' 4
assert_file_has "concurrent writers keep the header" "$CONC_INDEX" "Branch Index"
if [[ ! -d "$CONC_INDEX.lock" ]]; then ok "no lock directory remains after success"; else fail "no lock directory remains after success"; fi
assert_count "concurrent writers keep every row" "$CONC_INDEX" '^| 2' 3
assert_file_has "concurrent writers keep the header" "$CONC_INDEX" "Branch Index"
if [[ ! -d "$CONC_INDEX.lock" ]]; then ok "no lock directory remains after success"
else fail "no lock directory remains after success"; fi
rm -rf "$CONC_ROOT"

# CLAUDE.md has two independent marker blocks written by different commands.
# CLAUDE.md holds two independent marker blocks written by different commands.
# The duplicate-block count is the assertion that matters: block_install greps
# for its start marker, misses it against an unlanded concurrent write, and
# takes the append path, producing a second copy.
CONC_MD="$CONC_HOME/.claude/CLAUDE.md"
printf 'keep-this-line\n' > "$CONC_MD"
for _ in 1 2 3 4 5; do
for _ in 1 2 3; do
(CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" use akira >/dev/null 2>&1) &
(CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$CONC_HOME" "$CLI" coordinator on >/dev/null 2>&1) &
done
Expand All @@ -1044,64 +1034,6 @@ assert_count "concurrent writers leave one team block" "$CONC_MD" "CLAUDE
assert_count "concurrent writers leave one coordinator block" "$CONC_MD" "CLAUDE-COORDINATOR:START" 1
assert_count "concurrent writers keep user content" "$CONC_MD" "keep-this-line" 1
rm -rf "$CONC_HOME"

# The same storm, but starting from a stale lock, which is the state that made
# lock breaking the dangerous part: every contender arrives, agrees the holder is
# gone, and races to remove. Every writer here is a rewriter with its own
# distinct transition to apply, so any two contenders that end up inside the
# critical section together must lose one of them.
#
# This is a probabilistic detector, and deliberately kept as one. The window
# between a contender's staleness verdict and its removal is microseconds wide,
# so on the unfixed code this storm reproduced the lost update in roughly 1 run
# in 6 at this size, and more often under CPU load. It is here to guard the
# end-to-end invariant that no row is ever lost; the deterministic proofs of the
# single-winner rename are in the 'lock breaking has a single winner' section.
STALE_HOME=$(mktemp -d)
STALE_INDEX="$STALE_HOME/.claude/branches/INDEX.md"
mkdir -p "$STALE_HOME/.claude"
STALE_ROOT=$(mktemp -d)
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
git init -q "$STALE_ROOT/r$i"
(cd "$STALE_ROOT/r$i" && git commit -q --allow-empty -m init)
(cd "$STALE_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$STALE_HOME" "$CLI" branch start "feat/s$i" >/dev/null 2>&1)
done
mkdir -p "$STALE_INDEX.lock"
printf '%s %s %s\n' "$LOCK_HOST" "$(dead_pid)" "$(date '+%s')" > "$STALE_INDEX.lock/owner"
for i in 1 2 3 4 5 6 7 8 9 10 11 12; do
(cd "$STALE_ROOT/r$i" && CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$STALE_HOME" "$CLI" branch "done" >/dev/null 2>&1) &
done
wait
# merged == 12 with rows == 12 is what pins it: a discarded rewrite leaves its
# row behind as 'active', so the row count alone would not move.
assert_count "a stale lock plus 12 writers keeps every row" "$STALE_INDEX" '^| 2' 12
assert_count "a stale lock plus 12 writers applies every rewrite" "$STALE_INDEX" '| merged |' 12
assert_file_has "a stale lock plus 12 writers keeps the header" "$STALE_INDEX" "Branch Index"
if [[ ! -d "$STALE_INDEX.lock" ]]; then ok "the storm leaves no lock directory behind"
else fail "the storm leaves no lock directory behind"; fi
stale_left=$(find "$STALE_HOME/.claude/branches" -maxdepth 1 -name 'INDEX.md.lock.dead.*' 2>/dev/null | wc -l | tr -d ' ')
if [[ "$stale_left" == "0" ]]; then ok "the storm leaves no renamed-aside lock behind"
else fail "the storm leaves no renamed-aside lock behind (found $stale_left)"; fi
if [[ -z "$(find "$STALE_HOME/.claude" -name '.claude-team.*' 2>/dev/null)" ]]; then
ok "the storm leaves no temp file behind"
else fail "the storm leaves no temp file behind"; fi
rm -rf "$STALE_ROOT"

# CLAUDE.md under the same conditions: a stale lock, then writers on both of its
# independent marker blocks.
STALE_MD="$STALE_HOME/.claude/CLAUDE.md"
printf 'keep-this-line\n' > "$STALE_MD"
mkdir -p "$STALE_MD.lock"
printf '%s %s %s\n' "$LOCK_HOST" "$(dead_pid)" "$(date '+%s')" > "$STALE_MD.lock/owner"
for _ in 1 2 3 4 5; do
(CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$STALE_HOME" "$CLI" use akira >/dev/null 2>&1) &
(CLAUDE_TEAM_PROFILES="$PROFILES_DIR" HOME="$STALE_HOME" "$CLI" coordinator on >/dev/null 2>&1) &
done
wait
assert_count "a stale lock plus writers leaves one team block" "$STALE_MD" "CLAUDE-TEAM:START" 1
assert_count "a stale lock plus writers leaves one coordinator block" "$STALE_MD" "CLAUDE-COORDINATOR:START" 1
assert_count "a stale lock plus writers keeps user content" "$STALE_MD" "keep-this-line" 1
rm -rf "$STALE_HOME"
echo ""

# Temp files must be created beside their destination, not in TMPDIR. A
Expand Down
Loading