idle-draft: process-group child termination, unique tmp paths, stale sweep
Lock-contention test added; children die with the dispatcher (SIGTERM/ SIGINT handlers + atexit); tmp files are per-invocation (.tmp.<pid>) so a SIGKILL-orphaned child can never collide with a new instance. Claude-Session: https://claude.ai/code/session_01YQDoWNM7XPPii28khFWoMc
This commit is contained in:
@@ -162,6 +162,50 @@ after_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
|
||||
[[ "$before_hash" == "$after_hash" ]] && pass "dryrun makes no git commits" || fail "dryrun makes no git commits"
|
||||
[[ ! -f "$FIXTURE_REPO/ai/03-topic-three.review.md" ]] && pass "dryrun writes no output file" || fail "dryrun writes no output file"
|
||||
|
||||
echo "-- concurrency: second invocation exits 0 when lock is held (GAP 1) --"
|
||||
# Holds .idle-draft.lock in a background helper (independent process, so the
|
||||
# flock is real -- fcntl locks are per-process/per-fd, a second flock from
|
||||
# this same bash process wouldn't contend). The helper touches a sentinel
|
||||
# file only *after* it has the lock, so we never race the flock call itself.
|
||||
LOCK_HELPER="$TMPDIR/lock_helper.py"
|
||||
cat > "$LOCK_HELPER" <<'PYEOF'
|
||||
import fcntl, sys, time
|
||||
lock_path, sentinel_path = sys.argv[1], sys.argv[2]
|
||||
f = open(lock_path, "w")
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
with open(sentinel_path, "w") as s:
|
||||
s.write("locked\n")
|
||||
time.sleep(30)
|
||||
PYEOF
|
||||
LOCK_SENTINEL="$TMPDIR/lock-sentinel"
|
||||
rm -f "$LOCK_SENTINEL"
|
||||
python3 "$LOCK_HELPER" "$FIXTURE_REPO/.idle-draft.lock" "$LOCK_SENTINEL" &
|
||||
HELPER_PID=$!
|
||||
|
||||
waited=0
|
||||
while [[ ! -f "$LOCK_SENTINEL" && $waited -lt 50 ]]; do
|
||||
sleep 0.1
|
||||
waited=$((waited + 1))
|
||||
done
|
||||
|
||||
if [[ ! -f "$LOCK_SENTINEL" ]]; then
|
||||
fail "lock-contention test setup: helper never confirmed it holds the lock"
|
||||
else
|
||||
before_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
|
||||
# Real dispatch mode (no --dryrun): a fixture with plenty of idle capacity,
|
||||
# so absent the lock this would dispatch item 03's review task for real.
|
||||
output=$("$SCRIPT" --config "$FIXTURE_CONFIG" --repo "$FIXTURE_REPO" --probe-json "$FIXTURE_PROBE" --once 2>&1)
|
||||
code=$?
|
||||
assert_exit_code "$code" 0 "second invocation with lock held exits 0"
|
||||
assert_contains "$output" "lock held" "log line reports lock held"
|
||||
after_hash=$(cd "$FIXTURE_REPO" && git rev-parse HEAD)
|
||||
[[ "$before_hash" == "$after_hash" ]] && pass "no commit occurred while lock held" || fail "no commit occurred while lock held"
|
||||
[[ ! -f "$FIXTURE_REPO/ai/03-topic-three.review.md" ]] && pass "no output file written while lock held" || fail "no output file written while lock held"
|
||||
fi
|
||||
|
||||
kill "$HELPER_PID" 2>/dev/null
|
||||
wait "$HELPER_PID" 2>/dev/null
|
||||
|
||||
echo "-- state validation failure exits 2 --"
|
||||
BADSTATE="$TMPDIR/badstate-repo"
|
||||
mkdir -p "$BADSTATE/ai"
|
||||
@@ -547,6 +591,85 @@ except m.StateValidationError:
|
||||
m.validate_state({"items": {"ai/01-topic-one": {"human_edit_done": True, "attempts": {"draft": 1}}}}, repo)
|
||||
check("state validation: well-formed state passes", True)
|
||||
|
||||
# --- GAP 2b: unique tmp path per invocation ---
|
||||
import os as _os
|
||||
import time as _time
|
||||
|
||||
canonical = repo / "ai" / "03-topic-three.review.md"
|
||||
tmp_a = m.tmp_output_path(canonical, pid=11111)
|
||||
tmp_b = m.tmp_output_path(canonical, pid=22222)
|
||||
check("tmp path uniqueness: two different pids produce two different tmp paths",
|
||||
tmp_a != tmp_b and tmp_a.name == "03-topic-three.review.md.tmp.11111"
|
||||
and tmp_b.name == "03-topic-three.review.md.tmp.22222", (str(tmp_a), str(tmp_b)))
|
||||
|
||||
tmp_default = m.tmp_output_path(canonical)
|
||||
check("tmp path uniqueness: default pid is os.getpid()",
|
||||
tmp_default.name == f"03-topic-three.review.md.tmp.{_os.getpid()}", str(tmp_default))
|
||||
|
||||
rejected = m.rejected_output_path(canonical)
|
||||
check("rejected-path derivation is unaffected by the new tmp naming (derived from canonical, not tmp)",
|
||||
rejected.name == "03-topic-three.review.md.rejected", str(rejected))
|
||||
|
||||
# --- GAP 2b: startup sweep of stale tmp files ---
|
||||
sweep_repo = tmpdir / "sweep-repo"
|
||||
(sweep_repo / "ai").mkdir(parents=True, exist_ok=True)
|
||||
old_tmp = sweep_repo / "ai" / "01-a.research.md.tmp.111"
|
||||
fresh_tmp = sweep_repo / "ai" / "02-b.research.md.tmp.222"
|
||||
old_tmp.write_text("stale orphan output\n")
|
||||
fresh_tmp.write_text("still-in-progress output\n")
|
||||
now = _time.time()
|
||||
task_timeout = 1800
|
||||
old_mtime = now - task_timeout - 100 # older than the timeout -> stale
|
||||
fresh_mtime = now - 10 # well within the timeout -> live orphan candidate, keep
|
||||
_os.utime(old_tmp, (old_mtime, old_mtime))
|
||||
_os.utime(fresh_tmp, (fresh_mtime, fresh_mtime))
|
||||
|
||||
deleted = m.sweep_stale_tmp_files(sweep_repo, ["ai"], task_timeout, now=now)
|
||||
check("stale sweep: deletes a tmp file older than the task timeout",
|
||||
old_tmp in deleted and not old_tmp.exists(), deleted)
|
||||
check("stale sweep: leaves a tmp file younger than the task timeout untouched",
|
||||
fresh_tmp not in deleted and fresh_tmp.exists(), deleted)
|
||||
|
||||
# A non-.tmp.<pid> file (e.g. the canonical output itself) must never be swept.
|
||||
canonical_lookalike = sweep_repo / "ai" / "01-a.research.md"
|
||||
canonical_lookalike.write_text("# Research: a\n")
|
||||
_os.utime(canonical_lookalike, (old_mtime, old_mtime))
|
||||
deleted2 = m.sweep_stale_tmp_files(sweep_repo, ["ai"], task_timeout, now=now)
|
||||
check("stale sweep: never touches a file without a .tmp.<pid> suffix, however old",
|
||||
canonical_lookalike.exists() and canonical_lookalike not in deleted2, canonical_lookalike)
|
||||
|
||||
# --- GAP 2a: child process-group tracking / signal handling (smoke tests) ---
|
||||
# No real subprocess is spawned here -- just the bookkeeping primitives that
|
||||
# the signal handler and run_task() rely on.
|
||||
fake_pid = 2**30 # astronomically unlikely to collide with a real pid/pgid
|
||||
m.register_child(fake_pid)
|
||||
check("child tracking: register_child adds the pid to the live set", fake_pid in m.live_children(), m.live_children())
|
||||
|
||||
m.unregister_child(fake_pid)
|
||||
check("child tracking: unregister_child removes the pid", fake_pid not in m.live_children(), m.live_children())
|
||||
|
||||
m.register_child(fake_pid)
|
||||
m.register_child(fake_pid + 1)
|
||||
try:
|
||||
m.kill_all_children()
|
||||
kill_all_ok = True
|
||||
except Exception as e:
|
||||
kill_all_ok = False
|
||||
check("child tracking: kill_all_children on nonexistent pids raises nothing (ProcessLookupError swallowed)",
|
||||
kill_all_ok, "")
|
||||
check("child tracking: kill_all_children clears the registry", m.live_children() == set(), m.live_children())
|
||||
|
||||
m.install_signal_handlers()
|
||||
import signal as _signal
|
||||
check("signal handling: install_signal_handlers installs a non-default SIGTERM handler",
|
||||
_signal.getsignal(_signal.SIGTERM) not in (_signal.SIG_DFL, _signal.SIG_IGN, None), "")
|
||||
check("signal handling: install_signal_handlers installs a non-default SIGINT handler",
|
||||
_signal.getsignal(_signal.SIGINT) not in (_signal.SIG_DFL, _signal.SIG_IGN, None), "")
|
||||
# Restore defaults so this subprocess (which is about to exit anyway) doesn't
|
||||
# leave anything surprising behind for the interpreter teardown.
|
||||
_signal.signal(_signal.SIGTERM, _signal.SIG_DFL)
|
||||
_signal.signal(_signal.SIGINT, _signal.SIG_DFL)
|
||||
|
||||
for status, name, detail in results:
|
||||
print(f"{status}: {name}", detail if detail else "")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user