2280 lines
115 KiB
Bash
2280 lines
115 KiB
Bash
#!/usr/bin/env bats
|
|
# Tests for the isolation helpers in scripts/cli/_common.sh — the --isolate
|
|
# runtime-state plumbing that lets multiple `showcase test --isolate` runs share
|
|
# one host without colliding on ports, compose project names, or scratch dirs.
|
|
#
|
|
# Three behaviors are pinned here:
|
|
# 1. Runtime state lives under XDG_STATE_HOME (NOT /tmp). The slot registry and
|
|
# per-run scratch dir are rooted at $XDG_STATE_HOME/copilotkit/showcase, with
|
|
# the documented $HOME/.local/state fallback when XDG_STATE_HOME is unset.
|
|
# 2. Stale slots are reaped by compose-project liveness: a slot whose recorded
|
|
# project has no live containers is reclaimed; one with a live container is
|
|
# left alone (so a --keep'd stack is never stolen out from under).
|
|
# 3. restore_isolation honors --keep: when ISOLATE_KEEP is set it does NOT
|
|
# compose-down, does NOT remove the run dir, does NOT release the slot, and
|
|
# prints a survival notice (project, slot, the three +offset host ports, and
|
|
# the exact teardown command). ISOLATE_KEEP must be a GLOBAL that survives
|
|
# cmd_test returning — the EXIT trap fires at top-level exit, where a
|
|
# function-local flag has already unwound.
|
|
#
|
|
# NB on assertion style: bats-core runs test bodies with errexit semantics
|
|
# (`set -eET` plus an ERR trap), so a bare `[[ ... ]]` that fails DOES fail the
|
|
# test — even on a non-final line. We still write assertions as
|
|
# `[[ ... ]] || fail "message"` for the diagnostic message on failure, and for
|
|
# robustness in contexts where errexit is suppressed (e.g. inside && / ||
|
|
# chains or condition positions).
|
|
|
|
# fail <msg> — print the message and abort the test. The message goes to
|
|
# STDOUT: bats reliably displays a failed test's stdout, but not its stderr.
|
|
fail() {
|
|
echo "$1"
|
|
return 1
|
|
}
|
|
|
|
# require_python3 — skip tests that drive apply_isolation through its real
|
|
# python3 ports/compose rewriters; without this, a python3-less machine fails
|
|
# those tests with a bare "command not found" instead of a clear skip.
|
|
require_python3() {
|
|
command -v python3 >/dev/null 2>&1 || skip "python3 required (apply_isolation rewrites ports/compose files with it)"
|
|
}
|
|
|
|
# _touch_age <seconds-ago> <path> — backdate a path's mtime portably. BSD
|
|
# touch has no GNU-style `-d '-N seconds'`; compute the absolute `-t` stamp
|
|
# with python3 instead (callers must require_python3). DST-safe: the stamp is
|
|
# computed in UTC (time.gmtime) and interpreted in UTC (TZ=UTC for touch) —
|
|
# a localtime stamp straddling a DST transition would be off by an hour (or
|
|
# land in a nonexistent/ambiguous wall-clock time).
|
|
_touch_age() {
|
|
local stamp
|
|
stamp="$(python3 -c "import time; print(time.strftime('%Y%m%d%H%M.%S', time.gmtime(time.time() - $1)))")"
|
|
TZ=UTC touch -t "$stamp" "$2"
|
|
}
|
|
|
|
# _assert_stub_sentinel_logged — prove the docker-stub→DOCKER_LOG pipeline is
|
|
# alive before drawing any conclusion from the log's contents. Several tests
|
|
# assert the ABSENCE of compose-down lines; without this sentinel, a silently
|
|
# broken pipeline (DOCKER_LOG unset/unwritable in the child, an export
|
|
# regression, the stub falling off PATH) makes those absence checks pass
|
|
# vacuously — "no evidence of wrongdoing" instead of "verified right-doing".
|
|
# Each caller runs `docker version` before the code under test (and after any
|
|
# log truncation); this asserts that invocation actually landed in the log.
|
|
_assert_stub_sentinel_logged() {
|
|
[ -f "$DOCKER_LOG" ] \
|
|
|| fail "docker stub log missing — stub/DOCKER_LOG pipeline broken, absence assertions would be vacuous"
|
|
grep -qx "version" "$DOCKER_LOG" \
|
|
|| fail "sentinel 'docker version' invocation not logged — stub/DOCKER_LOG pipeline broken: $(cat "$DOCKER_LOG")"
|
|
}
|
|
|
|
# _seed_live_owner <slot-dir> [pid] — record a LIVE, start-time-VERIFIED owner
|
|
# in <slot-dir>: write the pid file AND a matching pid.start (the new
|
|
# anti-PID-reuse fingerprint). Under the post-Change-1 owner contract a bare
|
|
# `pid` file with NO pid.start is "unverifiable" (treated as dead), so every
|
|
# fixture that wants a slot to read `live` via its owning PID must seed BOTH
|
|
# files — that is what this helper does. Defaults to $$ (the bats process,
|
|
# definitely alive). The pid.start is produced by the SAME _pid_start_time
|
|
# helper the classifier reads, so the recorded and current values match. The
|
|
# pid file is written FIRST (preserving the claim-time write order the
|
|
# classifier's "missing pid ⇒ owner gone" signal relies on).
|
|
#
|
|
# NB: this sources _common.sh into the CURRENT shell to reach _pid_start_time.
|
|
# Tests that have already run load_common are unaffected; tests that have not
|
|
# get a harmless extra source (the path vars are re-pointed by load_common).
|
|
_seed_live_owner() {
|
|
local slotdir="${1:?slot dir required}"
|
|
local pid="${2:-$$}"
|
|
# shellcheck disable=SC1090
|
|
type _pid_start_time >/dev/null 2>&1 || source "$COMMON"
|
|
echo "$pid" > "$slotdir/pid"
|
|
_pid_start_time "$pid" > "$slotdir/pid.start"
|
|
}
|
|
|
|
setup() {
|
|
COMMON="$BATS_TEST_DIRNAME/../cli/_common.sh"
|
|
|
|
# ── Fake docker on PATH ────────────────────────────────────────────────────
|
|
# apply_isolation runs `$COMPOSE_CMD down` and the reaper runs
|
|
# `docker ps -q --filter ...`. Both must be stubbed so no real docker is hit.
|
|
# The `ps` behavior is driven by $DOCKER_PS_OUTPUT so individual tests can make
|
|
# a project look live (non-empty) or dead (empty), and by $DOCKER_PS_EXIT so
|
|
# tests can simulate a docker daemon failure (non-zero exit, no output).
|
|
# `compose ... down` failure is driven by $DOCKER_COMPOSE_DOWN_EXIT so tests
|
|
# can simulate a teardown that leaves the stack standing.
|
|
STUB_DIR="$BATS_TEST_TMPDIR/stub"
|
|
mkdir -p "$STUB_DIR"
|
|
cat > "$STUB_DIR/docker" <<'STUB'
|
|
#!/usr/bin/env bash
|
|
# Record every invocation so tests can assert on WHICH compose commands ran
|
|
# (e.g. that a failed --isolate never composes down the default stack).
|
|
printf '%s\n' "$*" >> "${DOCKER_LOG:-/dev/null}"
|
|
case "$1" in
|
|
ps)
|
|
[ "${DOCKER_PS_EXIT:-0}" -eq 0 ] || exit "${DOCKER_PS_EXIT}"
|
|
printf '%s' "${DOCKER_PS_OUTPUT:-}"; [ -z "${DOCKER_PS_OUTPUT:-}" ] || echo ;;
|
|
compose)
|
|
# Ordering probe: when REAP_ORDER_CHECK_DIR is set, snapshot whether that
|
|
# dir still exists at the moment a compose-down is invoked. Lets tests pin
|
|
# "compose-down runs BEFORE state deletion" (the reap's split-brain
|
|
# guarantee) instead of only "compose-down ran at some point".
|
|
if [[ " $* " == *" down "* ]] && [ -n "${REAP_ORDER_CHECK_DIR:-}" ]; then
|
|
if [ -d "$REAP_ORDER_CHECK_DIR" ]; then
|
|
echo "REAP_ORDER_CHECK dir-present" >> "${DOCKER_LOG:-/dev/null}"
|
|
else
|
|
echo "REAP_ORDER_CHECK dir-absent" >> "${DOCKER_LOG:-/dev/null}"
|
|
fi
|
|
fi
|
|
# Match `down` as a discrete word: a bare *" down"* substring also matches
|
|
# the PROJECT NAME in `--project-name downfail`, silently failing commands
|
|
# that never ran `down` at all. Space-padding "$*" makes the word check
|
|
# exact whether `down` is interior or the final argument.
|
|
if [[ " $* " == *" down "* ]] && [ "${DOCKER_COMPOSE_DOWN_EXIT:-0}" -ne 0 ]; then
|
|
echo "stub: compose down failed" >&2
|
|
exit "${DOCKER_COMPOSE_DOWN_EXIT}"
|
|
fi ;; # otherwise swallow `docker compose ...` quietly
|
|
*) : ;;
|
|
esac
|
|
exit 0
|
|
STUB
|
|
chmod +x "$STUB_DIR/docker"
|
|
|
|
# ── Fake lsof on PATH ──────────────────────────────────────────────────────
|
|
# apply_isolation's port-conflict probe (and cmd-doctor.sh) shell out to
|
|
# `lsof -i :<port> -sTCP:LISTEN -P -n`. Real lsof on the dev box reports
|
|
# whatever happens to be listening, which is both non-deterministic and
|
|
# non-hermetic. The stub drives held-port reporting from $LSOF_HOLD_PORTS
|
|
# (comma-separated): ports in the list emit the standard lsof header + a
|
|
# single LISTEN row and exit 0; ports not in the list exit 1 with empty
|
|
# stdout, mirroring real lsof's no-match semantics. Every invocation is
|
|
# appended to $LSOF_LOG so tests can assert which ports got probed.
|
|
cat > "$STUB_DIR/lsof" <<'STUB'
|
|
#!/usr/bin/env bash
|
|
# Record every invocation so tests can assert WHICH ports were probed.
|
|
echo "lsof $*" >> "${LSOF_LOG:-/dev/null}"
|
|
# Extract the port from `-i :<port>`. cmd-doctor.sh and _common.sh both invoke
|
|
# `lsof -i :<port> -sTCP:LISTEN -P -n`, so the port arg is the one following
|
|
# `-i`, with a leading colon. Other args (-sTCP:LISTEN, -P, -n) are tolerated
|
|
# and ignored — the stub does not care about protocol/numeric flags.
|
|
port=""
|
|
while [ $# -gt 0 ]; do
|
|
if [ "$1" = "-i" ] && [[ "${2:-}" =~ ^:([0-9]+)$ ]]; then
|
|
port="${BASH_REMATCH[1]}"
|
|
break
|
|
fi
|
|
shift
|
|
done
|
|
[ -n "$port" ] || exit 1
|
|
# Check $LSOF_HOLD_PORTS (comma-separated). Pad with commas so a port doesn't
|
|
# substring-match a longer port (e.g. "341" vs "3410").
|
|
case ",${LSOF_HOLD_PORTS:-}," in
|
|
*",${port},"*)
|
|
echo "COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME"
|
|
echo "${LSOF_HOLD_COMMAND:-Python} 12345 user 5u IPv4 0x0000000000000000 0t0 TCP 127.0.0.1:${port} (LISTEN)"
|
|
exit 0 ;;
|
|
*) exit 1 ;;
|
|
esac
|
|
STUB
|
|
chmod +x "$STUB_DIR/lsof"
|
|
|
|
PATH="$STUB_DIR:$PATH"
|
|
|
|
# Invocation log for the docker stub (one line per call, "$*"-joined args).
|
|
export DOCKER_LOG="$BATS_TEST_TMPDIR/docker-invocations.log"
|
|
|
|
# Invocation log + held-port config for the lsof stub. Per-test tmpdir, so
|
|
# each test sees an empty log at start — no cross-test contamination.
|
|
export LSOF_LOG="$BATS_TEST_TMPDIR/lsof-invocations.log"
|
|
export LSOF_HOLD_PORTS=""
|
|
export LSOF_HOLD_COMMAND="Python"
|
|
|
|
# ── Fake SHOWCASE_ROOT with the two files apply_isolation rewrites ──────────
|
|
export SHOWCASE_ROOT_OVERRIDE="$BATS_TEST_TMPDIR/root"
|
|
mkdir -p "$SHOWCASE_ROOT_OVERRIDE/shared"
|
|
cat > "$SHOWCASE_ROOT_OVERRIDE/shared/local-ports.json" <<'JSON'
|
|
{
|
|
"mastra": 3104
|
|
}
|
|
JSON
|
|
cat > "$SHOWCASE_ROOT_OVERRIDE/docker-compose.local.yml" <<'YML'
|
|
services:
|
|
aimock:
|
|
container_name: showcase-aimock
|
|
ports:
|
|
- "4010:4010"
|
|
YML
|
|
|
|
# ── XDG state base ─────────────────────────────────────────────────────────
|
|
export XDG_STATE_HOME="$BATS_TEST_TMPDIR/xdg"
|
|
|
|
# Default: dead project (no live containers), healthy docker daemon, compose
|
|
# down succeeds, unless a test overrides them.
|
|
export DOCKER_PS_OUTPUT=""
|
|
export DOCKER_PS_EXIT=0
|
|
export DOCKER_COMPOSE_DOWN_EXIT=0
|
|
}
|
|
|
|
# load_common — source _common.sh, then repoint the path vars at the fake root.
|
|
# _common.sh computes SHOWCASE_ROOT from its own location at source time, so we
|
|
# override the derived paths afterward to point at the per-test scratch root.
|
|
load_common() {
|
|
# shellcheck disable=SC1090
|
|
source "$COMMON"
|
|
SHOWCASE_ROOT="$SHOWCASE_ROOT_OVERRIDE"
|
|
COMPOSE_FILE="$SHOWCASE_ROOT/docker-compose.local.yml"
|
|
PORTS_FILE="$SHOWCASE_ROOT/shared/local-ports.json"
|
|
COMPOSE_CMD="docker compose -f $COMPOSE_FILE"
|
|
}
|
|
|
|
# ── Foundation smoke: lsof stub plumbing ────────────────────────────────────
|
|
# Pins the bats lsof stub used by the port-conflict tests: $LSOF_HOLD_PORTS
|
|
# (comma-separated) controls which ports the stub reports as LISTEN'd; every
|
|
# invocation is appended to $LSOF_LOG so callers can assert on what was probed.
|
|
# Kept PERMANENT (test #0) so a silent stub regression — stub off PATH, env
|
|
# vars unexported, log unwritable — fails LOUDLY here instead of corrupting
|
|
# every downstream port-conflict test with vacuous "no listener" results.
|
|
|
|
@test "lsof stub: emits listener line for held ports, exits 1 for free ports" {
|
|
# held: stub returns LISTEN row, exit 0
|
|
LSOF_HOLD_PORTS=3410 run lsof -i :3410 -sTCP:LISTEN -P -n
|
|
[ "$status" -eq 0 ]
|
|
[[ "$output" == *"Python"*"127.0.0.1:3410"*"(LISTEN)"* ]]
|
|
# log captured
|
|
grep -q "lsof -i :3410" "$LSOF_LOG"
|
|
|
|
# free: stub exits 1, empty stdout
|
|
LSOF_HOLD_PORTS="" run lsof -i :9999 -sTCP:LISTEN -P -n
|
|
[ "$status" -eq 1 ]
|
|
[ -z "$output" ]
|
|
}
|
|
|
|
# ── Change 1: runtime state under XDG_STATE_HOME ─────────────────────────────
|
|
|
|
@test "ISOLATE_SLOT_DIR is rooted under XDG_STATE_HOME, not /tmp" {
|
|
load_common
|
|
[[ "$ISOLATE_SLOT_DIR" == "$XDG_STATE_HOME/copilotkit/showcase/slots" ]] \
|
|
|| fail "ISOLATE_SLOT_DIR not under XDG_STATE_HOME: $ISOLATE_SLOT_DIR"
|
|
# (Regression note: the pre-XDG registry lived at /tmp/showcase-isolate-slots;
|
|
# the exact-path equality above already rules that out.)
|
|
}
|
|
|
|
@test "state base falls back to \$HOME/.local/state when XDG_STATE_HOME unset" {
|
|
unset XDG_STATE_HOME
|
|
export HOME="$BATS_TEST_TMPDIR/home"
|
|
load_common
|
|
run _showcase_state_base
|
|
[ "$status" -eq 0 ] || fail "_showcase_state_base failed: $output"
|
|
[[ "$output" == "$HOME/.local/state/copilotkit/showcase" ]] \
|
|
|| fail "wrong fallback base: $output"
|
|
}
|
|
|
|
@test "per-run dir lives under XDG state runs/<name>, not /tmp" {
|
|
require_python3
|
|
load_common
|
|
apply_isolation foo
|
|
[[ "$ISOLATE_TMPDIR" == "$XDG_STATE_HOME/copilotkit/showcase/runs/foo" ]] \
|
|
|| fail "run dir not under XDG state runs/<name>: $ISOLATE_TMPDIR"
|
|
# (Regression note: the pre-XDG scratch dir was the PID-keyed
|
|
# ${TMPDIR:-/tmp}/showcase-isolate-<pid>; the exact-path equality above
|
|
# already rules that out.)
|
|
[ -d "$ISOLATE_TMPDIR" ] || fail "run dir not created: $ISOLATE_TMPDIR"
|
|
}
|
|
|
|
@test "apply_isolation stamps the com.copilotkit.showcase.isolate label on every service" {
|
|
# The forward-stack self-id label is what lets `showcase reap` identify a
|
|
# harness-owned isolated project even when its slot record + run dir are both
|
|
# gone (a user-supplied --isolate <name> orphan). Drive the REAL rewrite and
|
|
# assert the label landed on the (only) service, right under its rewritten
|
|
# container_name, as a compose-native labels: block.
|
|
require_python3
|
|
load_common
|
|
apply_isolation foo
|
|
local rewritten="$ISOLATE_TMPDIR/docker-compose.local.yml"
|
|
[ -f "$rewritten" ] || fail "rewritten compose not created: $rewritten"
|
|
grep -q 'com.copilotkit.showcase.isolate: "1"' "$rewritten" \
|
|
|| fail "self-id label not stamped into the rewritten compose:
|
|
$(cat "$rewritten")"
|
|
# The label must sit under a labels: block, not be orphaned at the wrong
|
|
# indent — assert the labels: key is present too.
|
|
grep -q '^ labels:$' "$rewritten" \
|
|
|| fail "labels: block missing/mis-indented in the rewritten compose:
|
|
$(cat "$rewritten")"
|
|
# And it never touched the source compose (originals stay pristine).
|
|
grep -q 'com.copilotkit.showcase.isolate' "$SHOWCASE_ROOT/docker-compose.local.yml" \
|
|
&& fail "source compose was mutated with the label (must rewrite a COPY only)"
|
|
return 0
|
|
}
|
|
|
|
# ── Change 2: stale-slot reaping by compose-project liveness ─────────────────
|
|
|
|
@test "claim reaps a slot whose project has no live containers and no live owning PID" {
|
|
load_common
|
|
# Pre-seed slot 0 for a project with NO live containers (DOCKER_PS_OUTPUT="")
|
|
# and NO pid file (so there is no live owning PID to protect it either —
|
|
# under the three-signal order, the ABSENCE of live containers alone is not
|
|
# enough to reap while the owning process is still alive).
|
|
mkdir -p "$XDG_STATE_HOME/copilotkit/showcase/slots/0"
|
|
echo "ghost-proj" > "$XDG_STATE_HOME/copilotkit/showcase/slots/0/project"
|
|
|
|
export DOCKER_PS_OUTPUT=""
|
|
_claim_isolate_slot
|
|
# Slot 0 is reserved for the base stack, so the auto-picker lands on slot 1;
|
|
# the dead slot 0 is still reaped by the sweep (its ghost project file is
|
|
# gone), the claim just doesn't land there.
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "expected to claim slot 1 (slot 0 reserved), got: $ISOLATE_SLOT"
|
|
# The reap rm -rf'd the ghost slot dir: only apply_isolation (not the claim)
|
|
# writes a project file, so after a genuine reap the ghost project file must
|
|
# be GONE. (A `run cat`+`!= ghost-proj` check here would pass vacuously on
|
|
# empty output even if the reap never happened.)
|
|
[ ! -f "$XDG_STATE_HOME/copilotkit/showcase/slots/0/project" ] \
|
|
|| fail "ghost project file survived the reap"
|
|
}
|
|
|
|
@test "claim does NOT reap a slot whose project has a live container" {
|
|
load_common
|
|
mkdir -p "$XDG_STATE_HOME/copilotkit/showcase/slots/0"
|
|
echo "live-proj" > "$XDG_STATE_HOME/copilotkit/showcase/slots/0/project"
|
|
|
|
# Make docker ps report a live container id -> slot 0 must be preserved.
|
|
export DOCKER_PS_OUTPUT="abc123def456"
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "expected to skip live slot 0 and claim 1, got: $ISOLATE_SLOT"
|
|
# Slot 0 and its project file survive untouched.
|
|
[ -d "$XDG_STATE_HOME/copilotkit/showcase/slots/0" ] || fail "live slot 0 was wrongly reaped"
|
|
run cat "$XDG_STATE_HOME/copilotkit/showcase/slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "live slot 0 project file missing"
|
|
[[ "$output" == "live-proj" ]] || fail "live slot 0 project mangled: $output"
|
|
}
|
|
|
|
@test "slot claim persists the project name alongside the pid file" {
|
|
require_python3
|
|
load_common
|
|
apply_isolation myproj
|
|
local slotdir="$ISOLATE_SLOT_DIR/$ISOLATE_SLOT"
|
|
[ -f "$slotdir/project" ] || fail "no project file written for claimed slot"
|
|
run cat "$slotdir/project"
|
|
[ "$status" -eq 0 ] || fail "project file unreadable"
|
|
[[ "$output" == "myproj" ]] || fail "wrong project recorded: $output"
|
|
[ -f "$slotdir/pid" ] || fail "pid file no longer written"
|
|
}
|
|
|
|
@test "claim does NOT reap a project-recorded slot with zero containers but a LIVE owning PID" {
|
|
# The claim/start race: apply_isolation writes the project file minutes before
|
|
# any container exists (image builds happen later). Zero live containers must
|
|
# NOT be authoritative while the owning process is still alive.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "racer-proj" > "$slots/0/project"
|
|
_seed_live_owner "$slots/0" # this bats process — definitely alive (pid + pid.start)
|
|
|
|
export DOCKER_PS_OUTPUT="" # no containers yet (still building)
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "live-owner slot 0 was stolen; claimed: $ISOLATE_SLOT"
|
|
[ -d "$slots/0" ] || fail "live-owner slot 0 was reaped"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "slot 0 project file missing after sweep"
|
|
[[ "$output" == "racer-proj" ]] || fail "slot 0 project mangled: $output"
|
|
}
|
|
|
|
@test "claim leaves a project-recorded slot alone when docker ps fails" {
|
|
# A docker daemon failure must read as "cannot verify", never as "no
|
|
# containers" — otherwise a daemon hiccup mass-reaps live slots.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "unknowable-proj" > "$slots/0/project"
|
|
|
|
export DOCKER_PS_EXIT=1
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "unverifiable slot 0 was claimed: $ISOLATE_SLOT"
|
|
[ -d "$slots/0" ] || fail "slot 0 reaped despite docker ps failure"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "slot 0 project file missing after sweep"
|
|
[[ "$output" == "unknowable-proj" ]] || fail "slot 0 project mangled: $output"
|
|
}
|
|
|
|
@test "legacy slot without project file survives set -e and is reaped via dead-PID fallback" {
|
|
# bin/showcase runs `set -euo pipefail` and sources _common.sh; a slot dir
|
|
# with no project file must not kill the script (cat exits 1), and the
|
|
# documented PID fallback must actually reap it.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0" # legacy: NO project file
|
|
bash -c 'exit 0' &
|
|
local dead_pid=$!
|
|
wait "$dead_pid" || true
|
|
# PID-reuse guard: the fixture's premise is a DEAD owning pid; if the OS
|
|
# recycled it to a live process between wait and the sweep, skip rather than
|
|
# flake.
|
|
if kill -0 "$dead_pid" 2>/dev/null; then
|
|
skip "PID $dead_pid was recycled to a live process — dead-PID fixture invalid"
|
|
fi
|
|
echo "$dead_pid" > "$slots/0/pid"
|
|
|
|
run bash -euo pipefail -c "source '$COMMON'; _claim_isolate_slot; echo \"CLAIMED=\$ISOLATE_SLOT\""
|
|
[ "$status" -eq 0 ] || fail "script died under set -euo pipefail on legacy slot: $output"
|
|
# Slot 0 is reserved for the base stack, so the auto-picker lands on slot 1
|
|
# even though the dead legacy slot 0 was reaped by the sweep.
|
|
[[ "$output" == *"CLAIMED=1"* ]] || fail "dead legacy slot 0 not reaped (claim should be 1, slot 0 reserved): $output"
|
|
}
|
|
|
|
@test "claim does NOT reap a kept-stack slot (owner dead, containers live)" {
|
|
# --keep leaves the stack standing after the owning process exits; container
|
|
# liveness alone must keep protecting the slot.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "keeper-proj" > "$slots/0/project"
|
|
bash -c 'exit 0' &
|
|
local dead_pid=$!
|
|
wait "$dead_pid" || true
|
|
# PID-reuse guard (see the legacy-slot test above): a recycled live pid
|
|
# would invalidate the dead-owner premise.
|
|
if kill -0 "$dead_pid" 2>/dev/null; then
|
|
skip "PID $dead_pid was recycled to a live process — dead-PID fixture invalid"
|
|
fi
|
|
echo "$dead_pid" > "$slots/0/pid"
|
|
|
|
export DOCKER_PS_OUTPUT="abc123def456" # containers still up
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "kept-stack slot 0 was stolen: $ISOLATE_SLOT"
|
|
[ -d "$slots/0" ] || fail "kept-stack slot 0 was reaped"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "kept-stack project file missing after sweep"
|
|
[[ "$output" == "keeper-proj" ]] || fail "kept-stack project mangled: $output"
|
|
}
|
|
|
|
@test "claim reaps an over-age legacy slot whose pid file is empty (inconclusive)" {
|
|
# An empty/garbage pid file used to dodge BOTH the pid check and the
|
|
# `[ ! -f pid ]`-gated age fallback, leaking the slot forever.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0" # legacy: no project file
|
|
: > "$slots/0/pid" # empty pid file → pid check inconclusive
|
|
touch -t 202001010000 "$slots/0" # mtime far beyond the 2h threshold
|
|
|
|
_claim_isolate_slot
|
|
# Slot 0 is reserved for the base stack, so the auto-picker lands on slot 1.
|
|
# The empty-pid over-age slot 0 was still reaped by the sweep (verified by
|
|
# the slot dir being gone), the claim just doesn't land there.
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "empty-pid over-age slot 0 leaked or claim path broken; claimed: $ISOLATE_SLOT"
|
|
[ ! -d "$slots/0" ] || fail "empty-pid over-age slot 0 was not reaped"
|
|
}
|
|
|
|
@test "claim does NOT reap a RECENT project-recorded slot whose pid file is garbage (inconclusive)" {
|
|
# Truncated-pid-write race: a live owner mid-build (project recorded, zero
|
|
# containers yet) whose pid file write was truncated/corrupted must NOT lose
|
|
# its slot — "no live owner" was never actually verified. An unreadable/
|
|
# empty/non-numeric pid is INCONCLUSIVE and must fall through to the age
|
|
# fallback; a RECENT slot therefore survives the sweep.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "truncated-proj" > "$slots/0/project"
|
|
echo "not-a-pid" > "$slots/0/pid" # garbage contents — pid check inconclusive
|
|
|
|
export DOCKER_PS_OUTPUT="" # zero containers (still building)
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "inconclusive-pid slot 0 was stolen; claimed: $ISOLATE_SLOT"
|
|
[ -d "$slots/0" ] || fail "inconclusive-pid slot 0 was reaped"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "slot 0 project file missing after sweep"
|
|
[[ "$output" == "truncated-proj" ]] || fail "slot 0 project mangled: $output"
|
|
}
|
|
|
|
@test "claim reaps an OVER-AGE project-recorded slot whose pid file is garbage via the age fallback" {
|
|
# Companion to the test above: inconclusive-pid slots are not protected
|
|
# FOREVER — the age fallback still reclaims them (and their runs/<project>
|
|
# scratch dir) once they exceed ISOLATE_STALE_THRESHOLD.
|
|
load_common
|
|
local base="$XDG_STATE_HOME/copilotkit/showcase"
|
|
local slots="$base/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "truncated-proj" > "$slots/0/project"
|
|
echo "not-a-pid" > "$slots/0/pid"
|
|
mkdir -p "$base/runs/truncated-proj"
|
|
touch -t 202001010000 "$slots/0" # mtime far beyond the 2h threshold
|
|
|
|
export DOCKER_PS_OUTPUT=""
|
|
_claim_isolate_slot
|
|
# Slot 0 is reserved for the base stack, so the auto-picker lands on slot 1.
|
|
# The over-age inconclusive-pid slot 0 was still reaped by the sweep
|
|
# (verified by the ghost project record and orphan run dir being gone), the
|
|
# claim just doesn't land there.
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "over-age inconclusive-pid slot 0 leaked or claim path broken; claimed: $ISOLATE_SLOT"
|
|
[ ! -f "$slots/0/project" ] || fail "ghost project record survived the age-fallback reap"
|
|
[ ! -d "$base/runs/truncated-proj" ] || fail "orphan run dir leaked: $base/runs/truncated-proj"
|
|
}
|
|
|
|
@test "reaping a dead slot also removes its runs/<project> scratch dir" {
|
|
# Orphan-leak regression: the sweep used to remove only the slot dir; the
|
|
# crashed run's runs/<project> scratch dir was cleaned by NOTHING (the owner
|
|
# is dead, and restore_isolation only removes the CURRENT run's dir), so
|
|
# orphans accumulated under XDG state forever.
|
|
load_common
|
|
local base="$XDG_STATE_HOME/copilotkit/showcase"
|
|
mkdir -p "$base/slots/0"
|
|
echo "ghost-proj" > "$base/slots/0/project" # dead project, no pid file
|
|
mkdir -p "$base/runs/ghost-proj"
|
|
touch "$base/runs/ghost-proj/docker-compose.local.yml"
|
|
|
|
export DOCKER_PS_OUTPUT="" # no live containers → project-stale reap
|
|
_claim_isolate_slot
|
|
# Slot 0 is reserved for the base stack, so the auto-picker lands on slot 1.
|
|
# The dead slot 0 is still reaped by the sweep (verified by the ghost slot
|
|
# dir and orphan run dir being gone), the claim just doesn't land there.
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "expected slot 1 (slot 0 reserved), got: $ISOLATE_SLOT"
|
|
[ ! -f "$base/slots/0/project" ] || fail "ghost slot 0 was not reaped"
|
|
[ ! -d "$base/runs/ghost-proj" ] || fail "orphan run dir leaked: $base/runs/ghost-proj"
|
|
# The sweep released its own lock on the way out.
|
|
[ ! -d "$base/slots/.sweep.lock" ] || fail "sweep lock left behind after a normal sweep"
|
|
}
|
|
|
|
@test "reaping a dead slot composes the project down before deleting its state" {
|
|
# Stopped-but-present remnants: container-liveness protection is RUNNING
|
|
# containers only (docker ps -q). A --keep'd stack whose containers were
|
|
# stopped (manual docker stop, daemon restart, host reboot) reaches the reap
|
|
# with stopped containers and named volumes still present — deleting the run
|
|
# dir + slot while those remain would strand them with no compose state
|
|
# (split-brain). The reap must therefore attempt a best-effort, project-
|
|
# labeled compose-down FIRST.
|
|
load_common
|
|
local base="$XDG_STATE_HOME/copilotkit/showcase"
|
|
mkdir -p "$base/slots/0"
|
|
echo "stopped-keeper" > "$base/slots/0/project" # validated record, no pid file
|
|
mkdir -p "$base/runs/stopped-keeper"
|
|
|
|
export DOCKER_PS_OUTPUT="" # containers stopped → no RUNNING ones → reap path
|
|
# Ordering probe (see the stub): snapshot the runs dir's existence at
|
|
# compose-down time, so "down BEFORE state deletion" is pinned directly.
|
|
export REAP_ORDER_CHECK_DIR="$base/runs/stopped-keeper"
|
|
: > "$DOCKER_LOG"
|
|
docker version
|
|
_claim_isolate_slot
|
|
# Slot 0 is reserved for the base stack, so the auto-picker lands on slot 1.
|
|
# The dead slot 0 is still reaped by the sweep (verified by the ghost project
|
|
# record and runs dir being gone), the claim just doesn't land there.
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "expected slot 1 (slot 0 reserved), got: $ISOLATE_SLOT"
|
|
[ ! -f "$base/slots/0/project" ] || fail "ghost project record survived the reap"
|
|
[ ! -d "$base/runs/stopped-keeper" ] || fail "runs dir survived the reap"
|
|
# The remnant cleanup ran: a project-labeled compose down carrying --volumes.
|
|
_assert_stub_sentinel_logged
|
|
run grep -E -- "compose -p stopped-keeper down --remove-orphans --volumes" "$DOCKER_LOG"
|
|
[ "$status" -eq 0 ] \
|
|
|| fail "reap did not compose the dead project down: $(cat "$DOCKER_LOG")"
|
|
# …and it ran BEFORE the state deletion: the runs dir still existed when the
|
|
# down was invoked (deleting state first would strand stopped containers /
|
|
# named volumes with no compose state — the split-brain the order prevents).
|
|
run grep -qx -- "REAP_ORDER_CHECK dir-present" "$DOCKER_LOG"
|
|
[ "$status" -eq 0 ] \
|
|
|| fail "compose-down ran AFTER the runs dir was deleted (reap order regression): $(cat "$DOCKER_LOG")"
|
|
}
|
|
|
|
@test "a slot whose project record fails the traversal guard is left intact for inspection" {
|
|
# Path-traversal guard vs. orphaning: a corrupted/tampered project record
|
|
# (e.g. "../..") must never be interpolated into rm -rf — but the OLD code
|
|
# still rm -rf'd the SLOT dir, destroying the only pointer to whatever runs
|
|
# dir the record names. The guard now leaves the whole slot in place for
|
|
# manual inspection instead of half-destroying the evidence.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
printf '%s\n' '../evil' > "$slots/0/project" # fails [a-z0-9][a-z0-9_-]*
|
|
|
|
export DOCKER_PS_OUTPUT="" # no live containers, no pid file → reap path
|
|
_claim_isolate_slot
|
|
# The suspicious slot survives — with its record — and the claim moves on.
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "expected slot 1 (suspicious slot 0 preserved), got: $ISOLATE_SLOT"
|
|
[ -d "$slots/0" ] || fail "suspicious slot 0 was reaped (record/evidence destroyed)"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "suspicious slot 0 record removed"
|
|
[[ "$output" == "../evil" ]] || fail "suspicious record mangled: $output"
|
|
}
|
|
|
|
@test "a slot whose project record reads the RESERVED 'showcase' is left intact — reap never composes the default stack down" {
|
|
# Reserved-name guard in the REAPER: 'showcase' IS the default stack's
|
|
# compose project name, and it PASSES the charset regex — so a slot record
|
|
# reading 'showcase' (a corrupt record, or one written by an older CLI
|
|
# version before apply_isolation reserved the name) used to make the reap
|
|
# run `compose -p showcase down --remove-orphans --volumes` against the
|
|
# user's LIVE DEFAULT stack, destroying the PocketBase named volume.
|
|
# apply_isolation reserves the name at claim time, but the reaper must not
|
|
# trust records: the reserved name gets the same treatment as the
|
|
# path-traversal guard — warn and leave the whole slot in place for manual
|
|
# inspection (no compose-down, no state removal).
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "showcase" > "$slots/0/project" # passes [a-z0-9][a-z0-9_-]*
|
|
|
|
export DOCKER_PS_OUTPUT="" # no live containers, no pid file → reap path
|
|
: > "$DOCKER_LOG"
|
|
docker version # sentinel: prove stub+DOCKER_LOG wiring before the claim
|
|
run _claim_isolate_slot
|
|
[ "$status" -eq 0 ] || fail "_claim_isolate_slot failed: $output"
|
|
|
|
# A loud warning names the offending record and why it is dangerous.
|
|
# (Checked before any `run grep` clobbers $output.)
|
|
[[ "$output" == *"RESERVED"* ]] \
|
|
|| fail "no reserved-name warning printed: $output"
|
|
[[ "$output" == *"$slots/0"* ]] \
|
|
|| fail "warning does not name the slot record: $output"
|
|
|
|
# The reserved-name slot survives — with its record — for inspection, and
|
|
# the claim moves on to the next free slot (the subshell's mkdir persists
|
|
# on the filesystem even though `run` ran the claim in a subshell).
|
|
[ -d "$slots/0" ] || fail "reserved-name slot 0 was reaped (default stack endangered)"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "reserved-name slot 0 record removed"
|
|
[[ "$output" == "showcase" ]] || fail "reserved-name record mangled: $output"
|
|
[ -d "$slots/1" ] || fail "claim did not proceed past the preserved slot 0"
|
|
|
|
# NO compose-down of the 'showcase' project may have run — match BOTH
|
|
# compose project-flag spellings (the reaper uses -p; other paths use
|
|
# --project-name). The sentinel proves the stub→log pipeline is live, so
|
|
# this absence check cannot pass vacuously.
|
|
_assert_stub_sentinel_logged
|
|
run grep -E -- "(--project-name|-p) showcase down" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "reap composed the RESERVED 'showcase' project down (live default stack): $output"
|
|
}
|
|
|
|
@test "a fresh pre-existing .sweep.lock skips the sweep; claiming still succeeds" {
|
|
# TOCTOU sweep/claim race: two concurrent claimants could both observe slot 0
|
|
# stale — A reaps + re-claims it, then B reaps A's FRESH claim and claims the
|
|
# same slot (two owners, identical port offsets). The sweep is serialized by
|
|
# a .sweep.lock dir; a held (fresh) lock means another process is already
|
|
# sweeping, so this claimant must SKIP the sweep — the stale-looking slot
|
|
# survives — and proceed straight to claiming a free slot.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "ghost-proj" > "$slots/0/project" # stale-looking: dead project, no pid
|
|
mkdir -p "$slots/.sweep.lock" # fresh lock — sweeper "active"
|
|
|
|
export DOCKER_PS_OUTPUT=""
|
|
_claim_isolate_slot
|
|
# Sweep skipped: the stale-looking slot 0 survives untouched…
|
|
[ -d "$slots/0" ] || fail "sweep ran despite a held .sweep.lock"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "slot 0 project file missing after skipped sweep"
|
|
[[ "$output" == "ghost-proj" ]] || fail "slot 0 project mangled: $output"
|
|
# …and the claim proceeds to the next free slot (lock name never collides
|
|
# with the numeric claim loop).
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "expected slot 1 with sweep skipped, got: $ISOLATE_SLOT"
|
|
# The foreign sweeper's fresh lock is not ours to remove.
|
|
[ -d "$slots/.sweep.lock" ] || fail "foreign (fresh) sweep lock was removed"
|
|
}
|
|
|
|
@test "an over-age .sweep.lock is taken over and the sweep proceeds" {
|
|
# Crashed-sweeper recovery, with the DEDICATED lock threshold: the lock is
|
|
# held for seconds, so a leftover lock aged ~10 minutes must be taken over —
|
|
# under the old code the lock reused the 2-hour SLOT threshold
|
|
# (ISOLATE_STALE_THRESHOLD), so a crashed sweeper disabled stale reaping for
|
|
# 2 hours. The takeover is an atomic mv-to-tombstone (only one of two racing
|
|
# observers can win the rename), so no tombstone may leak either. The
|
|
# companion guarantee — a FRESH lock is NOT taken over — is pinned by the
|
|
# "fresh pre-existing .sweep.lock skips the sweep" test above.
|
|
require_python3 # _touch_age computes the backdated stamp with python3
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "ghost-proj" > "$slots/0/project" # dead project, no pid file
|
|
mkdir -p "$slots/.sweep.lock"
|
|
_touch_age 600 "$slots/.sweep.lock" # >60s lock threshold, <<2h slot threshold
|
|
|
|
export DOCKER_PS_OUTPUT=""
|
|
_claim_isolate_slot
|
|
# Takeover happened → the sweep ran → the dead slot 0 was reaped. The
|
|
# auto-picker lands on slot 1 (slot 0 reserved for the base stack); the
|
|
# absence of the ghost project record is what proves the sweep ran after the
|
|
# takeover.
|
|
[ "$ISOLATE_SLOT" = "1" ] \
|
|
|| fail "over-age sweep lock not taken over (sweep skipped); claimed: $ISOLATE_SLOT"
|
|
[ ! -f "$slots/0/project" ] \
|
|
|| fail "ghost project record survived — sweep did not run after the takeover"
|
|
# The winner's fresh lock was released on the way out, and no tombstone leaked.
|
|
[ ! -d "$slots/.sweep.lock" ] || fail "sweep lock left behind after takeover sweep"
|
|
run bash -c "ls -A '$slots' | grep -F '.sweep.lock.tomb'"
|
|
[ "$status" -ne 0 ] || fail "takeover tombstone leaked: $output"
|
|
}
|
|
|
|
@test "sweep-lock release is own-lock-aware: a taken-over lock is left in place" {
|
|
# Takeover/release race: if a slow sweeper's lock is taken over (heartbeat
|
|
# stalled — e.g. a wedged docker daemon pushed it over-age), the ORIGINAL
|
|
# holder's release must not remove the TAKEOVER's fresh lock — that would
|
|
# open the door to a THIRD concurrent sweeper. Ownership is the pid file
|
|
# written into the lock dir at acquisition; release only removes a lock
|
|
# that still carries OUR pid. Tested via direct invocation of the release
|
|
# helper (pausing a real sweep mid-flight is not deterministic in bats).
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
local lock="$slots/.sweep.lock"
|
|
|
|
# Simulate the takeover: the lock exists but carries a DIFFERENT holder's
|
|
# pid (the takeover rewrote it). PID 1 is never this test process.
|
|
mkdir -p "$lock"
|
|
echo "1" > "$lock/pid"
|
|
run _release_sweep_lock "$lock"
|
|
[ "$status" -eq 0 ] || fail "_release_sweep_lock failed on a foreign lock: $output"
|
|
[ -d "$lock" ] || fail "release removed a lock owned by ANOTHER holder (takeover's lock destroyed)"
|
|
[[ "$output" == *"taken over"* ]] || fail "no takeover warning printed: $output"
|
|
|
|
# Control: a lock carrying OUR pid IS removed on release.
|
|
echo "$$" > "$lock/pid"
|
|
run _release_sweep_lock "$lock"
|
|
[ "$status" -eq 0 ] || fail "_release_sweep_lock failed on our own lock: $output"
|
|
[ ! -d "$lock" ] || fail "release left our own lock behind"
|
|
}
|
|
|
|
@test "an over-age takeover tombstone is reclaimed on the next claim; a fresh one survives" {
|
|
# Crashed-takeover recovery: a sweeper that died between the takeover mv and
|
|
# its rm -rf leaves .sweep.lock.tomb.<pid> behind — dot-named, invisible to
|
|
# every glob, cleaned by nothing. The claim path must reclaim tombstones
|
|
# older than the LOCK threshold, while leaving a FRESH tombstone alone (it
|
|
# may belong to a takeover in flight: mv done, rm pending).
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/.sweep.lock.tomb.11111"
|
|
touch -t 202001010000 "$slots/.sweep.lock.tomb.11111" # far over the 60s lock threshold
|
|
mkdir -p "$slots/.sweep.lock.tomb.22222" # fresh — takeover may be in flight
|
|
|
|
_claim_isolate_slot
|
|
[ ! -d "$slots/.sweep.lock.tomb.11111" ] || fail "over-age tombstone leaked"
|
|
[ -d "$slots/.sweep.lock.tomb.22222" ] || fail "fresh (possibly in-flight) tombstone was removed"
|
|
}
|
|
|
|
@test "sweep heartbeat never recreates a removed sweep lock (no plain-file resurrection)" {
|
|
# Takeover/heartbeat race: the lock can be mv'd away (taken over) between
|
|
# heartbeats. The OLD heartbeat (bare `touch "$sweep_lock"`) then RECREATED
|
|
# the lock as a plain FILE — the takeover's `mkdir` failed against it and
|
|
# sweeping wedged until the 60s over-age self-heal. The heartbeat must
|
|
# refresh-only (touch -c behind a -d guard), never create. Direct
|
|
# invocation: run the sweep with the lock already gone — exactly the
|
|
# post-takeover view inside an iteration — and pin that no lock (file OR
|
|
# dir) materializes.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0" # one slot so the loop body (heartbeat) runs
|
|
echo "ghost-proj" > "$slots/0/project"
|
|
export DOCKER_PS_OUTPUT=""
|
|
_sweep_isolate_slots
|
|
[ ! -e "$slots/.sweep.lock" ] \
|
|
|| fail "heartbeat recreated the removed sweep lock: $(ls -ld "$slots/.sweep.lock")"
|
|
}
|
|
|
|
@test "an over-age PLAIN-FILE .sweep.lock (old heartbeat artifact) is taken over; claiming proceeds" {
|
|
# Forward-compat with the old bug's residue: a pre-fix heartbeat could leave
|
|
# the lock behind as a plain FILE. mkdir fails against it just like against
|
|
# a dir, so it must flow through the same over-age takeover (mv works on
|
|
# files too) instead of wedging the sweep/claim forever.
|
|
require_python3 # _touch_age computes the backdated stamp with python3
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "ghost-proj" > "$slots/0/project" # dead project, no pid file
|
|
: > "$slots/.sweep.lock" # plain FILE, not a dir
|
|
_touch_age 600 "$slots/.sweep.lock" # over the 60s lock threshold
|
|
export DOCKER_PS_OUTPUT=""
|
|
_claim_isolate_slot
|
|
# Takeover + sweep ran: the dead slot 0 was reaped. The auto-picker lands on
|
|
# slot 1 (slot 0 reserved for the base stack); the absence of slot 0's
|
|
# project file (asserted below alongside the lock-residue check) is what
|
|
# proves the sweep ran past the plain-file lock.
|
|
[ "$ISOLATE_SLOT" = "1" ] || fail "plain-file lock wedged the sweep; claimed: $ISOLATE_SLOT"
|
|
[ ! -f "$slots/0/project" ] || fail "ghost project record survived — sweep did not run"
|
|
# And nothing lock-shaped was left behind (takeover's own lock released).
|
|
[ ! -e "$slots/.sweep.lock" ] || fail "stale plain-file lock (or its takeover) left behind"
|
|
# No takeover tombstone leaked either (same guarantee the dir-lock variant
|
|
# pins: the winner disposes of the mv'd-aside tombstone).
|
|
run bash -c "ls -A '$slots' | grep -F '.sweep.lock.tomb'"
|
|
[ "$status" -ne 0 ] || fail "takeover tombstone leaked: $output"
|
|
}
|
|
|
|
@test "claim survives an over-age tombstone whose removal fails (set -e safety)" {
|
|
# The tombstone reclamation runs OUTSIDE the sweep lock by design, so two
|
|
# claimants can race the same rm: both observe the tombstone over-age, one
|
|
# wins, and the loser's mid-traversal ENOENT makes its rm exit nonzero —
|
|
# under bin/showcase's `set -euo pipefail` that used to kill the whole CLI.
|
|
# The race itself isn't deterministically reproducible in bats; an
|
|
# unremovable tombstone (non-writable dir with contents) forces the same
|
|
# nonzero rm exit deterministically.
|
|
#
|
|
# Root caveat: as root, `chmod a-w` does not block rm — the tombstone is
|
|
# removed anyway, the failure can't be forced, and the cleanup chmod below
|
|
# would then ERROR on the vanished path under bats errexit. Skip as root.
|
|
[ "$(id -u)" -ne 0 ] || skip "rm -rf succeeds as root — cannot force the tombstone rm failure"
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/.sweep.lock.tomb.99999/inner"
|
|
touch -t 202001010000 "$slots/.sweep.lock.tomb.99999" # far over the 60s threshold
|
|
chmod a-w "$slots/.sweep.lock.tomb.99999" # rm -rf cannot unlink 'inner'
|
|
run bash -euo pipefail -c "source '$COMMON'; _claim_isolate_slot; echo \"CLAIMED=\$ISOLATE_SLOT\""
|
|
# Let bats teardown clean up; guarded — the tombstone may be gone if rm
|
|
# unexpectedly succeeded, and a chmod error here must not mask the real
|
|
# assertions below.
|
|
chmod u+w "$slots/.sweep.lock.tomb.99999" 2>/dev/null || true
|
|
[ "$status" -eq 0 ] || fail "claim died under set -e on a failed tombstone rm: $output"
|
|
# Slot 0 is reserved for the base stack, so the auto-picker lands on slot 1.
|
|
# What this test really pins is that the claim PROCEEDED past the failed
|
|
# tombstone rm under set -e — landing on any valid slot is sufficient proof.
|
|
[[ "$output" == *"CLAIMED=1"* ]] \
|
|
|| fail "claim did not proceed past the failed tombstone rm: $output"
|
|
}
|
|
|
|
@test "takeover survives a failed tombstone disposal (set -e safety) and still sweeps" {
|
|
# The takeover winner's `rm -rf $lock_tombstone` is ACTIVELY raced: mv
|
|
# preserves the (already over-age) lock mtime, so the fresh tombstone is
|
|
# immediately over-age and concurrent claimants' tombstone-reclamation
|
|
# loops legitimately race its removal — the loser's nonzero rm must not
|
|
# kill the CLI under bin/showcase's `set -euo pipefail`. As in the
|
|
# tombstone-reclamation test above, the race itself isn't deterministic in
|
|
# bats; an unremovable tombstone (non-writable dir with contents, preserved
|
|
# verbatim by the takeover mv) forces the same nonzero rm exit.
|
|
require_python3 # _touch_age computes the backdated stamp with python3
|
|
[ "$(id -u)" -ne 0 ] || skip "rm -rf succeeds as root — cannot force the tombstone rm failure"
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "ghost-proj" > "$slots/0/project" # dead project, no pid file
|
|
mkdir -p "$slots/.sweep.lock/inner" # over-age lock the takeover will mv aside
|
|
_touch_age 600 "$slots/.sweep.lock" # >60s lock threshold
|
|
chmod a-w "$slots/.sweep.lock" # rm -rf of the mv'd tombstone cannot unlink 'inner'
|
|
export DOCKER_PS_OUTPUT=""
|
|
run bash -euo pipefail -c "source '$COMMON'; _claim_isolate_slot; echo \"CLAIMED=\$ISOLATE_SLOT\""
|
|
# Let bats teardown clean up the unremovable tombstone (pid-suffixed by the
|
|
# child, hence the glob); guarded — must not mask the assertions below.
|
|
chmod u+w "$slots"/.sweep.lock.tomb.* 2>/dev/null || true
|
|
[ "$status" -eq 0 ] || fail "takeover died under set -e on a failed tombstone rm: $output"
|
|
# The takeover completed despite the failed disposal: fresh lock acquired,
|
|
# sweep ran, dead slot 0 reaped. The auto-picker lands on slot 1 (slot 0
|
|
# reserved for the base stack); landing on any valid slot proves the sweep
|
|
# progressed past the failed tombstone disposal.
|
|
[[ "$output" == *"CLAIMED=1"* ]] \
|
|
|| fail "sweep did not run after the failed tombstone disposal: $output"
|
|
}
|
|
|
|
@test "_release_sweep_lock distinguishes a vanished lock from a takeover" {
|
|
# When the lock (or its pid ownership marker) is GONE entirely, the release
|
|
# used to mis-report "taken over (current holder pid: unknown)" — there is
|
|
# no holder to report. A vanished lock gets its own message; the takeover
|
|
# warn (pinned by the own-lock-aware test above) stays reserved for a lock
|
|
# that actually carries a DIFFERENT holder's pid.
|
|
load_common
|
|
local lock="$XDG_STATE_HOME/copilotkit/showcase/slots/.sweep.lock"
|
|
[ ! -e "$lock" ] || fail "precondition: lock unexpectedly exists"
|
|
run _release_sweep_lock "$lock"
|
|
[ "$status" -eq 0 ] || fail "_release_sweep_lock failed on a missing lock: $output"
|
|
[[ "$output" == *"vanished"* ]] || fail "missing lock not reported as vanished: $output"
|
|
[[ "$output" != *"taken over"* ]] || fail "missing lock mis-reported as a takeover: $output"
|
|
}
|
|
|
|
# ── Change 3: restore_isolation honors --keep ────────────────────────────────
|
|
|
|
@test "restore_isolation with keep set preserves slot + run dir and prints survival notice" {
|
|
require_python3
|
|
load_common
|
|
apply_isolation keepme
|
|
local slotdir="$ISOLATE_SLOT_DIR/$ISOLATE_SLOT"
|
|
local rundir="$ISOLATE_TMPDIR"
|
|
[ -d "$slotdir" ] || fail "precondition: slot dir missing after apply"
|
|
[ -d "$rundir" ] || fail "precondition: run dir missing after apply"
|
|
|
|
# Truncate the stub log: apply_isolation's idempotent pre-down already
|
|
# logged a `--project-name keepme down ...` line, which would poison the
|
|
# does-NOT-compose-down assertion below. The `docker version` sentinel then
|
|
# proves the stub→DOCKER_LOG pipeline is still live AFTER the truncation,
|
|
# so the absence check cannot pass vacuously.
|
|
: > "$DOCKER_LOG"
|
|
docker version
|
|
|
|
ISOLATE_KEEP=true
|
|
run restore_isolation
|
|
[ "$status" -eq 0 ] || fail "restore_isolation failed under keep: $output"
|
|
|
|
# Nothing torn down: slot + run dir survive, slot NOT released.
|
|
[ -d "$slotdir" ] || fail "kept slot dir was removed"
|
|
[ -d "$rundir" ] || fail "kept run dir was removed"
|
|
|
|
# Survival notice content: project name, the 3 +offset host ports, teardown cmd.
|
|
# Slot 0 is reserved for the base stack, so apply_isolation auto-picks slot 1
|
|
# (offset (1+1)*200 = 400): aimock 4010+400=4410, dashboard 3210+400=3610,
|
|
# pocketbase 8090+400=8490.
|
|
[[ "$output" == *"keepme"* ]] || fail "notice missing project name: $output"
|
|
[[ "$output" == *"4410"* ]] || fail "notice missing aimock host port: $output"
|
|
[[ "$output" == *"3610"* ]] || fail "notice missing dashboard host port: $output"
|
|
[[ "$output" == *"8490"* ]] || fail "notice missing pocketbase host port: $output"
|
|
[[ "$output" == *"docker compose -p keepme down"* ]] \
|
|
|| fail "notice missing literal teardown command: $output"
|
|
|
|
# The keep path's central promise: it does NOT compose-down the kept stack.
|
|
# Match BOTH compose project-flag spellings — restore_isolation's own down
|
|
# uses --project-name, but the reaper (and any keep-branch regression)
|
|
# spells it -p, which a --project-name-only grep would let slip through.
|
|
# (Last, because `run grep` clobbers the $output the notice checks read.)
|
|
_assert_stub_sentinel_logged
|
|
run grep -E -- "(--project-name|-p) keepme down" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] || fail "keep path composed the kept stack down: $output"
|
|
}
|
|
|
|
# The survival notice's human-readable hours must track ISOLATE_KEEP_TTL, not a
|
|
# hardcoded "(4h)". SHOWCASE_ISOLATE_KEEP_TTL is read at source time, so it must
|
|
# be exported BEFORE load_common sources _common.sh.
|
|
@test "survival notice's human-readable hours track an overridden ISOLATE_KEEP_TTL (not a stale (4h))" {
|
|
require_python3
|
|
export SHOWCASE_ISOLATE_KEEP_TTL=7200 # 2h, not the 4h default
|
|
load_common
|
|
[ "$ISOLATE_KEEP_TTL" = 7200 ] || fail "precondition: TTL override not picked up, got '$ISOLATE_KEEP_TTL'"
|
|
apply_isolation keepttl
|
|
|
|
ISOLATE_KEEP=true
|
|
run restore_isolation
|
|
[ "$status" -eq 0 ] || fail "restore_isolation failed under keep: $output"
|
|
|
|
# The seconds value must reflect the override.
|
|
[[ "$output" == *"7200s"* ]] || fail "notice missing overridden TTL seconds: $output"
|
|
# And the parenthetical hours must NOT contradict it with a stale 4h.
|
|
[[ "$output" != *"(4h)"* ]] || fail "notice still says (4h) for a 2h TTL: $output"
|
|
# The computed hours should read sensibly (2h here).
|
|
[[ "$output" == *"(2h)"* ]] || fail "notice missing computed (2h): $output"
|
|
}
|
|
|
|
@test "survival notice reads sensibly at the default ISOLATE_KEEP_TTL (4h)" {
|
|
require_python3
|
|
load_common
|
|
[ "$ISOLATE_KEEP_TTL" = 14400 ] || fail "precondition: expected default TTL 14400, got '$ISOLATE_KEEP_TTL'"
|
|
apply_isolation keepdef
|
|
|
|
ISOLATE_KEEP=true
|
|
run restore_isolation
|
|
[ "$status" -eq 0 ] || fail "restore_isolation failed under keep: $output"
|
|
|
|
[[ "$output" == *"14400s"* ]] || fail "notice missing default TTL seconds: $output"
|
|
[[ "$output" == *"(4h)"* ]] || fail "notice missing default (4h): $output"
|
|
}
|
|
|
|
# ── Change 4: a FAILED apply_isolation must never down the default stack ─────
|
|
#
|
|
# cmd-test.sh registers `trap restore_isolation EXIT` BEFORE calling
|
|
# apply_isolation, so a die() inside apply_isolation (invalid name, slot
|
|
# exhaustion, ...) fires the trap with half-initialized isolation state. If
|
|
# ISOLATE_ACTIVE was already true while COMPOSE_CMD still pointed at the
|
|
# ORIGINAL docker-compose.local.yml (no project name), restore_isolation would
|
|
# silently `compose down` the user's live DEFAULT stack. This pins that a
|
|
# failed apply_isolation results in NO compose-down against the default project.
|
|
|
|
@test "failed apply_isolation (invalid name) does not compose-down the default stack via the EXIT trap" {
|
|
load_common
|
|
# Mirror cmd-test.sh's real registration order in a child bash process run
|
|
# under bin/showcase's real shell options (-euo pipefail): a sentinel docker
|
|
# call proves the stub+log pipeline, the EXIT trap is armed, then
|
|
# apply_isolation dies on the invalid name ('MyName!' lowercases to
|
|
# 'myname!', which still fails [a-z0-9][a-z0-9_-]*).
|
|
run bash -euo pipefail -c "
|
|
source '$COMMON'
|
|
SHOWCASE_ROOT='$SHOWCASE_ROOT_OVERRIDE'
|
|
COMPOSE_FILE=\"\$SHOWCASE_ROOT/docker-compose.local.yml\"
|
|
PORTS_FILE=\"\$SHOWCASE_ROOT/shared/local-ports.json\"
|
|
COMPOSE_CMD=\"docker compose -f \$COMPOSE_FILE\"
|
|
docker version # sentinel: prove stub+DOCKER_LOG wiring BEFORE the trap
|
|
trap restore_isolation EXIT
|
|
apply_isolation 'MyName!'
|
|
"
|
|
[ "$status" -ne 0 ] || fail "apply_isolation unexpectedly succeeded with invalid name 'MyName!'"
|
|
|
|
# Stub pipeline verified live — now absence of compose-down is meaningful.
|
|
_assert_stub_sentinel_logged
|
|
|
|
# The EXIT trap must NOT have run any `compose ... down` (word-matched, so a
|
|
# project name merely CONTAINING "down" can't satisfy/poison the check, and
|
|
# the bare minimal `compose down` — no args in between — is matched too) —
|
|
# neither against the original compose file nor the (never-created)
|
|
# isolated project.
|
|
local default_compose="$SHOWCASE_ROOT_OVERRIDE/docker-compose.local.yml"
|
|
run grep -E "(^|[[:space:]])compose([[:space:]].*)?[[:space:]]down([[:space:]]|\$)" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "EXIT trap composed down after failed apply_isolation: $output"
|
|
run grep -F "$default_compose" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "EXIT trap touched the default compose file after failed apply_isolation: $output"
|
|
}
|
|
|
|
@test "apply_isolation dying AFTER the slot claim (python3 failure) does not compose-down the default stack via the EXIT trap" {
|
|
load_common
|
|
# The dangerous window is wider than the pre-claim die above: apply_isolation
|
|
# can also die AFTER the slot claim but BEFORE ISOLATE_ACTIVE=true — e.g. the
|
|
# python3 ports/compose rewriters failing. COMPOSE_CMD still points at the
|
|
# ORIGINAL compose file in that window, so the armed EXIT trap must not run
|
|
# any compose-down against the default stack. Poison python3 via a PATH stub
|
|
# in the child to force the post-claim die.
|
|
#
|
|
# NB: this test pins ONLY the no-default-stack-compose-down invariant; what
|
|
# happens to the slot/run dirs in this window is pinned elsewhere.
|
|
local pybad="$BATS_TEST_TMPDIR/pybad"
|
|
mkdir -p "$pybad"
|
|
printf '#!/usr/bin/env bash\nexit 1\n' > "$pybad/python3"
|
|
chmod +x "$pybad/python3"
|
|
|
|
run bash -euo pipefail -c "
|
|
PATH='$pybad':\"\$PATH\" # poison python3; docker stub stays on PATH
|
|
source '$COMMON'
|
|
SHOWCASE_ROOT='$SHOWCASE_ROOT_OVERRIDE'
|
|
COMPOSE_FILE=\"\$SHOWCASE_ROOT/docker-compose.local.yml\"
|
|
PORTS_FILE=\"\$SHOWCASE_ROOT/shared/local-ports.json\"
|
|
COMPOSE_CMD=\"docker compose -f \$COMPOSE_FILE\"
|
|
docker version # sentinel: prove stub+DOCKER_LOG wiring BEFORE the trap
|
|
trap restore_isolation EXIT
|
|
apply_isolation 'pyfail'
|
|
"
|
|
[ "$status" -ne 0 ] || fail "apply_isolation unexpectedly succeeded with a poisoned python3"
|
|
|
|
# Stub pipeline verified live — now absence of compose-down is meaningful.
|
|
_assert_stub_sentinel_logged
|
|
|
|
# No compose-down of ANY kind may have run in this window: the idempotent
|
|
# pre-down comes only after ISOLATE_ACTIVE=true (never reached here), and
|
|
# the trap's half-init cleanup is state-only. The pattern also matches the
|
|
# bare minimal `compose down` (no args between the two words), which a
|
|
# `compose .*[[:space:]]down` regex would miss.
|
|
run grep -E "(^|[[:space:]])compose([[:space:]].*)?[[:space:]]down([[:space:]]|\$)" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "EXIT trap composed down after post-claim die: $output"
|
|
}
|
|
|
|
# ── Leading-character rule: compose project names must START with [a-z0-9] ───
|
|
#
|
|
# docker compose accepts [a-z0-9][a-z0-9_-]* — '_' and '-' are valid only in
|
|
# the INTERIOR of a project name. Names like '_foo' or '-foo' used to slip
|
|
# through the CLI check (which only looked for chars outside [a-z0-9_-]) and
|
|
# then failed deep inside compose with exactly the opaque error this
|
|
# validation exists to prevent.
|
|
|
|
@test "apply_isolation rejects names with an invalid leading character" {
|
|
load_common
|
|
run apply_isolation '_foo'
|
|
[ "$status" -ne 0 ] || fail "apply_isolation unexpectedly accepted '_foo'"
|
|
[[ "$output" == *"must start with a lowercase letter or digit"* ]] \
|
|
|| fail "die message does not describe the leading-character rule: $output"
|
|
|
|
run apply_isolation '-foo'
|
|
[ "$status" -ne 0 ] || fail "apply_isolation unexpectedly accepted '-foo'"
|
|
[[ "$output" == *"must start with a lowercase letter or digit"* ]] \
|
|
|| fail "die message does not describe the leading-character rule: $output"
|
|
}
|
|
|
|
@test "apply_isolation accepts a normal name with interior '_' and '-'" {
|
|
require_python3
|
|
load_common
|
|
apply_isolation 'foo_bar-9'
|
|
[[ "$ISOLATE_TMPDIR" == "$XDG_STATE_HOME/copilotkit/showcase/runs/foo_bar-9" ]] \
|
|
|| fail "valid name 'foo_bar-9' was not accepted: $ISOLATE_TMPDIR"
|
|
}
|
|
|
|
# ── Reserved name: 'showcase' IS the default stack's compose project ─────────
|
|
#
|
|
# docker compose defaults the project name to the directory name, so the
|
|
# DEFAULT stack's project is literally "showcase". An accidental
|
|
# `--isolate showcase` used to sail through the charset validation (the
|
|
# container-name rewrite showcase- → showcase- is a no-op), and the idempotent
|
|
# pre-down then ran `--project-name showcase down --remove-orphans --volumes`
|
|
# — tearing down the user's live default stack, bypassing every guard this
|
|
# code has.
|
|
|
|
@test "apply_isolation rejects the reserved name 'showcase' before any compose command" {
|
|
load_common
|
|
docker version # sentinel: prove stub+DOCKER_LOG wiring BEFORE the call
|
|
run apply_isolation showcase
|
|
[ "$status" -ne 0 ] || fail "apply_isolation accepted the reserved name 'showcase'"
|
|
[[ "$output" == *"reserved"* ]] \
|
|
|| fail "die message does not say the name is reserved: $output"
|
|
|
|
# The uppercase variant must be caught too: the check runs AFTER the
|
|
# lowercase normalization, so 'Showcase' lowercases into the reserved name.
|
|
run apply_isolation Showcase
|
|
[ "$status" -ne 0 ] || fail "apply_isolation accepted 'Showcase' (lowercases to the reserved name)"
|
|
[[ "$output" == *"reserved"* ]] \
|
|
|| fail "die message for 'Showcase' does not say the name is reserved: $output"
|
|
|
|
# No compose command of ANY kind may have run — the rejection must land
|
|
# BEFORE the idempotent pre-down that would hit the default stack. Word-
|
|
# matched so the sweep's `ps -q --filter label=com.docker.compose.project=…`
|
|
# line can't satisfy/poison the check. The sentinel proves the stub→log
|
|
# pipeline is live first, so this absence check cannot pass vacuously.
|
|
_assert_stub_sentinel_logged
|
|
run grep -E "(^|[[:space:]])compose([[:space:]]|\$)" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "reserved-name rejection ran a compose command: $output"
|
|
}
|
|
|
|
@test "restore_isolation without keep removes run dir and releases slot" {
|
|
require_python3
|
|
load_common
|
|
apply_isolation dropme
|
|
local slotdir="$ISOLATE_SLOT_DIR/$ISOLATE_SLOT"
|
|
local rundir="$ISOLATE_TMPDIR"
|
|
[ -d "$slotdir" ] || fail "precondition: slot dir missing after apply"
|
|
[ -d "$rundir" ] || fail "precondition: run dir missing after apply"
|
|
|
|
# Truncate the stub log NOW: apply_isolation's idempotent pre-down logs a
|
|
# BYTE-IDENTICAL `--project-name dropme down --remove-orphans --volumes`
|
|
# line, so without the truncation the teardown assertion below would pass
|
|
# vacuously even if restore_isolation dropped --volumes or never composed
|
|
# down at all. After the truncation, any matching line can only have come
|
|
# from the teardown itself.
|
|
: > "$DOCKER_LOG"
|
|
|
|
ISOLATE_KEEP=false
|
|
run restore_isolation
|
|
[ "$status" -eq 0 ] || fail "restore_isolation failed: $output"
|
|
|
|
# NB `run` executes in a subshell, so the parent's ISOLATE_* are unchanged; we
|
|
# assert on the filesystem effects, which DO persist across the subshell.
|
|
[ ! -d "$rundir" ] || fail "run dir not removed on teardown: $rundir"
|
|
[ ! -d "$slotdir" ] || fail "slot not released on teardown: $slotdir"
|
|
|
|
# Volumes consistency: isolated stacks are ephemeral, and both printed
|
|
# manual teardown commands (keep notice + failed-down recovery) include
|
|
# --volumes. The AUTOMATIC teardown must agree, or project-scoped named
|
|
# volumes leak (unbounded for explicit names). The log was truncated after
|
|
# apply_isolation returned (see above), so this match is necessarily the
|
|
# teardown's own project-named down carrying --volumes.
|
|
run grep -E -- "--project-name dropme down --remove-orphans --volumes" "$DOCKER_LOG"
|
|
[ "$status" -eq 0 ] \
|
|
|| fail "automatic teardown down did not include --volumes: $(cat "$DOCKER_LOG")"
|
|
}
|
|
|
|
# ── Duplicate explicit --isolate name must not destroy a live run ────────────
|
|
#
|
|
# apply_isolation's idempotent pre-down keys on the compose project NAME, but
|
|
# the slot registry only enforces SLOT uniqueness. A second concurrent run
|
|
# passing the same explicit --isolate <name> used to get a different slot but
|
|
# the same compose project — its pre-down silently tore down the first run's
|
|
# containers mid-test (or a --keep-parked stack), and two slots then recorded
|
|
# the same project name, corrupting the liveness-reaping signal.
|
|
|
|
@test "apply_isolation dies on a duplicate explicit name without composing it down" {
|
|
load_common
|
|
# Seed slot 0 as another LIVE run already using "dupename": project recorded,
|
|
# owning PID alive (this bats process), so the stale-sweep must not reap it.
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "dupename" > "$slots/0/project"
|
|
_seed_live_owner "$slots/0"
|
|
|
|
export DOCKER_PS_OUTPUT="" # zero containers (still building) — PID protects
|
|
run apply_isolation dupename
|
|
[ "$status" -ne 0 ] || fail "apply_isolation accepted a duplicate name"
|
|
[[ "$output" == *"already in use by slot 0"* ]] \
|
|
|| fail "die message does not name the conflicting slot: $output"
|
|
|
|
# The live run's stack must be untouched: NO compose-down of any kind may
|
|
# have run (the pre-down comes only after the duplicate guard). The sweep is
|
|
# expected to have called `docker ps` for the recorded project — assert that
|
|
# exact invocation actually landed in the log (the stub logs one
|
|
# "$*"-joined line per call), not merely that the log file exists: a
|
|
# silently broken stub/DOCKER_LOG pipeline would make the absence check
|
|
# below pass vacuously.
|
|
[ -f "$DOCKER_LOG" ] \
|
|
|| fail "docker stub log missing — the sweep should have called docker ps; absence check would be vacuous"
|
|
grep -qxF -- "ps -q --filter label=com.docker.compose.project=dupename" "$DOCKER_LOG" \
|
|
|| fail "sweep's docker ps invocation not logged — stub/DOCKER_LOG pipeline broken, absence check would be vacuous: $(cat "$DOCKER_LOG")"
|
|
run grep -E "(^|[[:space:]])compose([[:space:]].*)?[[:space:]]down([[:space:]]|\$)" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "duplicate-name run composed down the live project: $output"
|
|
|
|
# Slot 0's record survives intact for the live owner.
|
|
[ -d "$slots/0" ] || fail "live slot 0 was reaped during the duplicate claim"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "live slot 0 project file missing after duplicate claim"
|
|
[[ "$output" == "dupename" ]] || fail "live slot 0 project mangled: $output"
|
|
}
|
|
|
|
@test "duplicate-name guard is claim-then-verify: loser backs off without touching the winner" {
|
|
# TOCTOU regression: the guard used to SCAN the other slots and only then
|
|
# write its own project record. Two concurrent same-name claims could both
|
|
# pass the scan and both record the name — one run's pre-down then tore the
|
|
# other's stack down mid-test, and reaping either slot deleted the SHARED
|
|
# runs/<name> dir. The fix writes our record FIRST, then scans: the later
|
|
# writer of any concurrent pair is guaranteed to see the earlier record and
|
|
# back off (die) before runs/<name> is ever created or touched.
|
|
load_common
|
|
local base="$XDG_STATE_HOME/copilotkit/showcase"
|
|
local slots="$base/slots"
|
|
# Winner: slot 0 already holds 'dup' (live owning PID) plus its run dir.
|
|
mkdir -p "$slots/0"
|
|
echo "dup" > "$slots/0/project"
|
|
_seed_live_owner "$slots/0"
|
|
mkdir -p "$base/runs/dup"
|
|
touch "$base/runs/dup/docker-compose.local.yml"
|
|
export DOCKER_PS_OUTPUT="" # zero containers (still building) — PID protects
|
|
|
|
# Ordering pin (claim BEFORE verify): `run` dies in a subshell with no trap,
|
|
# so the loser's slot dir survives for inspection — and it must already
|
|
# contain our own 'dup' record, written BEFORE the conflict scan. Under the
|
|
# old scan-then-write order this record never exists (that unrecorded window
|
|
# is exactly the TOCTOU hole).
|
|
run apply_isolation dup
|
|
[ "$status" -ne 0 ] || fail "apply_isolation accepted duplicate name 'dup'"
|
|
[[ "$output" == *"already in use by slot 0"* ]] \
|
|
|| fail "die message does not name the conflicting slot: $output"
|
|
run cat "$slots/1/project"
|
|
[ "$status" -eq 0 ] \
|
|
|| fail "loser did not record its claim BEFORE verifying (scan-then-write TOCTOU order)"
|
|
[[ "$output" == "dup" ]] || fail "loser recorded the wrong project: $output"
|
|
rm -rf "$slots/1" # clear the no-trap leftovers; trap cleanup is pinned next
|
|
|
|
# Real wiring (trap armed first, bin/showcase's shell options): the loser's
|
|
# die must clean up its OWN slot — including the just-written record — via
|
|
# the EXIT trap, and the winner's slot record AND runs/dup dir must be
|
|
# untouched (ISOLATE_TMPDIR is set only after the verify passes, so the
|
|
# loser's cleanup cannot reach the shared run dir).
|
|
run bash -euo pipefail -c "
|
|
source '$COMMON'
|
|
SHOWCASE_ROOT='$SHOWCASE_ROOT_OVERRIDE'
|
|
COMPOSE_FILE=\"\$SHOWCASE_ROOT/docker-compose.local.yml\"
|
|
PORTS_FILE=\"\$SHOWCASE_ROOT/shared/local-ports.json\"
|
|
COMPOSE_CMD=\"docker compose -f \$COMPOSE_FILE\"
|
|
trap restore_isolation EXIT
|
|
apply_isolation dup
|
|
"
|
|
[ "$status" -ne 0 ] || fail "apply_isolation accepted duplicate name 'dup' (trap run)"
|
|
[ ! -d "$slots/1" ] || fail "loser's slot/record leaked after the EXIT trap: $slots/1"
|
|
# Winner untouched: record intact, run dir (and its contents) intact.
|
|
[ -d "$slots/0" ] || fail "winner's slot was reaped by the losing claim"
|
|
run cat "$slots/0/project"
|
|
[ "$status" -eq 0 ] || fail "winner's project record missing"
|
|
[[ "$output" == "dup" ]] || fail "winner's project record mangled: $output"
|
|
[ -f "$base/runs/dup/docker-compose.local.yml" ] \
|
|
|| fail "winner's run dir was damaged by the losing claim"
|
|
}
|
|
|
|
@test "duplicate-name guard treats an EQUAL-mtime conflicting record as a conflict (tie = back off)" {
|
|
# Tie semantics pin: the backoff comparison is `-le` — a conflicting record
|
|
# that does NOT strictly postdate ours (older OR EQUAL epoch mtime) means we
|
|
# lose. A regression to `-lt` would make two same-second claimants BOTH
|
|
# treat the other's record as "strictly newer → the other backs off" and
|
|
# BOTH proceed as dual owners of one compose project. The other duplicate
|
|
# tests seed their conflicting record by wall clock, so whether they hit
|
|
# the equal or the older branch depends on second-boundary timing; force
|
|
# the tie DETERMINISTICALLY by stubbing _file_mtime to one fixed epoch for
|
|
# every path (safe here: slot 0 is pid-protected, so the sweep never
|
|
# consults mtimes, and no tombstones/locks exist).
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
echo "tiename" > "$slots/0/project"
|
|
_seed_live_owner "$slots/0" # live owner — sweep must not reap it
|
|
export DOCKER_PS_OUTPUT=""
|
|
|
|
_file_mtime() { echo 1700000000; } # our record and theirs: EQUAL mtimes
|
|
|
|
run apply_isolation tiename
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "equal-mtime conflicting record did not back the claimant off (tie regression: -le became -lt?)"
|
|
[[ "$output" == *"already in use by slot 0"* ]] \
|
|
|| fail "die message does not name the conflicting slot: $output"
|
|
}
|
|
|
|
@test "re-using a name after clean teardown still works" {
|
|
require_python3
|
|
load_common
|
|
apply_isolation reusable
|
|
ISOLATE_KEEP=false
|
|
restore_isolation
|
|
# The old slot was released and its record removed — the name is free again.
|
|
# (In production restore_isolation only runs at EXIT; it does not repoint
|
|
# COMPOSE_FILE/PORTS_FILE/COMPOSE_CMD at the originals. Re-point them here
|
|
# the way a fresh process sourcing _common.sh would see them.)
|
|
COMPOSE_FILE="$SHOWCASE_ROOT/docker-compose.local.yml"
|
|
PORTS_FILE="$SHOWCASE_ROOT/shared/local-ports.json"
|
|
COMPOSE_CMD="docker compose -f $COMPOSE_FILE"
|
|
apply_isolation reusable
|
|
[[ "$ISOLATE_NAME" == "reusable" ]] || fail "name not reusable after teardown"
|
|
}
|
|
|
|
# ── Half-initialized apply_isolation must clean its state via the EXIT trap ──
|
|
#
|
|
# If apply_isolation dies AFTER _claim_isolate_slot but BEFORE
|
|
# ISOLATE_ACTIVE=true (python3 failure, mkdir failure), the EXIT trap's
|
|
# restore_isolation must not compose-down anything (that guard protects the
|
|
# user's default stack) — but it used to ALSO leak the claimed slot dir and
|
|
# the runs/<name> dir silently. The slot self-healed via the stale-sweep; the
|
|
# runs dir never did. Pin that the trap now cleans both, with NO compose
|
|
# command at all.
|
|
|
|
@test "half-initialized apply_isolation cleans slot and run dir via EXIT trap without any compose" {
|
|
# Poison python3 so apply_isolation dies after the slot claim, project-file
|
|
# write, and run-dir mkdir — i.e. genuinely half-initialized.
|
|
local poison="$BATS_TEST_TMPDIR/poison"
|
|
mkdir -p "$poison"
|
|
printf '#!/usr/bin/env bash\nexit 1\n' > "$poison/python3"
|
|
chmod +x "$poison/python3"
|
|
|
|
# Mirror cmd-test.sh's real wiring in a child bash: trap armed first, then
|
|
# apply_isolation fails partway through under set -euo pipefail.
|
|
run bash -euo pipefail -c "
|
|
PATH='$poison':\"\$PATH\"
|
|
source '$COMMON'
|
|
SHOWCASE_ROOT='$SHOWCASE_ROOT_OVERRIDE'
|
|
COMPOSE_FILE=\"\$SHOWCASE_ROOT/docker-compose.local.yml\"
|
|
PORTS_FILE=\"\$SHOWCASE_ROOT/shared/local-ports.json\"
|
|
COMPOSE_CMD=\"docker compose -f \$COMPOSE_FILE\"
|
|
docker version # sentinel: prove stub+DOCKER_LOG wiring BEFORE the trap
|
|
trap restore_isolation EXIT
|
|
apply_isolation halfinit
|
|
"
|
|
[ "$status" -ne 0 ] || fail "apply_isolation unexpectedly survived poisoned python3"
|
|
|
|
# State cleaned: the claimed slot AND the runs/<name> dir are gone.
|
|
[ ! -d "$XDG_STATE_HOME/copilotkit/showcase/slots/0" ] \
|
|
|| fail "half-initialized slot dir leaked"
|
|
[ ! -d "$XDG_STATE_HOME/copilotkit/showcase/runs/halfinit" ] \
|
|
|| fail "half-initialized run dir leaked"
|
|
|
|
# And the cleanup was STATE-ONLY: no compose command of any kind ran (the
|
|
# compose-down hazard against the default stack is exactly what the
|
|
# ISOLATE_ACTIVE guard prevents — cleanup must not reintroduce it). The
|
|
# sentinel proves the stub→log pipeline is live first, so this absence
|
|
# check cannot pass vacuously. Word-matched so the sweep's
|
|
# `ps -q --filter label=com.docker.compose.project=…` line can't
|
|
# satisfy/poison the check.
|
|
_assert_stub_sentinel_logged
|
|
run grep -E "(^|[[:space:]])compose([[:space:]]|\$)" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "half-init cleanup ran a compose command: $output"
|
|
}
|
|
|
|
@test "restore_isolation without keep preserves run dir and slot when compose down FAILS" {
|
|
require_python3
|
|
# Split-brain regression: a silently failed `compose down` (stderr to
|
|
# /dev/null, `|| true`) used to leave the stack RUNNING while the run dir
|
|
# (the only copy of the rewritten compose file) and the slot were deleted —
|
|
# live containers with no state and a re-claimable slot (port collisions).
|
|
# On compose-down failure the evidence must survive and a loud warning with
|
|
# the project name + manual teardown command must print.
|
|
load_common
|
|
apply_isolation downfail
|
|
local slotdir="$ISOLATE_SLOT_DIR/$ISOLATE_SLOT"
|
|
local rundir="$ISOLATE_TMPDIR"
|
|
[ -d "$slotdir" ] || fail "precondition: slot dir missing after apply"
|
|
[ -d "$rundir" ] || fail "precondition: run dir missing after apply"
|
|
|
|
# Set AFTER apply_isolation so its idempotent pre-down is unaffected.
|
|
export DOCKER_COMPOSE_DOWN_EXIT=1
|
|
ISOLATE_KEEP=false
|
|
run restore_isolation
|
|
[ "$status" -eq 0 ] || fail "restore_isolation must not propagate the failure: $output"
|
|
|
|
# Evidence preserved: run dir kept, slot NOT released.
|
|
[ -d "$rundir" ] || fail "run dir deleted despite failed compose down"
|
|
[ -d "$slotdir" ] || fail "slot released despite failed compose down"
|
|
|
|
# Loud warning: project name + manual teardown command for recovery.
|
|
[[ "$output" == *"downfail"* ]] || fail "warning missing project name: $output"
|
|
[[ "$output" == *"docker compose -p downfail down"* ]] \
|
|
|| fail "warning missing manual teardown command: $output"
|
|
}
|
|
|
|
@test "restore_isolation guard mismatch warns and preserves run dir + slot (no fall-through deletion)" {
|
|
# Belt-and-suspenders divergence: ISOLATE_ACTIVE=true but COMPOSE_CMD is NOT
|
|
# repointed at the isolated project. The old code skipped the compose-down
|
|
# (correct — never down an unknown target) but then FELL THROUGH to deleting
|
|
# the run dir and releasing the slot, manufacturing the exact split-brain
|
|
# the adjacent comment documents: a possibly-running stack whose only
|
|
# compose state is deleted and whose slot is reclaimable. Unreachable
|
|
# through today's call paths (apply_isolation sets ISOLATE_ACTIVE only
|
|
# after the repoint), but the defensive path must be SAFE, not destructive:
|
|
# warn loudly and preserve everything, mirroring the failed-down branch.
|
|
load_common
|
|
local base="$XDG_STATE_HOME/copilotkit/showcase"
|
|
mkdir -p "$base/slots/3" "$base/runs/mismatched"
|
|
echo "$$" > "$base/slots/3/pid"
|
|
ISOLATE_ACTIVE=true
|
|
ISOLATE_NAME="mismatched"
|
|
ISOLATE_SLOT=3
|
|
ISOLATE_TMPDIR="$base/runs/mismatched"
|
|
ISOLATE_KEEP=false
|
|
# COMPOSE_CMD (from load_common) carries NO --project-name — the mismatch.
|
|
: > "$DOCKER_LOG"
|
|
docker version
|
|
run restore_isolation
|
|
[ "$status" -eq 0 ] || fail "restore_isolation failed on the mismatch path: $output"
|
|
[ -d "$base/runs/mismatched" ] \
|
|
|| fail "run dir deleted on the guard-mismatch path (split-brain)"
|
|
[ -d "$base/slots/3" ] \
|
|
|| fail "slot released on the guard-mismatch path (split-brain)"
|
|
[[ "$output" == *"mismatch"* ]] \
|
|
|| fail "no loud warning on the guard-mismatch path: $output"
|
|
[[ "$output" == *"docker compose -p mismatched down"* ]] \
|
|
|| fail "warning missing manual teardown command: $output"
|
|
# No compose command of any kind ran — COMPOSE_CMD's target is unknown.
|
|
_assert_stub_sentinel_logged
|
|
run grep -E "(^|[[:space:]])compose([[:space:]].*)?[[:space:]]down([[:space:]]|\$)" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] || fail "guard-mismatch path ran a compose down: $output"
|
|
}
|
|
|
|
@test "apply_isolation warns (but does not die) when the idempotent pre-down fails" {
|
|
require_python3
|
|
# The pre-down exists to clear a prior crashed run's containers/volumes. Its
|
|
# failure must stay non-fatal (the common case is "nothing to tear down"),
|
|
# but it must not be SILENT either — leftover state is exactly what the
|
|
# pre-clean is for, so the user gets a warning that it may remain.
|
|
load_common
|
|
export DOCKER_COMPOSE_DOWN_EXIT=1 # set BEFORE apply: poison the pre-down
|
|
run apply_isolation predownfail
|
|
[ "$status" -eq 0 ] || fail "apply_isolation died on a failed pre-down: $output"
|
|
[[ "$output" == *"pre-clean of project predownfail failed"* ]] \
|
|
|| fail "no warning printed for the failed pre-down: $output"
|
|
[[ "$output" == *"Isolation active"* ]] \
|
|
|| fail "apply_isolation did not complete after the failed pre-down: $output"
|
|
}
|
|
|
|
@test "EXIT trap honors keep flag set inside a function that has returned (real cmd_test wiring)" {
|
|
require_python3
|
|
# Regression test for the --keep teardown bug: cmd_test used to hold the keep
|
|
# flag in a `local keep`. On the NORMAL path cmd_test returns, its locals
|
|
# unwind, and the EXIT trap fires later at top-level script exit — where the
|
|
# local no longer exists — so restore_isolation read `${keep:-false}` as
|
|
# false and tore the kept stack down anyway. (The earlier tests above call
|
|
# restore_isolation directly with the flag set at test scope, which is
|
|
# exactly the pattern that masked this.)
|
|
#
|
|
# This test exercises the REAL wiring: a child bash sources _common.sh,
|
|
# registers `trap restore_isolation EXIT`, then a function sets the keep flag
|
|
# the same way cmd_test's --keep handler does, applies isolation, RETURNS,
|
|
# and the script exits normally. The run dir and slot must survive the trap
|
|
# and the survival notice must print.
|
|
cat > "$BATS_TEST_TMPDIR/keep-wiring.sh" <<'FIXTURE'
|
|
#!/usr/bin/env bash
|
|
set -euo pipefail # bin/showcase's real shell options — fixture fidelity
|
|
# shellcheck disable=SC1090
|
|
source "$COMMON_SH"
|
|
# Repoint the derived paths at the per-test fake root (same as load_common).
|
|
SHOWCASE_ROOT="$SHOWCASE_ROOT_OVERRIDE"
|
|
COMPOSE_FILE="$SHOWCASE_ROOT/docker-compose.local.yml"
|
|
PORTS_FILE="$SHOWCASE_ROOT/shared/local-ports.json"
|
|
COMPOSE_CMD="docker compose -f $COMPOSE_FILE"
|
|
|
|
fake_cmd_test() {
|
|
ISOLATE_KEEP=true # what cmd_test's --keep arg handler does
|
|
trap restore_isolation EXIT
|
|
apply_isolation trapkeep
|
|
return 0
|
|
}
|
|
fake_cmd_test
|
|
# Truncate the stub log: apply_isolation's idempotent pre-down already logged
|
|
# a `--project-name trapkeep down ...` line that would poison the parent
|
|
# test's does-NOT-compose-down assertion. The `docker version` sentinel then
|
|
# proves the stub→DOCKER_LOG pipeline is still live after the truncation.
|
|
: > "$DOCKER_LOG"
|
|
docker version
|
|
exit 0 # locals (if any) are long gone; the EXIT trap fires HERE
|
|
FIXTURE
|
|
|
|
export COMMON_SH="$COMMON"
|
|
run bash "$BATS_TEST_TMPDIR/keep-wiring.sh"
|
|
[ "$status" -eq 0 ] || fail "fixture script failed: $output"
|
|
|
|
# Survival: the run dir and slot must still exist after the trap ran.
|
|
# Slot 0 is reserved for the base stack, so apply_isolation auto-picks slot 1.
|
|
local slotdir="$XDG_STATE_HOME/copilotkit/showcase/slots/1"
|
|
local rundir="$XDG_STATE_HOME/copilotkit/showcase/runs/trapkeep"
|
|
[ -d "$rundir" ] || fail "kept run dir was torn down by the EXIT trap: $output"
|
|
[ -d "$slotdir" ] || fail "kept slot was released by the EXIT trap: $output"
|
|
[[ "$output" == *"Kept isolated group standing"* ]] \
|
|
|| fail "survival notice not printed at exit: $output"
|
|
|
|
# The keep path's central promise, under the REAL trap wiring: the EXIT trap
|
|
# did NOT compose-down the kept stack. Match BOTH compose project-flag
|
|
# spellings (--project-name and -p) — a keep-branch regression via the -p
|
|
# form would pass a --project-name-only grep undetected. The log was
|
|
# truncated inside the fixture after apply_isolation's pre-down, and the
|
|
# sentinel proves the stub→log pipeline stayed live, so this absence check
|
|
# is not vacuous.
|
|
_assert_stub_sentinel_logged
|
|
run grep -E -- "(--project-name|-p) trapkeep down" "$DOCKER_LOG"
|
|
[ "$status" -ne 0 ] || fail "EXIT trap composed the kept stack down: $output"
|
|
}
|
|
|
|
# ── SHOWCASE_ISO_SLOT pinning ────────────────────────────────────────────────
|
|
#
|
|
# When SHOWCASE_ISO_SLOT is exported the picker pins to that slot instead of
|
|
# auto-selecting the first free one. Three behaviors are pinned here:
|
|
# 1. A free pinned slot is claimed verbatim, even if lower-numbered slots are
|
|
# occupied — the pin BYPASSES auto-selection, doesn't merely seed it.
|
|
# 2. A pinned slot that is currently LIVE refuses to steal it — the die
|
|
# message names the liveness state so the user can clear it explicitly.
|
|
# 3. Invalid pin values (reserved slot 0, non-numeric, out-of-range) are
|
|
# rejected with the appropriate validation message BEFORE any slot is
|
|
# claimed or reaped.
|
|
|
|
@test "SHOWCASE_ISO_SLOT=9 claims slot 9 even when 1-3 are pre-occupied" {
|
|
# Pinning bypass: when slots 1, 2, 3 are all live-occupied (live owning PID),
|
|
# the auto-picker would land on slot 4; with SHOWCASE_ISO_SLOT=9 set the
|
|
# picker must skip every lower free slot AND every lower live slot and land
|
|
# on 9 verbatim. Live PID alone (no project file, no docker containers)
|
|
# makes _slot_liveness return "live" via signal #2 (owning-PID liveness),
|
|
# so the sweep leaves the pre-seeded slots intact.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
local n
|
|
for n in 1 2 3; do
|
|
mkdir -p "$slots/$n"
|
|
_seed_live_owner "$slots/$n" # this bats process — definitely alive (pid + pid.start)
|
|
done
|
|
|
|
export SHOWCASE_ISO_SLOT=9
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "9" ] \
|
|
|| fail "pinned SHOWCASE_ISO_SLOT=9 did not land on slot 9: got $ISOLATE_SLOT"
|
|
# Offset formula is (slot + 1) * 200 — slot 9 → 2000.
|
|
[ "$ISOLATE_PORT_OFFSET" = "2000" ] \
|
|
|| fail "pinned slot 9 port offset wrong: expected 2000, got $ISOLATE_PORT_OFFSET"
|
|
# Slot 9 actually got claimed on disk and the pid file was written by the
|
|
# common post-claim block.
|
|
[ -d "$slots/9" ] || fail "slot 9 dir not created on pin"
|
|
[ -f "$slots/9/pid" ] || fail "slot 9 pid file not written on pin"
|
|
# Pre-occupied slots survive — the pin must not steal/reap them.
|
|
for n in 1 2 3; do
|
|
[ -d "$slots/$n" ] || fail "pre-occupied slot $n was reaped by the pin"
|
|
done
|
|
}
|
|
|
|
@test "SHOWCASE_ISO_SLOT pinned to live slot dies with liveness message" {
|
|
# Live-pin refusal: a pinned slot that is currently live must NOT be stolen.
|
|
# Seed slot 9 with a live owning PID (this bats process) so _slot_liveness 9
|
|
# classifies it as "live" via signal #2 — no docker containers needed.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/9"
|
|
_seed_live_owner "$slots/9" # this bats process — _slot_liveness → live
|
|
|
|
export SHOWCASE_ISO_SLOT=9
|
|
run _claim_isolate_slot
|
|
[ "$status" -ne 0 ] || fail "pin to LIVE slot 9 unexpectedly succeeded: $output"
|
|
[[ "$output" == *"liveness=live"* ]] \
|
|
|| fail "die message does not mention the live liveness state: $output"
|
|
# The live slot survives — pin must not touch it.
|
|
[ -d "$slots/9" ] || fail "live slot 9 was reaped by the refused pin"
|
|
run cat "$slots/9/pid"
|
|
[ "$status" -eq 0 ] || fail "live slot 9 pid file vanished after refused pin"
|
|
[[ "$output" == "$$" ]] || fail "live slot 9 pid file mangled: $output"
|
|
}
|
|
|
|
@test "SHOWCASE_ISO_SLOT validates input (0, foo, 99 all rejected)" {
|
|
# Three input-validation branches, one test:
|
|
# - SHOWCASE_ISO_SLOT=0 → reserved (slot 0 is the base stack)
|
|
# - SHOWCASE_ISO_SLOT=foo → non-numeric
|
|
# - SHOWCASE_ISO_SLOT=99 → exceeds ISOLATE_MAX_SLOT (45)
|
|
# All three must die with a message naming the specific failure mode,
|
|
# BEFORE any slot is claimed (so the slots dir stays empty across all
|
|
# three calls).
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
|
|
# NB: the regex check runs FIRST, so non-numeric values trip the "positive
|
|
# integer" branch even if they would also be out-of-range. Order: numeric
|
|
# regex → >=1 → <=ISOLATE_MAX_SLOT.
|
|
|
|
# Reserved slot 0.
|
|
SHOWCASE_ISO_SLOT=0 run _claim_isolate_slot
|
|
[ "$status" -ne 0 ] || fail "SHOWCASE_ISO_SLOT=0 unexpectedly succeeded: $output"
|
|
[[ "$output" == *"slot 0 is reserved"* ]] \
|
|
|| fail "SHOWCASE_ISO_SLOT=0 die message does not say 'slot 0 is reserved': $output"
|
|
|
|
# Non-numeric.
|
|
SHOWCASE_ISO_SLOT=foo run _claim_isolate_slot
|
|
[ "$status" -ne 0 ] || fail "SHOWCASE_ISO_SLOT=foo unexpectedly succeeded: $output"
|
|
[[ "$output" == *"positive integer"* ]] \
|
|
|| fail "SHOWCASE_ISO_SLOT=foo die message does not mention 'positive integer': $output"
|
|
|
|
# Out of range (ISOLATE_MAX_SLOT=45, so 99 is over).
|
|
SHOWCASE_ISO_SLOT=99 run _claim_isolate_slot
|
|
[ "$status" -ne 0 ] || fail "SHOWCASE_ISO_SLOT=99 unexpectedly succeeded: $output"
|
|
[[ "$output" == *"exceeds ISOLATE_MAX_SLOT=45"* ]] \
|
|
|| fail "SHOWCASE_ISO_SLOT=99 die message does not mention 'exceeds ISOLATE_MAX_SLOT=45': $output"
|
|
|
|
# None of the rejected calls claimed a slot — the slots dir contains no
|
|
# numeric entries (the sweep may have created/removed .sweep.lock-shaped
|
|
# entries, but no integer slot dirs).
|
|
if [ -d "$slots" ]; then
|
|
run bash -c "ls -A '$slots' | grep -E '^[0-9]+\$' || true"
|
|
[ -z "$output" ] || fail "rejected pins claimed a slot anyway: $output"
|
|
fi
|
|
}
|
|
|
|
# ── Port-probe and stale-reap behavior (MT3.2c) ──────────────────────────────
|
|
#
|
|
# Four additional behaviors pinned here:
|
|
# #4. Pinned-path reap-and-retry: a stale slot at the pinned number is reaped
|
|
# and the slot dir re-mkdir'd by the picker on the same call.
|
|
# #5. Post-shift _slot_offset_ports regression: slot 8's dashboard port is
|
|
# now 5010 (3210 + 1800), NOT 5000 (the pre-shift 3200 + 1800). The
|
|
# regression test pins both halves: 5010 present, 5000 absent.
|
|
# #6. Auto-pick port-probe skip: a slot whose dashboard port is held by a
|
|
# FOREIGN process (not docker) is skipped — the picker rmdirs the
|
|
# provisional slot dir and advances to the next slot.
|
|
# #7. Own-project docker-listener filter: a live slot's project sees a
|
|
# com.docker.* listener on one of its ports as the slot's OWN binding
|
|
# (treated as free); a non-docker process name on the same port is NOT
|
|
# filtered (treated as held). This is the port-probe path that lets a
|
|
# --keep'd stack's own docker-proxy listener not be re-flagged as a
|
|
# foreign hold.
|
|
|
|
@test "SHOWCASE_ISO_SLOT pinned-path reaps stale slot and re-claims successfully" {
|
|
# Stale-reap on pinned: pre-create slot 9 with a project file but NO pid
|
|
# file — _slot_liveness classifies this as "stale" (has_proj=true,
|
|
# pid_file_present=false → stale). The pinned branch should reap-and-retry
|
|
# (rm -rf the slot entry, then mkdir it fresh) and write our pid into the
|
|
# rebuilt slot via the common post-claim block.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/9"
|
|
echo "stale-proj" > "$slots/9/project" # project recorded, no pid file → stale
|
|
|
|
export DOCKER_PS_OUTPUT="" # no live containers for stale-proj
|
|
export SHOWCASE_ISO_SLOT=9
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "9" ] \
|
|
|| fail "pinned stale slot 9 did not re-claim: got $ISOLATE_SLOT"
|
|
# The reap removed the original slot dir + project file; the common
|
|
# post-claim block re-mkdir'd it and wrote our pid. Prove BOTH halves:
|
|
[ -f "$slots/9/pid" ] || fail "post-reap pid file missing on slot 9"
|
|
run cat "$slots/9/pid"
|
|
[ "$status" -eq 0 ] || fail "post-reap pid file unreadable on slot 9"
|
|
[[ "$output" == "$$" ]] \
|
|
|| fail "post-reap pid file does not contain reaper's pid: got $output, expected $$"
|
|
# The stale project file should be gone (reap rm -rf'd it; the claim does
|
|
# NOT re-write a project file — only apply_isolation does).
|
|
[ ! -f "$slots/9/project" ] \
|
|
|| fail "stale project file survived the reap-and-retry: $(cat "$slots/9/project")"
|
|
}
|
|
|
|
@test "_slot_offset_ports 8 includes 5010 and excludes 5000 (port-shift regression)" {
|
|
# Pre-shift the dashboard base was 3200, so slot 8 (offset 1800) bound 5000.
|
|
# Post-shift the dashboard base is 3210, so slot 8 binds 5010. Pin BOTH halves
|
|
# of the regression: 5010 must be present (the new port emitted by the offset
|
|
# function), AND 5000 must be absent (a stale assumption that would let a
|
|
# foreign 5000 binder collide with slot 8 invisibly). One assertion without
|
|
# the other lets a half-revert (e.g. emitting BOTH 5000 and 5010) slip past.
|
|
load_common
|
|
run _slot_offset_ports 8
|
|
[ "$status" -eq 0 ] || fail "_slot_offset_ports 8 failed: $output"
|
|
[[ "$output" == *"5010"* ]] \
|
|
|| fail "_slot_offset_ports 8 did not emit dashboard port 5010 (post-shift): $output"
|
|
! [[ "$output" == *"5000"* ]] \
|
|
|| fail "_slot_offset_ports 8 still emits pre-shift dashboard port 5000: $output"
|
|
}
|
|
|
|
@test "auto-pick skips slot 1 when its dashboard port is held by a foreign process" {
|
|
# Auto-pick port-probe skip: with no SHOWCASE_ISO_SLOT set, the picker walks
|
|
# 1..ISOLATE_MAX_SLOT. Slot 1's dashboard port (3210 + 400 = 3610) is held
|
|
# by a non-docker process (Python — not subject to the own-project filter,
|
|
# which only fires for docker/com.docker process names). The picker must
|
|
# mkdir slot 1, see the port held, rmdir slot 1, and advance to slot 2.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
export LSOF_HOLD_PORTS="3610"
|
|
export LSOF_HOLD_COMMAND="Python"
|
|
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "2" ] \
|
|
|| fail "auto-pick did not skip port-held slot 1: claimed $ISOLATE_SLOT"
|
|
# Slot 1 was rmdir'd after the port probe failed; slot 2 was claimed.
|
|
[ ! -d "$slots/1" ] \
|
|
|| fail "port-held slot 1 dir survived the picker's rmdir"
|
|
[ -d "$slots/2" ] || fail "slot 2 was not claimed"
|
|
[ -f "$slots/2/pid" ] || fail "slot 2 pid file not written"
|
|
}
|
|
|
|
@test "_slot_ports_free treats own-project docker listeners as free, but not Python" {
|
|
# Own-project filter (port-probe path): a docker / com.docker listener on a
|
|
# port owned by a slot whose project is recorded and live is treated as the
|
|
# slot's OWN binding (returns 0 = "all free"). The same port held by a
|
|
# non-docker process name is NOT filtered (returns 1 = "held"). This is
|
|
# what lets a --keep'd stack's own docker-proxy not be re-flagged as a
|
|
# foreign hold.
|
|
#
|
|
# The brief originally proposed exercising this via the pinned-path's
|
|
# EEXIST branch with a live slot, but that path DIES on a live pin before
|
|
# reaching the port probe — so the own-project filter is not observable
|
|
# from _claim_isolate_slot directly. We test _slot_ports_free in isolation,
|
|
# which is the function that actually implements the filter.
|
|
#
|
|
# Pinning slot 8 as live: write the project file + a live pid ($$). Slot 8's
|
|
# dashboard port post-shift is 3210 + 1800 = 5010.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/8"
|
|
echo "iso8-proj" > "$slots/8/project"
|
|
_seed_live_owner "$slots/8" # _slot_liveness 8 → live (pid live + pid.start, no containers needed)
|
|
|
|
# Case A: docker listener on slot 8's dashboard port → filtered as own-project.
|
|
export DOCKER_PS_OUTPUT="" # no docker containers; pid-liveness still wins
|
|
export LSOF_HOLD_PORTS="5010"
|
|
export LSOF_HOLD_COMMAND="com.docker.backend"
|
|
run _slot_ports_free 8
|
|
[ "$status" -eq 0 ] \
|
|
|| fail "_slot_ports_free 8 should treat com.docker.backend on own port as free, got status=$status output=$output"
|
|
|
|
# Case B: same port held by Python (non-docker) → NOT filtered, returns 1.
|
|
export LSOF_HOLD_COMMAND="Python"
|
|
run _slot_ports_free 8
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "_slot_ports_free 8 should treat Python listener on own port as held (foreign), got status=0 output=$output"
|
|
[[ "$output" == *"Slot 8 port 5010 held by Python"* ]] \
|
|
|| fail "_slot_ports_free 8 did not emit the foreign-hold info line for Python: $output"
|
|
}
|
|
|
|
@test "_slot_ports_free fails LOUDLY for a bad slot instead of falsely reporting all-free" {
|
|
# Regression: _slot_ports_free fed its port list from
|
|
# while read port; do ...; done < <(_slot_offset_ports "$slot")
|
|
# When _slot_offset_ports dies on a bad slot (out-of-range / non-numeric), the
|
|
# die only kills the process-substitution SUBSHELL — not _slot_ports_free. The
|
|
# while loop then read ZERO ports, any_held stayed 0, and the function returned
|
|
# 0 ("all ports free"), SILENTLY defeating the port-conflict guard for a bad
|
|
# slot. Both claim paths treat 0 as "free, go claim it", so a bad slot would
|
|
# have sailed past the guard. The fix captures the port list (and thus
|
|
# _slot_offset_ports's exit status) BEFORE the loop, so a bad slot fails LOUDLY.
|
|
load_common
|
|
export LSOF_HOLD_PORTS=""
|
|
|
|
# Out-of-range slot (ISOLATE_MAX_SLOT=45). _slot_offset_ports dies on this.
|
|
run _slot_ports_free 99
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "_slot_ports_free 99 (out-of-range) wrongly reported success; the bad slot defeated the guard. status=$status output=$output"
|
|
[[ "$output" == *"exceeds ISOLATE_MAX_SLOT"* ]] \
|
|
|| fail "_slot_ports_free 99 did not surface the _slot_offset_ports die reason: $output"
|
|
|
|
# Non-numeric slot. _slot_offset_ports dies on this too.
|
|
run _slot_ports_free "bogus"
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "_slot_ports_free bogus (non-numeric) wrongly reported success. status=$status output=$output"
|
|
[[ "$output" == *"non-negative integer"* ]] \
|
|
|| fail "_slot_ports_free bogus did not surface the _slot_offset_ports die reason: $output"
|
|
}
|
|
|
|
@test "_slot_ports_free still reports a valid slot correctly (free when free, held when held)" {
|
|
# Guard the fix: valid slots must NOT regress. Slot 8's dashboard port post-
|
|
# shift is 3210 + 1800 = 5010 (same arithmetic as the own-project test above).
|
|
load_common
|
|
|
|
# Free: nothing held → returns 0.
|
|
export LSOF_HOLD_PORTS=""
|
|
run _slot_ports_free 8
|
|
[ "$status" -eq 0 ] \
|
|
|| fail "_slot_ports_free 8 should be free when no port is held, got status=$status output=$output"
|
|
|
|
# Held by a foreign (non-docker) process on slot 8's dashboard port → returns 1.
|
|
export LSOF_HOLD_PORTS="5010"
|
|
export LSOF_HOLD_COMMAND="Python"
|
|
run _slot_ports_free 8
|
|
[ "$status" -ne 0 ] \
|
|
|| fail "_slot_ports_free 8 should be held when port 5010 is taken by Python, got status=0 output=$output"
|
|
[[ "$output" == *"Slot 8 port 5010 held by Python"* ]] \
|
|
|| fail "_slot_ports_free 8 did not emit the foreign-hold info line: $output"
|
|
}
|
|
|
|
# ── Composite tests (MT3.2d): _slot_state axes, bin/showcase slots, concurrent claim ──
|
|
#
|
|
# Three additional behaviors pinned here:
|
|
# #8. _slot_state emits one pipe-delimited line carrying ALL 7 axes (slot,
|
|
# dir, pid, liveness, ports, offset, project) for a populated slot — the
|
|
# wire format that `bin/showcase slots` parses.
|
|
# #9. `bin/showcase slots` enumerates ALL 46 slots (0..ISOLATE_MAX_SLOT=45)
|
|
# with a header row and the special "showcase (base)" project label for
|
|
# slot 0 — the table contract the CLI exposes to operators.
|
|
# #10. Two concurrent `_claim_isolate_slot` invocations land on DISTINCT
|
|
# slots — atomic mkdir is the synchronization primitive; no two
|
|
# claimants ever end up owning the same slot.
|
|
|
|
@test "_slot_state 5 reports all axes for a live populated slot" {
|
|
# The wire format: one pipe-delimited line of 7 fields, exactly as
|
|
# cmd-slots.sh's `IFS='|' read -r slot dir pid liveness ports offset project`
|
|
# consumes them. Seed slot 5 with a project record AND a live owning pid
|
|
# (this bats process) so _slot_liveness 5 returns "live" via signal #2 — no
|
|
# docker containers needed. Slot 5's offset is (5+1)*200 = 1200.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/5"
|
|
echo "iso5-proj" > "$slots/5/project"
|
|
_seed_live_owner "$slots/5" # this bats process — _slot_liveness → live (pid + pid.start)
|
|
|
|
# No held ports → _slot_ports_free returns 0 → ports="free".
|
|
export LSOF_HOLD_PORTS=""
|
|
run _slot_state 5
|
|
[ "$status" -eq 0 ] || fail "_slot_state 5 failed: $output"
|
|
# Exactly one output line.
|
|
[ "${#lines[@]}" -eq 1 ] \
|
|
|| fail "_slot_state emitted ${#lines[@]} lines, expected 1: $output"
|
|
|
|
# Parse the 7 pipe-delimited axes. `read -ra fields` on a single line under
|
|
# IFS='|' gives one field per axis — independent of bats's line-splitting.
|
|
local -a fields
|
|
IFS='|' read -ra fields <<< "$output"
|
|
[ "${#fields[@]}" -eq 7 ] \
|
|
|| fail "_slot_state emitted ${#fields[@]} fields, expected 7 (slot|dir|pid|liveness|ports|offset|project): $output"
|
|
|
|
[ "${fields[0]}" = "5" ] || fail "axis[0] slot wrong: got '${fields[0]}', expected '5'"
|
|
[ "${fields[1]}" = "present" ] || fail "axis[1] dir wrong: got '${fields[1]}', expected 'present'"
|
|
[ "${fields[2]}" = "$$" ] || fail "axis[2] pid wrong: got '${fields[2]}', expected '$$'"
|
|
[ "${fields[3]}" = "live" ] || fail "axis[3] liveness wrong: got '${fields[3]}', expected 'live'"
|
|
[ "${fields[4]}" = "free" ] || fail "axis[4] ports wrong: got '${fields[4]}', expected 'free' (no LSOF_HOLD_PORTS)"
|
|
[ "${fields[5]}" = "1200" ] || fail "axis[5] offset wrong: got '${fields[5]}', expected '1200' (=(5+1)*200)"
|
|
[ "${fields[6]}" = "iso5-proj" ] || fail "axis[6] project wrong: got '${fields[6]}', expected 'iso5-proj'"
|
|
}
|
|
|
|
@test "bin/showcase slots prints all 46 slots with header and special slot 0 project label" {
|
|
# End-to-end contract for `showcase slots`: with no slots pre-claimed, the
|
|
# default fixed-width table emits one header row + 46 data rows (slots
|
|
# 0..ISOLATE_MAX_SLOT=45), and slot 0's project column shows the special
|
|
# "showcase (base)" label that cmd-slots.sh injects (the on-disk record is
|
|
# never set for slot 0 — it's reserved for the base stack).
|
|
#
|
|
# XDG_STATE_HOME is already set to the per-test scratch dir by setup(), so
|
|
# the slots dir starts empty and every row reports dir=absent.
|
|
# The docker/lsof stubs in $STUB_DIR are on PATH and stay on PATH for the
|
|
# child shell, so no real docker/lsof is hit; _slot_liveness short-circuits
|
|
# to "inconclusive" for absent dirs (no docker call).
|
|
|
|
# bin/showcase lives at the repo's showcase/bin/showcase, two dirs up from
|
|
# this test file (scripts/__tests__/ → showcase/, then bin/showcase).
|
|
run bash "$BATS_TEST_DIRNAME/../../bin/showcase" slots
|
|
[ "$status" -eq 0 ] || fail "bin/showcase slots failed: $output"
|
|
|
|
# ≥47 lines: 1 header + 46 data rows.
|
|
[ "${#lines[@]}" -ge 47 ] \
|
|
|| fail "bin/showcase slots emitted ${#lines[@]} lines, expected >=47 (1 header + 46 rows): $output"
|
|
|
|
# Header: first non-blank line starts with "SLOT" (the fixed-width table's
|
|
# header row, per cmd-slots.sh: `printf '%-4s ... "SLOT" "DIR" ...`).
|
|
local first_nonblank=""
|
|
local line
|
|
for line in "${lines[@]}"; do
|
|
if [ -n "${line// /}" ]; then
|
|
first_nonblank="$line"
|
|
break
|
|
fi
|
|
done
|
|
[[ "$first_nonblank" == SLOT* ]] \
|
|
|| fail "first non-blank line is not the SLOT header: '$first_nonblank'"
|
|
|
|
# Slot 0's special project label "showcase (base)" must appear (cmd-slots.sh
|
|
# injects it for slot 0 regardless of the on-disk record).
|
|
[[ "$output" == *"showcase (base)"* ]] \
|
|
|| fail "slot 0 project label 'showcase (base)' missing: $output"
|
|
|
|
# Exactly 46 data rows: lines starting with a digit + whitespace (the
|
|
# printf format opens with %-4s for the slot number, so data rows start
|
|
# with a digit followed by spaces; the header line starts with "SLOT").
|
|
local data_rows=0
|
|
for line in "${lines[@]}"; do
|
|
if [[ "$line" =~ ^[0-9]+[[:space:]] ]]; then
|
|
data_rows=$((data_rows + 1))
|
|
fi
|
|
done
|
|
[ "$data_rows" -eq 46 ] \
|
|
|| fail "expected 46 data rows (slots 0..45), got $data_rows: $output"
|
|
}
|
|
|
|
@test "concurrent _claim_isolate_slot calls claim distinct slots (no double-claim)" {
|
|
# Atomic-mkdir lock under concurrency: two _claim_isolate_slot invocations
|
|
# running simultaneously must NEVER both land on the same slot. Bats runs
|
|
# in a single process so we spawn the two claims as background subshells
|
|
# (`&`) and wait for both — the atomic-mkdir lock in _claim_isolate_slot
|
|
# is the synchronization primitive. Slots 1-8 are pre-occupied with live
|
|
# owning PIDs so the auto-picker is forced to evaluate slots 9 and 10.
|
|
load_common
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
local n
|
|
for n in 1 2 3 4 5 6 7 8; do
|
|
mkdir -p "$slots/$n"
|
|
_seed_live_owner "$slots/$n" # this bats process — definitely alive (pid + pid.start)
|
|
done
|
|
|
|
# No held ports for any slot's offset range → _slot_ports_free returns 0
|
|
# for whichever slot the picker probes.
|
|
export LSOF_HOLD_PORTS=""
|
|
export DOCKER_PS_OUTPUT=""
|
|
|
|
# Spawn two subshell claims concurrently. Each subshell writes its result
|
|
# (the claimed ISOLATE_SLOT and its own subshell pid) to a file the parent
|
|
# can read after wait(); ISOLATE_SLOT itself is a variable inside each
|
|
# subshell and doesn't propagate back to the parent. The filesystem state
|
|
# under $slots/ is the real source of truth for "did they double-claim".
|
|
local out1="$BATS_TEST_TMPDIR/claim1.out"
|
|
local out2="$BATS_TEST_TMPDIR/claim2.out"
|
|
(
|
|
_claim_isolate_slot
|
|
printf 'slot=%s pid=%s\n' "$ISOLATE_SLOT" "$BASHPID" > "$out1"
|
|
) &
|
|
local p1=$!
|
|
# Tiny stagger so both subshells exist before the second's mkdir loop —
|
|
# without this the second can finish before the first ever enters the loop,
|
|
# serializing them (still correct, but doesn't exercise the concurrent path).
|
|
sleep 0.05
|
|
(
|
|
_claim_isolate_slot
|
|
printf 'slot=%s pid=%s\n' "$ISOLATE_SLOT" "$BASHPID" > "$out2"
|
|
) &
|
|
local p2=$!
|
|
wait "$p1" || fail "subshell 1 (_claim_isolate_slot) failed"
|
|
wait "$p2" || fail "subshell 2 (_claim_isolate_slot) failed"
|
|
|
|
# Both wrote their result files.
|
|
[ -f "$out1" ] || fail "subshell 1 did not produce $out1"
|
|
[ -f "$out2" ] || fail "subshell 2 did not produce $out2"
|
|
|
|
# Filesystem proof: BOTH slots 9 AND 10 exist with pid files. The picker
|
|
# walks 1..ISOLATE_MAX_SLOT, skips the live-occupied 1-8 (each carries
|
|
# this bats process's pid → liveness=live), and the two concurrent claims
|
|
# land on the next two free slots.
|
|
[ -d "$slots/9" ] || fail "slot 9 was not claimed (no concurrent landing): $(ls -1 "$slots")"
|
|
[ -d "$slots/10" ] || fail "slot 10 was not claimed (no concurrent landing): $(ls -1 "$slots")"
|
|
[ -f "$slots/9/pid" ] || fail "slot 9 has no pid file (claim did not write owner): $(ls -1 "$slots/9")"
|
|
[ -f "$slots/10/pid" ] || fail "slot 10 has no pid file (claim did not write owner): $(ls -1 "$slots/10")"
|
|
|
|
# No double-claim: the two subshells reported DIFFERENT slot numbers in
|
|
# their own ISOLATE_SLOT — proving they followed disjoint paths through the
|
|
# auto-pick loop. (The pid files themselves carry $$ — the parent bats PID,
|
|
# which a subshell inherits unchanged — so identical pid file contents is
|
|
# NOT evidence of double-claim; the slot-number divergence is.)
|
|
local result1 result2
|
|
result1="$(cat "$out1" 2>/dev/null || true)"
|
|
result2="$(cat "$out2" 2>/dev/null || true)"
|
|
local slot1 slot2
|
|
slot1="$(printf '%s' "$result1" | sed -E 's/^slot=([0-9]+).*/\1/')"
|
|
slot2="$(printf '%s' "$result2" | sed -E 's/^slot=([0-9]+).*/\1/')"
|
|
[ -n "$slot1" ] || fail "subshell 1 did not record a slot: result1='$result1'"
|
|
[ -n "$slot2" ] || fail "subshell 2 did not record a slot: result2='$result2'"
|
|
[ "$slot1" != "$slot2" ] \
|
|
|| fail "subshell 1 and subshell 2 both claimed slot $slot1 (double-claim): result1='$result1' result2='$result2'"
|
|
|
|
# Slot-numbers must each be 9 OR 10 (the two slots the auto-picker reaches
|
|
# after skipping the live-occupied 1-8).
|
|
case "$slot1" in 9|10) ;; *) fail "subshell 1 claimed unexpected slot $slot1 (expected 9 or 10): $result1" ;; esac
|
|
case "$slot2" in 9|10) ;; *) fail "subshell 2 claimed unexpected slot $slot2 (expected 9 or 10): $result2" ;; esac
|
|
}
|
|
|
|
@test "_slot_state emits ports=? when lsof is unavailable" {
|
|
# Passive-inspector forgiveness: `bin/showcase slots` calls _slot_state for
|
|
# every present slot, and _slot_state's port-probe goes through
|
|
# _slot_ports_free — which dies hard when lsof is missing. That die is the
|
|
# right contract for the picker (--isolate genuinely needs lsof), but it
|
|
# makes the read-only `slots` table unusable on lsof-less hosts. Fix:
|
|
# _slot_state short-circuits to ports="?" when lsof is unavailable, leaving
|
|
# _slot_ports_free's die intact for the picker path.
|
|
load_common
|
|
# Seed slot 0 as present so _slot_state takes the probe branch (dir=absent
|
|
# would leave ports="-" and bypass the lsof check entirely).
|
|
local slots="$XDG_STATE_HOME/copilotkit/showcase/slots"
|
|
mkdir -p "$slots/0"
|
|
|
|
# Hide the stub lsof by pointing PATH at an empty dir — done AFTER
|
|
# load_common so the harness's stub wiring is already in place and we are
|
|
# specifically simulating "lsof not installed".
|
|
local empty_bin="$BATS_TEST_TMPDIR/no-lsof-bin"
|
|
mkdir -p "$empty_bin"
|
|
local saved_path="$PATH"
|
|
PATH="$empty_bin"
|
|
|
|
# _slot_state must not die; the ports field (axis #5, between the 4th and
|
|
# 5th pipes) must be "?".
|
|
local state
|
|
state=$(_slot_state 0)
|
|
local rc=$?
|
|
|
|
PATH="$saved_path"
|
|
|
|
[ "$rc" -eq 0 ] || fail "_slot_state 0 failed (rc=$rc) with lsof unavailable: $state"
|
|
[ -n "$state" ] || fail "_slot_state 0 emitted no output with lsof unavailable"
|
|
[[ "$state" == *"|?|"* ]] \
|
|
|| fail "_slot_state did not degrade to ports='?' when lsof was unavailable: $state"
|
|
}
|
|
|
|
# ── --isolate=<N> arg-parser sugar (cmd-test.sh) ─────────────────────────────
|
|
#
|
|
# The `--isolate=<N>` form is sugar over `SHOWCASE_ISO_SLOT=<N> ... --isolate`:
|
|
# the arg parser in cmd-test.sh sets SHOWCASE_ISO_SLOT from the =<N> suffix and
|
|
# exports it, then the existing picker (_claim_isolate_slot) handles ALL
|
|
# validation. The tests here pin the parser→env→picker pipeline end-to-end by
|
|
# replaying cmd-test.sh's --isolate=* branch verbatim and then invoking the
|
|
# picker (the SAME wiring bin/showcase test would do). No new validation logic
|
|
# exists in the arg parser, so we don't re-test validation here — we prove the
|
|
# arg form drives the existing validation paths pinned above.
|
|
#
|
|
# We replay the parser branch rather than invoking `cmd_test` directly because
|
|
# cmd_test runs heavy work (need_slug, trap restore_isolation, apply_isolation
|
|
# which forks docker/python3) after parsing — orthogonal to what this test
|
|
# pins.
|
|
|
|
@test "--isolate=<N> arg form exports SHOWCASE_ISO_SLOT and pins the slot through the picker" {
|
|
load_common
|
|
# Replay cmd-test.sh's `--isolate=*` branch verbatim, then run the picker.
|
|
set -- --isolate=9 dummy-slug
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--isolate=*)
|
|
SHOWCASE_ISO_SLOT="${1#--isolate=}"
|
|
export SHOWCASE_ISO_SLOT
|
|
shift
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
[ "$SHOWCASE_ISO_SLOT" = "9" ] \
|
|
|| fail "--isolate=9 did not export SHOWCASE_ISO_SLOT=9; got: $SHOWCASE_ISO_SLOT"
|
|
|
|
# The exported pin drives the picker exactly as the SHOWCASE_ISO_SLOT=9
|
|
# pinned-path test does (pinned by "SHOWCASE_ISO_SLOT=9 claims slot 9 ..."
|
|
# above): slot 9 claimed verbatim.
|
|
_claim_isolate_slot
|
|
[ "$ISOLATE_SLOT" = "9" ] \
|
|
|| fail "picker did not honor --isolate=9-exported SHOWCASE_ISO_SLOT: got $ISOLATE_SLOT"
|
|
}
|
|
|
|
@test "--isolate=0 arg form drives the picker's reserved-slot rejection" {
|
|
# Wiring proof: the arg parser does NO validation of N itself — it relies on
|
|
# the picker. So --isolate=0 must trip the SAME 'slot 0 is reserved' die
|
|
# pinned by "SHOWCASE_ISO_SLOT validates input (0, foo, 99 all rejected)"
|
|
# above. Replay the parser branch, then invoke the picker.
|
|
load_common
|
|
set -- --isolate=0
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--isolate=*)
|
|
SHOWCASE_ISO_SLOT="${1#--isolate=}"
|
|
export SHOWCASE_ISO_SLOT
|
|
shift
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
[ "$SHOWCASE_ISO_SLOT" = "0" ] || fail "parser did not export 0: got $SHOWCASE_ISO_SLOT"
|
|
|
|
run _claim_isolate_slot
|
|
[ "$status" -ne 0 ] || fail "--isolate=0 unexpectedly succeeded: $output"
|
|
[[ "$output" == *"slot 0 is reserved"* ]] \
|
|
|| fail "--isolate=0 did not surface the reserved-slot die: $output"
|
|
}
|
|
|
|
@test "cmd-test.sh --isolate=<N> actually wires the arg through to SHOWCASE_ISO_SLOT" {
|
|
# Drift guard: the three tests above replay the parser branch verbatim. This
|
|
# test sources the REAL cmd-test.sh and runs cmd_test end-to-end with a stub
|
|
# apply_isolation that snapshots SHOWCASE_ISO_SLOT — so if the parser branch
|
|
# is ever removed or wired differently, this fails LOUDLY.
|
|
load_common
|
|
|
|
local snapshot="$BATS_TEST_TMPDIR/iso-slot-snapshot"
|
|
local cmd_test_sh="$BATS_TEST_DIRNAME/../cli/cmd-test.sh"
|
|
[ -f "$cmd_test_sh" ] || fail "cmd-test.sh not found: $cmd_test_sh"
|
|
|
|
# Stub apply_isolation: snapshot SHOWCASE_ISO_SLOT, then exit cleanly via
|
|
# `die` so the rest of cmd_test (which forks docker/etc) never runs.
|
|
# Stub need_slug to a no-op so the parser path is what's exercised.
|
|
# Stub everything else cmd_test invokes after the parser to no-ops.
|
|
run bash -euo pipefail -c "
|
|
source '$COMMON'
|
|
source '$cmd_test_sh'
|
|
apply_isolation() { printf '%s\n' \"\${SHOWCASE_ISO_SLOT:-<unset>}\" > '$snapshot'; exit 0; }
|
|
need_slug() { :; }
|
|
info() { :; }
|
|
run_harness() { :; }
|
|
cmd_test --isolate=7 dummy-slug
|
|
"
|
|
[ "$status" -eq 0 ] || fail "cmd_test --isolate=7 failed: $output"
|
|
[ -f "$snapshot" ] || fail "stubbed apply_isolation never fired (parser/use_isolate wiring broken)"
|
|
run cat "$snapshot"
|
|
[[ "$output" == "7" ]] \
|
|
|| fail "cmd-test.sh did not export SHOWCASE_ISO_SLOT=7 from --isolate=7: got '$output'"
|
|
}
|
|
|
|
@test "--isolate=99 arg form drives the picker's out-of-range rejection" {
|
|
# Same wiring proof for the upper bound: ISOLATE_MAX_SLOT=45, so 99 is over.
|
|
load_common
|
|
set -- --isolate=99
|
|
while [[ $# -gt 0 ]]; do
|
|
case "$1" in
|
|
--isolate=*)
|
|
SHOWCASE_ISO_SLOT="${1#--isolate=}"
|
|
export SHOWCASE_ISO_SLOT
|
|
shift
|
|
;;
|
|
*) shift ;;
|
|
esac
|
|
done
|
|
[ "$SHOWCASE_ISO_SLOT" = "99" ] || fail "parser did not export 99: got $SHOWCASE_ISO_SLOT"
|
|
|
|
run _claim_isolate_slot
|
|
[ "$status" -ne 0 ] || fail "--isolate=99 unexpectedly succeeded: $output"
|
|
[[ "$output" == *"exceeds ISOLATE_MAX_SLOT=45"* ]] \
|
|
|| fail "--isolate=99 did not surface the out-of-range die: $output"
|
|
}
|
|
|
|
# ── --isolate <name> space-form arg-parser (cmd-test.sh) ─────────────────────
|
|
#
|
|
# The space-separated `--isolate <name>` form (vs the `--isolate=<name>` sugar)
|
|
# is parsed entirely inside cmd-test.sh's option loop, which must bind the
|
|
# explicit name to ISOLATE's name slot and the service to the positional slug
|
|
# REGARDLESS of token order — `--isolate <name> <slug>` (name first) and
|
|
# `<slug> --isolate <name>` (slug first) must both resolve correctly. The
|
|
# auto-named form (`--isolate <slug>` with NO explicit name) must still treat
|
|
# the lone trailing token as the slug. And a bare/empty `--isolate=` (or
|
|
# `--isolate` with no value) must be REJECTED loudly, never silently auto-pick.
|
|
#
|
|
# These tests drive the REAL cmd_test parser end-to-end (the same source +
|
|
# stub-after-parser pattern as the --isolate=<N> drift guard above): they stub
|
|
# everything cmd_test invokes after parsing (apply_isolation, run-harness, etc.)
|
|
# and snapshot the parsed slug + isolate_name so a parser regression fails
|
|
# LOUDLY. apply_isolation receives ("$isolate_name" "$slug") in that order
|
|
# (see _common.sh), so the snapshot records the two positional args it was
|
|
# handed — exactly the contract downstream depends on.
|
|
|
|
# _run_cmd_test_parse <args...> — source the real cmd-test.sh, stub the entire
|
|
# post-parser body, and snapshot what the parser bound. Writes two files:
|
|
# $PARSE_SNAP.name → the isolate name apply_isolation was handed ($1)
|
|
# $PARSE_SNAP.slug → the slug apply_isolation was handed ($2)
|
|
# When --isolate is NOT requested, apply_isolation never fires; the no-isolate
|
|
# fallback stub records need_slug's argument as the slug and "<no-iso>" name.
|
|
# Sets $status/$output via `run`.
|
|
_run_cmd_test_parse() {
|
|
local cmd_test_sh="$BATS_TEST_DIRNAME/../cli/cmd-test.sh"
|
|
[ -f "$cmd_test_sh" ] || fail "cmd-test.sh not found: $cmd_test_sh"
|
|
PARSE_SNAP="$BATS_TEST_TMPDIR/parse-snap"
|
|
rm -f "$PARSE_SNAP".name "$PARSE_SNAP".slug
|
|
# apply_isolation snapshots (name, slug) then exits cleanly so the docker/
|
|
# python3-forking remainder of cmd_test never runs. need_slug snapshots the
|
|
# slug too (and preserves its real "slug required" die so the empty-slug
|
|
# contract is exercised). info/success/warn are silenced; the harness call is
|
|
# a no-op (it is never reached — apply_isolation exits first when isolating).
|
|
run bash -euo pipefail -c "
|
|
source '$COMMON'
|
|
source '$cmd_test_sh'
|
|
apply_isolation() {
|
|
printf '%s' \"\${1:-}\" > '$PARSE_SNAP.name'
|
|
printf '%s' \"\${2:-}\" > '$PARSE_SNAP.slug'
|
|
exit 0
|
|
}
|
|
need_slug() {
|
|
[ -n \"\${1:-}\" ] || die 'slug required'
|
|
printf '<no-iso>' > '$PARSE_SNAP.name'
|
|
printf '%s' \"\${1:-}\" > '$PARSE_SNAP.slug'
|
|
}
|
|
info() { :; }
|
|
success() { :; }
|
|
npx() { :; }
|
|
cmd_test $*
|
|
"
|
|
}
|
|
|
|
@test "cmd-test.sh --isolate <name> <slug> (name BEFORE slug) binds name and slug correctly" {
|
|
load_common
|
|
_run_cmd_test_parse --isolate myname mastra
|
|
[ "$status" -eq 0 ] || fail "cmd_test --isolate myname mastra failed: $output"
|
|
[ -f "$PARSE_SNAP.name" ] || fail "apply_isolation never fired (use_isolate wiring broken): $output"
|
|
run cat "$PARSE_SNAP.name"
|
|
[[ "$output" == "myname" ]] || fail "name-before-slug bound wrong isolate name: got '$output' (want myname)"
|
|
run cat "$PARSE_SNAP.slug"
|
|
[[ "$output" == "mastra" ]] || fail "name-before-slug bound wrong slug: got '$output' (want mastra)"
|
|
}
|
|
|
|
@test "cmd-test.sh <slug> --isolate <name> (slug BEFORE name) still binds name and slug correctly" {
|
|
load_common
|
|
_run_cmd_test_parse mastra --isolate myname
|
|
[ "$status" -eq 0 ] || fail "cmd_test mastra --isolate myname failed: $output"
|
|
run cat "$PARSE_SNAP.name"
|
|
[[ "$output" == "myname" ]] || fail "slug-before-name bound wrong isolate name: got '$output' (want myname)"
|
|
run cat "$PARSE_SNAP.slug"
|
|
[[ "$output" == "mastra" ]] || fail "slug-before-name bound wrong slug: got '$output' (want mastra)"
|
|
}
|
|
|
|
@test "cmd-test.sh --isolate <slug> (no explicit name) treats the lone token as the slug (auto-named)" {
|
|
load_common
|
|
_run_cmd_test_parse --isolate mastra
|
|
[ "$status" -eq 0 ] || fail "cmd_test --isolate mastra failed: $output"
|
|
run cat "$PARSE_SNAP.name"
|
|
[[ "$output" == "" ]] || fail "auto-named --isolate wrongly bound an explicit name: got '$output' (want empty)"
|
|
run cat "$PARSE_SNAP.slug"
|
|
[[ "$output" == "mastra" ]] || fail "auto-named --isolate bound wrong slug: got '$output' (want mastra)"
|
|
}
|
|
|
|
@test "cmd-test.sh --isolate <name> <slug> with surrounding flags binds name and slug correctly" {
|
|
load_common
|
|
_run_cmd_test_parse --d5 --isolate d5verify agno --verbose
|
|
[ "$status" -eq 0 ] || fail "cmd_test with flags around --isolate failed: $output"
|
|
run cat "$PARSE_SNAP.name"
|
|
[[ "$output" == "d5verify" ]] || fail "flagged name-before-slug bound wrong name: got '$output' (want d5verify)"
|
|
run cat "$PARSE_SNAP.slug"
|
|
[[ "$output" == "agno" ]] || fail "flagged name-before-slug bound wrong slug: got '$output' (want agno)"
|
|
}
|
|
|
|
@test "cmd-test.sh bare --isolate= (empty value) is rejected loudly, never silently auto-picks" {
|
|
load_common
|
|
_run_cmd_test_parse --isolate= mastra
|
|
[ "$status" -ne 0 ] || fail "bare --isolate= unexpectedly succeeded (silent auto-pick): $output"
|
|
# It must NOT have reached apply_isolation with an empty pin and proceeded.
|
|
[ ! -f "$PARSE_SNAP.slug" ] || fail "bare --isolate= fell through to apply_isolation instead of erroring: name='$(cat "$PARSE_SNAP.name" 2>/dev/null)' slug='$(cat "$PARSE_SNAP.slug" 2>/dev/null)'"
|
|
[[ "$output" == *"isolate"* ]] || fail "bare --isolate= error did not mention isolate: $output"
|
|
}
|
|
|
|
@test "cmd-test.sh --isolate=<name> (non-numeric sugar) binds the explicit name, not a slot" {
|
|
load_common
|
|
# The =<name> sugar is equivalent to the space form `--isolate <name>`: a
|
|
# non-numeric value is an explicit isolate name, NOT a slot pin (a slot pin
|
|
# would die in the picker on "must be a positive integer"). SHOWCASE_ISO_SLOT
|
|
# must remain unset so the picker auto-picks a slot for the named project.
|
|
local snap="$BATS_TEST_TMPDIR/iso-namesugar-snap"
|
|
local cmd_test_sh="$BATS_TEST_DIRNAME/../cli/cmd-test.sh"
|
|
run bash -euo pipefail -c "
|
|
source '$COMMON'
|
|
source '$cmd_test_sh'
|
|
apply_isolation() {
|
|
printf '%s' \"\${SHOWCASE_ISO_SLOT:-<unset>}\" > '$snap.slot'
|
|
printf '%s' \"\${1:-}\" > '$snap.name'
|
|
printf '%s' \"\${2:-}\" > '$snap.slug'
|
|
exit 0
|
|
}
|
|
need_slug() { :; }
|
|
info() { :; }
|
|
success() { :; }
|
|
npx() { :; }
|
|
cmd_test --isolate=d5verify agno
|
|
"
|
|
[ "$status" -eq 0 ] || fail "cmd_test --isolate=d5verify agno failed: $output"
|
|
run cat "$snap.slot"
|
|
[[ "$output" == "<unset>" ]] || fail "--isolate=d5verify wrongly pinned a slot: SHOWCASE_ISO_SLOT='$output'"
|
|
run cat "$snap.name"
|
|
[[ "$output" == "d5verify" ]] || fail "--isolate=d5verify bound wrong name: got '$output'"
|
|
run cat "$snap.slug"
|
|
[[ "$output" == "agno" ]] || fail "--isolate=d5verify bound wrong slug: got '$output'"
|
|
}
|
|
|
|
@test "cmd-test.sh --isolate=<N> numeric pinned path is unchanged (still exports the slot)" {
|
|
load_common
|
|
# Regression guard for the existing numeric sugar: --isolate=7 must still
|
|
# export SHOWCASE_ISO_SLOT=7 and reach apply_isolation with an EMPTY name and
|
|
# the real slug (the picker, not the parser, owns numeric validation).
|
|
local snap="$BATS_TEST_TMPDIR/iso-num-snap"
|
|
local cmd_test_sh="$BATS_TEST_DIRNAME/../cli/cmd-test.sh"
|
|
run bash -euo pipefail -c "
|
|
source '$COMMON'
|
|
source '$cmd_test_sh'
|
|
apply_isolation() {
|
|
printf '%s' \"\${SHOWCASE_ISO_SLOT:-<unset>}\" > '$snap.slot'
|
|
printf '%s' \"\${1:-}\" > '$snap.name'
|
|
printf '%s' \"\${2:-}\" > '$snap.slug'
|
|
exit 0
|
|
}
|
|
need_slug() { :; }
|
|
info() { :; }
|
|
success() { :; }
|
|
npx() { :; }
|
|
cmd_test --isolate=7 mastra
|
|
"
|
|
[ "$status" -eq 0 ] || fail "cmd_test --isolate=7 mastra failed: $output"
|
|
run cat "$snap.slot"
|
|
[[ "$output" == "7" ]] || fail "--isolate=7 did not export SHOWCASE_ISO_SLOT=7: got '$output'"
|
|
run cat "$snap.name"
|
|
[[ "$output" == "" ]] || fail "--isolate=7 wrongly bound a name: got '$output'"
|
|
run cat "$snap.slug"
|
|
[[ "$output" == "mastra" ]] || fail "--isolate=7 bound wrong slug: got '$output'"
|
|
}
|