Files
amethyst/cli/tests/cache/cache-headless.sh
T
Claude 42698ed59c fix(eventsync): drain the outbox before closing and count each send once
Audit of the sync path the new geode-backed EventSyncTest exercises found
two bugs in EventSync itself, plus review nits on the harness changes.

- runSync closed its client (`use {}`) the moment the last page arrived,
  while `publish` is fire-and-forget through the client's outbox. Events
  forwarded from the final page of the last relay were still waiting for
  a socket or an OK when the outbox was destroyed, so the sync reported
  Done and silently never delivered them. Wait, bounded by the existing
  per-relay timeout, until no forwarded event has a relay left pending.
- The "events sent" counters incremented on every onSent, including the
  failed write to a destination still connecting and the outbox's
  at-least-once resend of an unacknowledged event after the connection
  syncs. Every cold destination therefore reported at least one extra
  event sent. Count only successful writes, once per (event, relay).
  The test now asserts the sent total equals the routed total.
- Harness: the 127.0.0.2 rationale claimed it survives Quartz's
  isLocalHost() strip; that filter now covers all of 127.0.0.0/8, so
  say so and note what it means for the strict-inbox DM cases. The
  interactive Marmot harness gets an overridable RELAY_HOST/RELAY_BIND
  and documents the loopback/RFC1918 stripping limit it inherits, and
  its new --port guards a missing value instead of dying on set -u.
- EventSyncTest builds both scenarios through one helper.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PguqnDbP2v11dtANs9xdxc
2026-09-12 20:57:11 +00:00

269 lines
11 KiB
Bash
Executable File

#!/usr/bin/env bash
#
# cache-headless.sh — verifies the file-backed event store is the
# source of truth for `amy` reads.
#
# Two amy identities (A and B) talk to a local embedded relay
# (`amy serve`, i.e. geode). We assert that:
#
# 1. After A runs `amy create`, A's local store contains the bootstrap
# events (kind:0 / 3 / 10002 / 10050 / 10051 …).
# 2. A's `amy profile show` is served from cache (`source: "cache"`)
# — no relay round-trip needed.
# 3. A's `amy profile show --refresh` bypasses the cache and pulls
# from relays (`source: "relays"`).
# 4. B fetches A's profile once over the relay (cache miss), and the
# second invocation serves from B's local store (cache hit).
# 5. `amy store stat` reports the right kind histogram and a non-zero
# event count + disk usage.
# 6. `amy relay list` reads the relay URLs back out of the local
# kind:10002 / 10050 / 10051 events (the pre-relays.json contract).
#
# Usage: ./cache-headless.sh [--port N] [--no-build]
#
set -uo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)"
TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)"
STATE_DIR="$SCRIPT_DIR/state-cache-headless"
LOG_DIR="$STATE_DIR/logs"
# Per-account dirs under the same fake $HOME=$STATE_DIR — accounts A
# and B share $STATE_DIR/.amy/shared/events-store/ (production layout).
A_DIR="$STATE_DIR/.amy/A"
B_DIR="$STATE_DIR/.amy/B"
RUN_TS="$(date +%Y%m%d-%H%M%S)"
LOG_FILE="$LOG_DIR/run-$RUN_TS.log"
RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv"
AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy"
# Loopback relay = `amy serve` (geode), booted from $AMY_BIN by
# start_local_relay in headless/helpers.sh. 127.0.0.2 only for parity
# with the DM and Marmot harnesses: Quartz's isLocalHost() now covers all
# of 127.0.0.0/8, so it is stripped from parsed relay lists exactly like
# 127.0.0.1. Nothing here depends on that parse — amy publishes to and
# reads from the relay it was told about.
RELAY_HOST="${RELAY_HOST:-127.0.0.2}"
RELAY_DATA="$STATE_DIR/relay"
RELAY_PORT="${RELAY_PORT:-8092}"
RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"
NO_BUILD=0
A_NPUB=""
A_HEX=""
B_NPUB=""
B_HEX=""
while [[ $# -gt 0 ]]; do
case "$1" in
--port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
--host) RELAY_HOST="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;;
--no-build) NO_BUILD=1 ;;
-h|--help)
sed -n '3,21p' "${BASH_SOURCE[0]}" | sed 's/^# \?//'
exit 0 ;;
*) printf 'unknown flag: %s\n' "$1" >&2; exit 2 ;;
esac
shift
done
mkdir -p "$STATE_DIR" "$LOG_DIR"
: >"$LOG_FILE"
: >"$RESULTS_FILE"
# shellcheck source=../lib.sh
source "$TESTS_DIR/lib.sh"
# shellcheck source=../headless/helpers.sh — amy wrappers + start_local_relay / stop_local_relay
source "$TESTS_DIR/headless/helpers.sh"
# Keep the dm setup's preflight (just checks for amy) but define our own
# identity bootstrap so we don't pull in DM-specific wiring.
# shellcheck source=../dm/setup.sh
source "$TESTS_DIR/dm/setup.sh"
amy_b() { HOME="$STATE_DIR" "$AMY_BIN" --account B --secret-backend plaintext --json "$@"; }
# Helper: B-side amy_json. The dm headless's helpers.sh hardcodes A_DIR
# in amy_a; we need a parallel for B without re-sourcing.
amy_b_json() {
local out
if ! out=$(amy_b "$@" 2>>"$LOG_FILE"); then
fail_msg "amy_b $*: exit $? (see $LOG_FILE)"
printf '%s\n' "$out" >>"$LOG_FILE"
return 1
fi
printf '%s' "$out"
}
cleanup() {
local rc=$?
trap - EXIT INT TERM HUP
stop_local_relay
print_summary
exit "$rc"
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
banner "Amethyst event-store cache headless ($RUN_TS)"
preflight_dm
start_local_relay
# Identity A: full bootstrap so the shared store has kind:0 / 3 / 10002 / …
ensure_identity_for A
banner "Bootstrapping A's account (publishes kind:0 + bootstrap events)"
amy_a relay add "$RELAY_URL" >>"$LOG_FILE" 2>&1
# `amy create` here would mint a *second* identity; A already exists from
# `init`. Build the bootstrap events ourselves by publishing a minimal
# kind:0 + the relay lists, all of which land in the shared store via
# verifyAndStore.
amy_a relay publish-lists >>"$LOG_FILE" 2>&1
amy_a profile edit --name "AAA" --about "cache test subject" \
>>"$LOG_FILE" 2>&1 \
|| fail_msg "amy_a profile edit failed"
# Identity B: same fake $HOME, separate per-account dir, but A and B
# both read/write to $STATE_DIR/.amy/shared/events-store/.
ensure_identity_for B
amy_b relay add "$RELAY_URL" >>"$LOG_FILE" 2>&1
amy_b relay publish-lists >>"$LOG_FILE" 2>&1
# ----------------------------------------------------------------------
# 1. amy store stat reports a non-empty store after bootstrap.
# ----------------------------------------------------------------------
banner "T1 — amy store stat after bootstrap"
T1=$(amy_a store stat) || fail_msg "store stat failed"
T1_EVENTS=$(printf '%s' "$T1" | jq -r '.events')
T1_K0=$(printf '%s' "$T1" | jq -r '.by_kind."0" // 0')
T1_K10002=$(printf '%s' "$T1" | jq -r '.by_kind."10002" // 0')
T1_BYTES=$(printf '%s' "$T1" | jq -r '.disk_bytes')
if [[ "$T1_EVENTS" -gt 0 ]]; then
record_result T1.events pass "$T1_EVENTS event(s) in A's store"
else
record_result T1.events fail "store should be non-empty after publish-lists+profile edit"
fi
if [[ "$T1_K0" -ge 1 ]]; then
record_result T1.kind0 pass "kind:0 present"
else
record_result T1.kind0 fail "no kind:0 in by_kind histogram"
fi
if [[ "$T1_K10002" -ge 1 ]]; then
record_result T1.kind10002 pass "kind:10002 present"
else
record_result T1.kind10002 fail "no kind:10002 in by_kind histogram"
fi
if [[ "$T1_BYTES" -gt 0 ]]; then
record_result T1.disk_bytes pass "$T1_BYTES bytes used"
else
record_result T1.disk_bytes fail "disk_bytes should be > 0"
fi
# ----------------------------------------------------------------------
# 2. profile show on self → source: "cache" (slot lookup, no network).
# ----------------------------------------------------------------------
banner "T2 — A's profile show is served from cache by default"
T2=$(amy_a profile show)
T2_SRC=$(printf '%s' "$T2" | jq -r '.source')
T2_FOUND=$(printf '%s' "$T2" | jq -r '.found')
T2_NAME=$(printf '%s' "$T2" | jq -r '.metadata.name // ""')
assert_eq "$T2_SRC" "cache" T2.source "default reads should hit the local store" \
&& record_result T2.source pass "self profile-show served from cache"
assert_eq "$T2_FOUND" "true" T2.found "" \
&& record_result T2.found pass ""
assert_eq "$T2_NAME" "AAA" T2.name "profile edit must round-trip" \
&& record_result T2.name pass "metadata.name round-trips"
# ----------------------------------------------------------------------
# 3. profile show --refresh forces a relay drain.
# ----------------------------------------------------------------------
banner "T3 — --refresh forces source: relays"
T3=$(amy_a profile show --refresh)
T3_SRC=$(printf '%s' "$T3" | jq -r '.source')
T3_QR=$(printf '%s' "$T3" | jq -r '.queried_relays | length')
assert_eq "$T3_SRC" "relays" T3.source "--refresh must skip cache" \
&& record_result T3.source pass "--refresh switched source to relays"
if [[ "$T3_QR" -ge 1 ]]; then
record_result T3.queried_relays pass "$T3_QR relay(s) queried"
else
record_result T3.queried_relays fail "expected at least one queried_relays entry"
fi
# ----------------------------------------------------------------------
# 4. Shared events-store: B looks up A's profile and gets a cache hit
# on the first try, because A's kind:0 already landed in the shared
# store during A's bootstrap. (Pre-shared-store behaviour was "first
# lookup is a relay miss, second is a cache hit"; that test only
# made sense when each account had its own private cache.)
# ----------------------------------------------------------------------
banner "T4 — B's profile lookup of A is a shared-store cache hit"
T4=$(amy_b_json profile show "$A_NPUB")
T4_SRC=$(printf '%s' "$T4" | jq -r '.source')
T4_NAME=$(printf '%s' "$T4" | jq -r '.metadata.name // ""')
assert_eq "$T4_SRC" "cache" T4.source "shared store should hand B a cached A profile immediately" \
&& record_result T4.source pass "B saw A from the shared cache"
assert_eq "$T4_NAME" "AAA" T4.name "A's profile metadata must be readable from B's invocation" \
&& record_result T4.name pass "shared-cache metadata round-trips across accounts"
# ----------------------------------------------------------------------
# 5. relay list reads URLs back from the local kind:10002 / 10050 / 10051.
# `relay add URL` fans out to the transport lists (nip65 both, dm, key-package).
# ----------------------------------------------------------------------
banner "T5 — relay list reads from store"
T5=$(amy_a relay list)
T5_NIP65=$(printf '%s' "$T5" | jq -r '.nip65 | length')
T5_DM=$(printf '%s' "$T5" | jq -r '.dm | length')
T5_KP=$(printf '%s' "$T5" | jq -r '.key_package | length')
if [[ "$T5_NIP65" -ge 1 ]]; then
record_result T5.nip65 pass "$T5_NIP65 nip65 url(s)"
else
record_result T5.nip65 fail "nip65 bucket empty after relay add"
fi
if [[ "$T5_DM" -ge 1 ]]; then
record_result T5.dm pass "$T5_DM dm url(s)"
else
record_result T5.dm fail "dm bucket empty after relay add"
fi
if [[ "$T5_KP" -ge 1 ]]; then
record_result T5.key_package pass "$T5_KP key_package url(s)"
else
record_result T5.key_package fail "key_package bucket empty after relay add"
fi
# ----------------------------------------------------------------------
# 6. relays.json must NOT exist anywhere — the events ARE the config.
# ----------------------------------------------------------------------
banner "T6 — relays.json is gone"
if [[ ! -f "$A_DIR/relays.json" && ! -f "$B_DIR/relays.json" ]]; then
record_result T6.no_relays_json pass "neither data-dir contains relays.json"
else
record_result T6.no_relays_json fail "relays.json found — should have been removed"
fi
# ----------------------------------------------------------------------
# 7. Maintenance verbs work even when the selected account has no
# identity yet — they only construct the FsEventStore, never
# `Context.open`. We use a fresh fake $HOME so the empty store has
# no inherited events.
# ----------------------------------------------------------------------
banner "T7 — store maintenance verbs (sweep/scrub/compact) on an empty store"
TMP_HOME=$(mktemp -d)
T7_AMY=(${AMY_BIN} --secret-backend plaintext --account throwaway --json store)
T7_STAT=$(HOME="$TMP_HOME" "${T7_AMY[@]}" stat)
T7_SWEEP=$(HOME="$TMP_HOME" "${T7_AMY[@]}" sweep-expired)
T7_SCRUB=$(HOME="$TMP_HOME" "${T7_AMY[@]}" scrub)
T7_COMPACT=$(HOME="$TMP_HOME" "${T7_AMY[@]}" compact)
assert_eq "$(printf '%s' "$T7_STAT" | jq -r '.events')" "0" T7.stat.events "" \
&& record_result T7.stat pass "stat works on empty store"
assert_eq "$(printf '%s' "$T7_SWEEP" | jq -r '.swept')" "0" T7.sweep.swept "" \
&& record_result T7.sweep pass "sweep-expired works on empty store"
assert_eq "$(printf '%s' "$T7_SCRUB" | jq -r '.ok')" "true" T7.scrub.ok "" \
&& record_result T7.scrub pass "scrub works on empty store"
assert_eq "$(printf '%s' "$T7_COMPACT" | jq -r '.ok')" "true" T7.compact.ok "" \
&& record_result T7.compact pass "compact works on empty store"
rm -rf "$TMP_HOME"