testing/mesh-lab: nat-lan cpu-pinning sidecar + trace overlay + stall_signature

Three deltas to the mesh-lab nat-lan suite for stall characterization:

- FIPS_NAT_LAN_CPUSET env-var-driven CPU-pinning sidecar in
  run_nat_lan, mirroring the bloom-storm pattern. Pinning is needed
  because the mesh-lab compose-resource-limits.yml override is
  rekey-family service-name specific (rekey-* / rekey-accept-off-* /
  rekey-outbound-only-*), so it does not constrain the nat-lan
  containers. Default cpuset 0,1 mimics a GHA 2-core runner; empty
  disables the sidecar.

- New compose-trace-nat.yml overlay that bumps RUST_LOG to trace on
  discovery::nostr, transport::udp, node::lifecycle,
  handlers::handshake, handlers::forwarding — the modules covering
  the cross-init / adoption / handshake path. Picked up by the
  nat-test.sh COMPOSE array via a new FIPS_NAT_EXTRA_COMPOSE
  colon-separated env-var hook. run_nat_lan sets this hook when
  FIPS_MESH_LAB_TRACE is non-empty; the README env-var section
  updated to reflect that FIPS_MESH_LAB_TRACE now applies to nat-lan
  in addition to the rekey-family.

- parse_nat_lan extended with a per-node stall_signature emitting
  last-occurrence timestamps for eight event categories (startup,
  discovery, adoption, handshake_init, msg2_sent, cross_init_ignore_*,
  handshake_failed) plus derived last_meaningful_event_ts,
  last_event_category, silent_gap_s. Top-level stall_class binned as
  no_timeout / silent / localized / distributed / incomplete from
  the per-node categories. Aggregation phase consumes the per-rep
  signatures across a characterization run to classify stall
  mechanism.

Wired support in nat-test.sh: FIPS_NAT_EXTRA_COMPOSE colon-separated
list of repo-relative or absolute compose files layered onto the
base via the COMPOSE array; FIPS_NAT_SKIP_FINAL_CLEANUP gates the
success-path teardown so the mesh-lab harness can capture docker
logs before tearing down (failure paths already returned without
cleanup, leaving stall-state containers intact for capture).

Smoke-tested on idle profile with TRACE on: 1 rep PASS, 32/36 TRACE
lines per node, signature.json events all populated with the
expected category timestamps.
This commit is contained in:
Johnathan Corgan
2026-05-24 17:49:37 +00:00
parent dae33d4fd1
commit cc7f967128
4 changed files with 321 additions and 18 deletions
+29 -10
View File
@@ -96,16 +96,24 @@ each rep does, set them in the invoking shell:
injection point.
- **`FIPS_MESH_LAB_TRACE`** — when set to any non-empty value, the
harness layers `compose-trace.yml` on top of the base + resource-
limits compose stack. That override bumps `RUST_LOG` to trace
level on the modules relevant to the rekey-class flake: `rekey`,
`handshake`, `forwarding`, `session`, `encrypted`, `mmp`. Use
only when capturing primary failure-moment evidence for mechanism
investigation — log volume increases substantially. Without this
knob, daemon logs only capture state transitions (rekey cutover,
K-bit flip), not per-datagram forwarding decisions, which makes
evidence collection for routing-state stalls effectively
impossible.
harness layers a suite-specific trace-RUST_LOG compose override
on top of the base stack. Module sets are per-suite:
- rekey / rekey-accept-off / rekey-outbound-only — `rekey`,
`handshake`, `forwarding`, `session`, `encrypted`, `mmp`
(via `compose-trace.yml`).
- nat-lan — `discovery::nostr`, `transport::udp`,
`node::lifecycle`, `handlers::handshake`, `handlers::forwarding`
(via `compose-trace-nat.yml`, picked up by
`testing/nat/scripts/nat-test.sh` through the
`FIPS_NAT_EXTRA_COMPOSE` env-var hook).
- bloom-storm — no compose override applies; chaos uses its own
python sim runner.
Use only when capturing primary failure-moment evidence for
mechanism investigation — log volume increases substantially.
Without this knob, daemon logs only capture state transitions and
not per-datagram forwarding decisions, which makes evidence
collection for routing-state stalls effectively impossible.
- **`FIPS_BLOOM_STORM_CPUSET`** — comma-separated CPU set for the
bloom-storm dispatch's container-pinning sidecar (default
@@ -116,6 +124,17 @@ each rep does, set them in the invoking shell:
relax, or to the empty string to disable the sidecar entirely.
Only applies to the `bloom-storm` suite; other suites ignore it.
- **`FIPS_NAT_LAN_CPUSET`** — comma-separated CPU set for the
nat-lan dispatch's container-pinning sidecar (default `0,1`).
Same shape as `FIPS_BLOOM_STORM_CPUSET`, but the sidecar polls
for `fips-nat-lan-*` containers and applies the cpuset as the
compose-up creates them. The mesh-lab
`compose-resource-limits.yml` overlay is rekey-family
service-name-specific (services `rekey-*` / `rekey-accept-off-*`
/ `rekey-outbound-only-*`), so it does NOT constrain the nat-lan
containers; the sidecar fills that gap. Only applies to the
`nat-lan` suite; other suites ignore it.
- **`FIPS_MESH_LAB_RUNS_DIR`** — root directory for harness output
(the `runs/<timestamp>/` tree). When unset, the harness falls back
to an in-tree path under `testing/mesh-lab/` and prints a warning
+42
View File
@@ -0,0 +1,42 @@
# Compose override that bumps RUST_LOG to trace level on the modules
# relevant to NAT-traversal handshake-completion flake evidence
# collection (ISSUE-2026-0027):
#
# - fips::discovery::nostr — overlay advert publish/consume
# (where the cross-init race begins)
# - fips::transport::udp — UDP socket bind/send/recv
# (where the punch packets flow)
# - fips::node::lifecycle — daemon bootstrap, peer state
# machine, adoption transitions
# - fips::node::handlers::handshake — Noise handshake msg1/2/3,
# cross-init tie-breaker
# ("Ignoring established NAT
# traversal..." emits here)
# - fips::node::handlers::forwarding — transit/local datagram routing
# (catches drops post-adoption)
#
# Other modules stay at info to keep log volume manageable. The base
# docker-compose.yml's `*fips-common` env block sets RUST_LOG as a
# list-form env var (`- RUST_LOG=...`); the override below replaces
# it entirely via the YAML map form, which is the docker-compose
# override semantics for the environment field.
#
# Service-name layout: only the two `lan-*` services are FIPS daemons;
# the relay and stun services are not (and don't honor RUST_LOG).
#
# Include this override via the FIPS_NAT_EXTRA_COMPOSE env var that
# testing/nat/scripts/nat-test.sh consults:
# FIPS_NAT_EXTRA_COMPOSE=testing/mesh-lab/compose-trace-nat.yml \
# bash testing/nat/scripts/nat-test.sh lan
#
# The mesh-lab harness sets it automatically when FIPS_MESH_LAB_TRACE
# is set and the suite is nat-lan.
x-trace-rust-log: &trace-rust-log
RUST_LOG: "info,fips::discovery::nostr=trace,fips::transport::udp=trace,fips::node::lifecycle=trace,fips::node::handlers::handshake=trace,fips::node::handlers::forwarding=trace"
services:
lan-a:
environment: *trace-rust-log
lan-b:
environment: *trace-rust-log
+227 -7
View File
@@ -327,9 +327,58 @@ mechanism_match_rekey() {
run_nat_lan() {
local REP_DIR="$1"
local rc=0
# Optional CPU-pinning sidecar. Mirrors the bloom-storm pattern at
# run_bloom_storm above. nat-test.sh uses docker-compose, but the
# mesh-lab compose-resource-limits.yml override is rekey-family
# service-name specific (services rekey-* / rekey-accept-off-* /
# rekey-outbound-only-*), so it does NOT constrain the nat-lan
# containers (lan-a, lan-b). To pressure-match the GHA 2-core
# constraint without adding nat-specific resource-limits files, the
# sidecar polls for `fips-nat-lan-*` containers as compose spawns
# them and applies `docker update --cpuset-cpus <set>` to each.
# Pinning is idempotent — re-applying the same cpuset is a no-op.
# Default `0,1` mimics a GHA 2-core runner; set FIPS_NAT_LAN_CPUSET
# to a wider set (e.g. `0,1,2,3`) to relax, or to the empty string
# to disable the sidecar.
local cpuset="${FIPS_NAT_LAN_CPUSET-0,1}"
local pinning_pid=""
if [ -n "$cpuset" ]; then
(
while true; do
for c in $(docker ps --filter "name=fips-nat-lan-" --format '{{.Names}}' 2>/dev/null); do
docker update --cpuset-cpus "$cpuset" "$c" >/dev/null 2>&1 || true
done
sleep 0.5
done
) &
pinning_pid=$!
echo "nat-lan: cpu-pinning sidecar PID $pinning_pid (cpuset=$cpuset)" \
>"$REP_DIR/setup.log"
fi
# Trace-logging override. When FIPS_MESH_LAB_TRACE is set, point
# nat-test.sh at the nat-specific trace overlay via the
# FIPS_NAT_EXTRA_COMPOSE env-var hook in nat-test.sh. The overlay
# bumps RUST_LOG to trace on discovery::nostr, transport::udp,
# node::lifecycle, handlers::handshake, handlers::forwarding —
# the modules covering the cross-init / adoption / handshake
# path that ISSUE-2026-0027 exhibits. Path is repo-relative.
local -a env_args=(FIPS_NAT_SKIP_FINAL_CLEANUP=1)
if [ -n "${FIPS_MESH_LAB_TRACE:-}" ]; then
env_args+=(FIPS_NAT_EXTRA_COMPOSE=testing/mesh-lab/compose-trace-nat.yml)
echo "nat-lan: FIPS_MESH_LAB_TRACE set, layering compose-trace-nat.yml" \
>>"$REP_DIR/setup.log"
fi
# nat-test.sh's run_lan() tears containers down on the success
# path (line 404 cleanup), which races with our docker-logs
# capture below. FIPS_NAT_SKIP_FINAL_CLEANUP=1 disables that
# final teardown; failure paths in run_lan already leave
# containers up. We do the teardown explicitly after capture.
(
cd "$REPO_ROOT" || exit 1
bash testing/nat/scripts/nat-test.sh lan
env "${env_args[@]}" bash testing/nat/scripts/nat-test.sh lan
) >"$REP_DIR/test-output.log" 2>&1 || rc=$?
mkdir -p "$REP_DIR/docker-logs"
@@ -337,9 +386,133 @@ run_nat_lan() {
docker logs "$c" >"$REP_DIR/docker-logs/$c.log" 2>&1 || true
done
# Post-capture teardown. nat-test.sh skipped its final cleanup
# (skipping is success-path only; failure paths return without
# cleanup either way), so we tear down here. Mirrors nat-test.sh's
# cleanup() shape: all three profiles + -v --remove-orphans.
(
cd "$REPO_ROOT" || exit 1
docker compose -f testing/nat/docker-compose.yml \
--profile cone --profile symmetric --profile lan \
down -v --remove-orphans \
>>"$REP_DIR/teardown.log" 2>&1 || true
)
if [ -n "$pinning_pid" ]; then
kill "$pinning_pid" 2>/dev/null || true
wait "$pinning_pid" 2>/dev/null || true
fi
return "$rc"
}
# Extract the timestamp of the last line in $log matching $pattern. The
# daemon's tracing-subscriber emits ISO 8601 UTC timestamps with sub-
# second precision as the first token on each line (after ANSI escapes
# are stripped). Returns the empty string if no line matches. Helper
# scoped to parse_nat_lan; relies on grep -E semantics.
_nat_lan_extract_last_ts() {
local log="$1"
local pattern="$2"
[ -f "$log" ] || { echo ""; return; }
grep -E "$pattern" "$log" 2>/dev/null \
| sed -E 's/\x1b\[[0-9;]*[mK]//g' \
| grep -oE '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9:.]+Z' \
| tail -1
}
# Compute the per-node stall_signature events dict + derived fields by
# scanning the container's docker log for known event patterns. Echoes
# a JSON object suitable for inlining into the rep's signature.json.
_nat_lan_node_stall_signature() {
local nodelog="$1"
local startup_ts nostr_ts adoption_ts handshake_init_ts msg2_sent_ts
local cross_init_progress_ts cross_init_connected_ts handshake_failed_ts
local last_log_ts
startup_ts=$(_nat_lan_extract_last_ts "$nodelog" 'Node started')
nostr_ts=$(_nat_lan_extract_last_ts "$nodelog" 'Started Nostr UDP NAT traversal attempt|nostr notify loop entered')
adoption_ts=$(_nat_lan_extract_last_ts "$nodelog" 'Adopted NAT traversal socket')
handshake_init_ts=$(_nat_lan_extract_last_ts "$nodelog" 'Connection initiated')
msg2_sent_ts=$(_nat_lan_extract_last_ts "$nodelog" 'Sent msg2 response')
cross_init_progress_ts=$(_nat_lan_extract_last_ts "$nodelog" 'Ignoring established NAT traversal while peer handshake is already in progress')
cross_init_connected_ts=$(_nat_lan_extract_last_ts "$nodelog" 'Ignoring established NAT traversal for already-connected peer')
handshake_failed_ts=$(_nat_lan_extract_last_ts "$nodelog" 'Handshake completion failed')
last_log_ts=$(_nat_lan_extract_last_ts "$nodelog" '.')
# Derive last_meaningful_event_category and ts by string-max across
# categories (ISO 8601 string compare == time compare). Empty
# category timestamps don't contribute.
local cat="" cat_ts=""
local -a pairs=(
"startup:$startup_ts"
"discovery:$nostr_ts"
"adoption:$adoption_ts"
"handshake_init:$handshake_init_ts"
"msg2_sent:$msg2_sent_ts"
"cross_init_ignore_progress:$cross_init_progress_ts"
"cross_init_ignore_connected:$cross_init_connected_ts"
"handshake_failed:$handshake_failed_ts"
)
local p name ts
for p in "${pairs[@]}"; do
name="${p%%:*}"
ts="${p#*:}"
if [ -n "$ts" ] && [[ "$ts" > "$cat_ts" ]]; then
cat_ts="$ts"
cat="$name"
fi
done
# silent_gap_s: seconds between last_meaningful_event_ts and
# last_log_ts. Large gap → daemon stayed alive but stopped doing
# meaningful work. Computed via Python (date math is fragile in
# pure bash). Empty if either timestamp is missing.
local silent_gap_s=""
if [ -n "$last_log_ts" ] && [ -n "$cat_ts" ]; then
silent_gap_s=$(python3 -c "
from datetime import datetime
def p(s):
return datetime.strptime(s.rstrip('Z')[:26], '%Y-%m-%dT%H:%M:%S.%f' if '.' in s else '%Y-%m-%dT%H:%M:%S')
try:
print(round((p('$last_log_ts') - p('$cat_ts')).total_seconds(), 3))
except Exception:
print('')
" 2>/dev/null || echo "")
fi
jq -n \
--arg last_log "$last_log_ts" \
--arg last_meaningful "$cat_ts" \
--arg last_cat "$cat" \
--arg silent_gap "$silent_gap_s" \
--arg startup "$startup_ts" \
--arg discovery "$nostr_ts" \
--arg adoption "$adoption_ts" \
--arg handshake_init "$handshake_init_ts" \
--arg msg2_sent "$msg2_sent_ts" \
--arg ci_progress "$cross_init_progress_ts" \
--arg ci_connected "$cross_init_connected_ts" \
--arg hs_failed "$handshake_failed_ts" \
'{
last_log_ts: (if $last_log == "" then null else $last_log end),
last_meaningful_event_ts: (if $last_meaningful == "" then null else $last_meaningful end),
last_event_category: (if $last_cat == "" then null else $last_cat end),
silent_gap_s: (if $silent_gap == "" then null else ($silent_gap | tonumber) end),
events: {
startup: (if $startup == "" then null else $startup end),
discovery: (if $discovery == "" then null else $discovery end),
adoption: (if $adoption == "" then null else $adoption end),
handshake_init: (if $handshake_init == "" then null else $handshake_init end),
msg2_sent: (if $msg2_sent == "" then null else $msg2_sent end),
cross_init_ignore_progress: (if $ci_progress == "" then null else $ci_progress end),
cross_init_ignore_connected: (if $ci_connected == "" then null else $ci_connected end),
handshake_failed: (if $hs_failed == "" then null else $hs_failed end)
}
}'
}
parse_nat_lan() {
local REP_DIR="$1"
local log="$REP_DIR/test-output.log"
@@ -355,12 +528,59 @@ parse_nat_lan() {
cross_init_observed="true"
fi
cat <<EOF >"$REP_DIR/signature.json"
{
"peer_adoption_timeout": $peer_adoption_timeout,
"cross_init_observed": $cross_init_observed
}
EOF
# Per-node stall_signature: scan each container's docker log for
# known event patterns and extract last-occurrence timestamps. Used
# by the aggregation phase to bin stalls into localized (both nodes
# last at same category) / distributed (different categories) /
# silent (daemon emitted nothing meaningful for >threshold s before
# timeout) classes. See _nat_lan_node_stall_signature for the event
# taxonomy.
local sig_a sig_b
sig_a=$(_nat_lan_node_stall_signature "$REP_DIR/docker-logs/fips-nat-lan-a.log")
sig_b=$(_nat_lan_node_stall_signature "$REP_DIR/docker-logs/fips-nat-lan-b.log")
# Top-level stall_class derived from per-node last_event_category:
# no_timeout — peer_adoption_timeout=false (success case)
# silent — either node's silent_gap_s > 5
# localized — both nodes' last_event_category is the same
# distributed — categories differ
# incomplete — categories missing on one or both nodes
local stall_class="no_timeout"
if [ "$peer_adoption_timeout" = "true" ]; then
local cat_a cat_b gap_a gap_b
cat_a=$(echo "$sig_a" | jq -r '.last_event_category // ""')
cat_b=$(echo "$sig_b" | jq -r '.last_event_category // ""')
gap_a=$(echo "$sig_a" | jq -r '.silent_gap_s // 0')
gap_b=$(echo "$sig_b" | jq -r '.silent_gap_s // 0')
local silent_a silent_b
silent_a=$(awk -v g="$gap_a" 'BEGIN{ print (g > 5) ? "1" : "0" }')
silent_b=$(awk -v g="$gap_b" 'BEGIN{ print (g > 5) ? "1" : "0" }')
if [ "$silent_a" = "1" ] || [ "$silent_b" = "1" ]; then
stall_class="silent"
elif [ -z "$cat_a" ] || [ -z "$cat_b" ]; then
stall_class="incomplete"
elif [ "$cat_a" = "$cat_b" ]; then
stall_class="localized"
else
stall_class="distributed"
fi
fi
jq -n \
--argjson peer_adoption_timeout "$peer_adoption_timeout" \
--argjson cross_init_observed "$cross_init_observed" \
--argjson sig_a "$sig_a" \
--argjson sig_b "$sig_b" \
--arg stall_class "$stall_class" \
'{
peer_adoption_timeout: $peer_adoption_timeout,
cross_init_observed: $cross_init_observed,
stall_class: $stall_class,
stall_signature: {
"fips-nat-lan-a": $sig_a,
"fips-nat-lan-b": $sig_b
}
}' >"$REP_DIR/signature.json"
}
mechanism_match_nat_lan() {