277 lines
8.1 KiB
Python
277 lines
8.1 KiB
Python
"""A-3 — shift buffer isolation & garbage collection.
|
|
|
|
Defects addressed:
|
|
① Colliding buffer names (e.g. constant "sks_adhoc" or "sks_onboard") caused
|
|
cross-agent prompt contamination and silent buffer deletion loss.
|
|
② Call-unique buffer names (sks_<job>_<pid>_<rand>) fixed ① but introduced a
|
|
permanent leak of abandoned buffers (SIGINT/SIGTERM/timeout between
|
|
set-buffer and delete-buffer). Fixed with automatic GC (-mmin +60).
|
|
③ F2's atomic write created DOT-prefixed temp files ('.buf_sks_....tmp') that
|
|
F4's initial GC pattern ('buf_sks_*') missed. Fixed by widening the GC
|
|
pattern to include '.buf_sks_*' and cleaning up on write/rename failure.
|
|
"""
|
|
import os
|
|
import subprocess
|
|
import time
|
|
|
|
import pytest
|
|
|
|
BARE_PATH = "/usr/bin:/bin:/usr/sbin:/sbin"
|
|
|
|
|
|
def _bash(sandbox, snippet, extra_env=None):
|
|
env = dict(os.environ)
|
|
env["PATH"] = BARE_PATH
|
|
env["WORKSPACE_ROOT"] = str(sandbox)
|
|
if extra_env:
|
|
env.update(extra_env)
|
|
script = f'source "{sandbox}/.agents/skills/lib.sh" >/dev/null 2>&1\n{snippet}'
|
|
return subprocess.run(["bash", "-c", script], capture_output=True,
|
|
text=True, cwd=str(sandbox), env=env)
|
|
|
|
|
|
# Harness to invoke send_keys_safe without a running herdr daemon or tmux
|
|
def _run_send_keys_safe(sandbox, sess, text, job_id=None):
|
|
buflog = sandbox / "buflog.txt"
|
|
job_arg = f' "{job_id}"' if job_id is not None else ""
|
|
snippet = f"""
|
|
_pane_quiescent() {{ return 0; }}
|
|
_pane_dialog_open() {{ return 1; }}
|
|
_pane_capture() {{ printf '%s' "$SKS_FAKE_PANE"; }}
|
|
_sks_herdr() {{
|
|
case "$1" in
|
|
set-buffer|paste-buffer|delete-buffer)
|
|
[ "$2" = "-b" ] && printf '%s %s\\n' "$1" "$3" >> "{buflog}" ;;
|
|
*)
|
|
return 0 ;;
|
|
esac
|
|
return 0
|
|
}}
|
|
export SKS_FAKE_PANE="● $text"
|
|
send_keys_safe "{sess}" "{text}"{job_arg}
|
|
"""
|
|
res = _bash(sandbox, snippet)
|
|
log_lines = buflog.read_text().splitlines() if buflog.exists() else []
|
|
return res, log_lines
|
|
|
|
|
|
# X-1 — Two calls with the same job_id (e.g. "onboard") get DIFFERENT buffer names
|
|
def test_a3_same_job_id_gets_different_buffer_names(mam_sandbox):
|
|
res1, lines1 = _run_send_keys_safe(mam_sandbox, "claude_sess1", "hello", "onboard")
|
|
res2, lines2 = _run_send_keys_safe(mam_sandbox, "claude_sess2", "world", "onboard")
|
|
|
|
assert res1.returncode == 0 and res2.returncode == 0
|
|
buf1 = lines1[0].split()[1]
|
|
buf2 = lines2[-3].split()[1]
|
|
assert buf1 != buf2, f"buffer names collided: {buf1} == {buf2}"
|
|
|
|
|
|
# X-2 — Within one call, set-buffer, paste-buffer, and delete-buffer use the SAME name
|
|
def test_a3_triple_uses_same_buffer_name(mam_sandbox):
|
|
res, lines = _run_send_keys_safe(mam_sandbox, "claude_sess1", "test payload", "job1")
|
|
assert res.returncode == 0
|
|
assert len(lines) == 3
|
|
cmd0, name0 = lines[0].split()
|
|
cmd1, name1 = lines[1].split()
|
|
cmd2, name2 = lines[2].split()
|
|
|
|
assert cmd0 == "set-buffer" and cmd1 == "paste-buffer" and cmd2 == "delete-buffer"
|
|
assert name0 == name1 == name2
|
|
|
|
|
|
# X-3 — Different buffer names map to different files; delete does not touch another's file
|
|
def test_a3_different_names_do_not_interfere(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
"$SHIM" set-buffer -b "buf_A" "PAYLOAD_A"
|
|
"$SHIM" set-buffer -b "buf_B" "PAYLOAD_B"
|
|
|
|
assert_file() { [ -f "$DIR/$1" ] && echo "$1:EXISTS" || echo "$1:MISSING"; }
|
|
assert_file "buf_buf_A"
|
|
assert_file "buf_buf_B"
|
|
|
|
"$SHIM" delete-buffer -b "buf_A"
|
|
assert_file "buf_buf_A"
|
|
assert_file "buf_buf_B"
|
|
""")
|
|
assert "buf_buf_A:EXISTS" in res.stdout
|
|
assert "buf_buf_B:EXISTS" in res.stdout
|
|
assert "buf_buf_A:MISSING" in res.stdout
|
|
assert "buf_buf_B:EXISTS" in res.stdout
|
|
|
|
|
|
# X-4 — Invalid/empty -b names are rejected explicitly, not falling back to tmp_buffer
|
|
def test_a3_unusable_buffer_name_is_rejected(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
|
|
"$SHIM" set-buffer -b "!!!" "PAYLOAD" 2>&1
|
|
echo "RC:$?"
|
|
""")
|
|
assert "RC:1" in res.stdout
|
|
assert "contains no usable characters" in res.stdout + res.stderr
|
|
|
|
|
|
# X-5 — Parallel execution test: 12 callers get isolated buffer text
|
|
def test_a3_parallel_calls_are_isolated(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
|
|
for i in $(seq 1 12); do
|
|
"$SHIM" set-buffer -b "par_$i" "DATA_$i" &
|
|
done
|
|
wait
|
|
|
|
for i in $(seq 1 12); do
|
|
content=$(cat "$DIR/buf_par_$i" 2>/dev/null)
|
|
if [ "$content" != "DATA_$i" ]; then
|
|
echo "CORRUPTION in par_$i: got '$content'"
|
|
fi
|
|
done
|
|
echo "PARALLEL:OK"
|
|
""")
|
|
assert "PARALLEL:OK" in res.stdout
|
|
assert "CORRUPTION" not in res.stdout
|
|
|
|
|
|
# X-6 — Atomic write does not leave .tmp files behind on success
|
|
def test_a3_atomic_write_leaves_no_tmp_on_success(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
SHIM="$WORKSPACE_ROOT/.mam/shim/herdr"
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
"$SHIM" set-buffer -b "atomic_test" "ATOMIC_PAYLOAD"
|
|
ls -a "$DIR"
|
|
""")
|
|
assert "buf_atomic_test" in res.stdout
|
|
assert not any(f.endswith(".tmp") for f in res.stdout.split())
|
|
|
|
|
|
# X-7 — Abandoned buffers (older than GC threshold) are reclaimed
|
|
def test_a3_abandoned_buffers_are_collected(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$DIR"
|
|
ABANDONED="$DIR/buf_sks_OLD_1_1"
|
|
: > "$ABANDONED"
|
|
touch -t 202501010000 "$ABANDONED"
|
|
|
|
SHIM="$DIR/herdr"
|
|
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
|
|
|
if [ -f "$ABANDONED" ]; then
|
|
echo "GC:LEAK"
|
|
else
|
|
echo "GC:SWEPT"
|
|
fi
|
|
""")
|
|
assert "GC:SWEPT" in res.stdout
|
|
|
|
|
|
# X-8 — Live buffers (newer than GC threshold) are preserved
|
|
def test_a3_live_buffers_are_preserved(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$DIR"
|
|
LIVE="$DIR/buf_sks_LIVE_2_2"
|
|
echo "LIVE_CONTENT" > "$LIVE"
|
|
|
|
SHIM="$DIR/herdr"
|
|
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
|
|
|
if [ -f "$LIVE" ]; then
|
|
echo "LIVE:PRESERVED"
|
|
else
|
|
echo "LIVE:DELETED"
|
|
fi
|
|
""")
|
|
assert "LIVE:PRESERVED" in res.stdout
|
|
|
|
|
|
# X-9 — Sweep is scoped to buf_sks_* and does not touch non-matching files
|
|
def test_a3_sweep_is_scoped(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$DIR"
|
|
OTHER="$DIR/other_old_file"
|
|
: > "$OTHER"
|
|
touch -t 202501010000 "$OTHER"
|
|
|
|
SHIM="$DIR/herdr"
|
|
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
|
|
|
if [ -f "$OTHER" ]; then
|
|
echo "OTHER:PRESERVED"
|
|
else
|
|
echo "OTHER:DELETED"
|
|
fi
|
|
""")
|
|
assert "OTHER:PRESERVED" in res.stdout
|
|
|
|
|
|
# X-10 — Abandoned atomic write DOT-temp files (.buf_sks_*.tmp) are collected
|
|
def test_a3_abandoned_dot_temps_are_collected(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$DIR"
|
|
DOT_TEMP="$DIR/.buf_sks_ORPHAN_1_1.99.tmp"
|
|
: > "$DOT_TEMP"
|
|
touch -t 202501010000 "$DOT_TEMP"
|
|
|
|
SHIM="$DIR/herdr"
|
|
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
|
|
|
if [ -f "$DOT_TEMP" ]; then
|
|
echo "DOT_TEMP:LEAK"
|
|
else
|
|
echo "DOT_TEMP:SWEPT"
|
|
fi
|
|
""")
|
|
assert "DOT_TEMP:SWEPT" in res.stdout
|
|
|
|
|
|
# X-11 — Failed rename cleans up temp file immediately and fails with exit code 1
|
|
def test_a3_failed_rename_cleans_up_and_fails(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$DIR"
|
|
# Block creation of buf_sks_blocked by placing a read-only directory in its place
|
|
BLOCK_DIR="$DIR/buf_sks_blocked"
|
|
mkdir -p "$BLOCK_DIR"
|
|
chmod 500 "$BLOCK_DIR"
|
|
|
|
SHIM="$DIR/herdr"
|
|
"$SHIM" set-buffer -b "sks_blocked" "PAYLOAD" 2>&1
|
|
RC=$?
|
|
echo "RC:$RC"
|
|
|
|
# Clean up permissions so test sandbox can clean up
|
|
chmod 700 "$BLOCK_DIR"
|
|
|
|
# Check if any .tmp files were left behind
|
|
ls -a "$DIR" | grep -q '\.tmp$' && echo "TEMP:LEAKED" || echo "TEMP:CLEAN"
|
|
""")
|
|
assert "RC:1" in res.stdout
|
|
assert "TEMP:CLEAN" in res.stdout
|
|
|
|
|
|
# X-12 — Scoped GC preserves unrelated dot-files
|
|
def test_a3_unrelated_dot_files_preserved(mam_sandbox):
|
|
res = _bash(mam_sandbox, """
|
|
DIR="$WORKSPACE_ROOT/.mam/shim"
|
|
mkdir -p "$DIR"
|
|
DOT_OTHER="$DIR/.some_other_dot_file"
|
|
: > "$DOT_OTHER"
|
|
touch -t 202501010000 "$DOT_OTHER"
|
|
|
|
SHIM="$DIR/herdr"
|
|
"$SHIM" set-buffer -b "trigger_gc" "NEW_DATA"
|
|
|
|
if [ -f "$DOT_OTHER" ]; then
|
|
echo "DOT_OTHER:PRESERVED"
|
|
else
|
|
echo "DOT_OTHER:DELETED"
|
|
fi
|
|
""")
|
|
assert "DOT_OTHER:PRESERVED" in res.stdout
|