96febc7b
test: line coverage for the e2e suite, via kcov
a73x 2026-08-21 20:24
Commit message
Makefile
| Old | New | ||
|---|---|---|---|
| @@ -2,7 +2,7 @@ | |||
| 2 | # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. | 2 | # default zig is 0.17-dev. Override with ZIG=... if yours lives elsewhere. |
| 3 | ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig | 3 | ZIG ?= $(HOME)/Downloads/zig-x86_64-linux-0.15.2/zig |
| 4 | 4 | ||
| 5 | .PHONY: build check ci test e2e soak bench agent throughput deps clean clean-deps xversion xversion-build install release | 5 | .PHONY: build check ci test e2e soak bench agent throughput coverage deps clean clean-deps xversion xversion-build install release |
| 6 | 6 | ||
| 7 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target | 7 | # The QUIC stack (deps/quic) is built on demand by build.zig, so no target |
| 8 | # here needs to depend on this one. It exists to make the one-time cost | 8 | # here needs to depend on this one. It exists to make the one-time cost |
| @@ -94,6 +94,30 @@ bench: | |||
| 94 | # reading, so a timing assertion there would measure ghostty's debug wiring | 94 | # reading, so a timing assertion there would measure ghostty's debug wiring |
| 95 | # and never this code. That is also why there is no build.zig step — a step | 95 | # and never this code. That is also why there is no build.zig step — a step |
| 96 | # is handed the default-optimize artifacts, which are the wrong ones. | 96 | # is handed the default-optimize artifacts, which are the wrong ones. |
| 97 | # Line coverage for the e2e suite, via kcov (pacman -S kcov). | ||
| 98 | # | ||
| 99 | # ReleaseSafe in its own prefix, for throughput's reason: kcov traps on every | ||
| 100 | # instrumented line, so its cost rides on top of whatever the binary already | ||
| 101 | # pays per line, and a Debug ghostty pays the page-integrity check documented | ||
| 102 | # above on top of that. Nothing here has measured the Debug pairing to be | ||
| 103 | # unusable — the runs that looked slow were hanging, for the reason | ||
| 104 | # test/coverage.sh gives — so this is the documented 600x avoided, not a | ||
| 105 | # number this target established. | ||
| 106 | # | ||
| 107 | # The trade is line granularity: an optimised build inlines, so lines merge | ||
| 108 | # into their callers and the denominator shrinks. Measured on muxd: 1639 | ||
| 109 | # instrumented lines here against 2651 in Debug. | ||
| 110 | # | ||
| 111 | # Outside `ci` deliberately — it needs an optional system package and is | ||
| 112 | # still several times slower than the suite it wraps. | ||
| 113 | COVDIR ?= dist/coverage | ||
| 114 | coverage: | ||
| 115 | $(ZIG) build -Doptimize=ReleaseSafe -p $(COVDIR) | ||
| 116 | ./test/coverage.sh $(COVDIR)/bin/muxd $(COVDIR)/bin/mux \ | ||
| 117 | $(COVDIR)/bin/rawmode $(COVDIR)/bin/delaypipe $(COVDIR)/bin/render \ | ||
| 118 | $(COVDIR)/bin/ptyclient $(COVDIR)/bin/wsclient $(COVDIR)/bin/muxweb \ | ||
| 119 | $(COVDIR)/bin/muxa | ||
| 120 | |||
| 97 | THRUDIR ?= dist/throughput | 121 | THRUDIR ?= dist/throughput |
| 98 | throughput: | 122 | throughput: |
| 99 | $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR) | 123 | $(ZIG) build -Doptimize=ReleaseSafe -p $(THRUDIR) |
test/coverage.sh
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,173 @@ | |||
| 1 | #!/bin/sh | ||
| 2 | # Line coverage for the e2e suite. kcov wraps the four user binaries, the | ||
| 3 | # suite runs UNCHANGED, and the per-process databases are merged into one | ||
| 4 | # report. | ||
| 5 | # | ||
| 6 | # kcov is a ptrace tracer, not an instrumentation pass, so nothing here | ||
| 7 | # changes how anything is built: the report describes the same Debug | ||
| 8 | # artifacts `zig build e2e` grades, not a special coverage build. That is | ||
| 9 | # the whole reason kcov was chosen over -fprofile-instr-generate, which Zig | ||
| 10 | # 0.15 does not expose at all. | ||
| 11 | # | ||
| 12 | # Two consequences of ptrace are load-bearing, and both are why this file | ||
| 13 | # exists instead of a one-line kcov invocation: | ||
| 14 | # | ||
| 15 | # - kcov cannot nest. It sets PTRACE_O_TRACEFORK, so every descendant of | ||
| 16 | # a traced process is ALREADY traced, and an inner kcov's TRACEME is | ||
| 17 | # refused with EPERM. `mux --via` spawns a muxd, which is exactly that | ||
| 18 | # shape. MUX_KCOV_ACTIVE makes a nested shim exec the real binary bare: | ||
| 19 | # those lines are attributed to whichever ancestor is traced, or lost, | ||
| 20 | # but the suite never dies. | ||
| 21 | # - the pid the suite holds becomes kcov's, not the daemon's. SIGKILL is | ||
| 22 | # the one signal a wrapper cannot forward, so a leg that kill -9s a | ||
| 23 | # daemon would leave the real one alive holding its socket. e2e.sh's | ||
| 24 | # hardkill answers that; see the comment there. | ||
| 25 | # - kcov waits for the LAST traced descendant, and `muxd start` daemonises: | ||
| 26 | # its launcher forks, the parent exits, and the daemon reparents to init. | ||
| 27 | # Traced, that daemon outlives the scenario, so kcov never exits and the | ||
| 28 | # suite's `wait` on it never returns — a hang at the auto-start leg that | ||
| 29 | # reads as a slow run, and one that ignores SIGTERM, so `timeout` will not | ||
| 30 | # free it either. The shims therefore run `start` BARE, exporting | ||
| 31 | # MUX_KCOV_ACTIVE so the `muxd run` it re-execs stays bare too. The cost | ||
| 32 | # is the auto-started daemons' lines; the alternative was a suite that | ||
| 33 | # could not finish. | ||
| 34 | # | ||
| 35 | # Not --exit-first-process, which is kcov's own answer to daemons: it | ||
| 36 | # reparents the tracer, and then the real process is no longer a CHILD of | ||
| 37 | # the pid the suite holds — which is precisely what hardkill needs it to | ||
| 38 | # be. It trades this hang for a dead hardkill and a hang one leg later. | ||
| 39 | # | ||
| 40 | # A SIGKILLed wrapper writes nothing. kcov commits its database on clean exit | ||
| 41 | # only — measured across --collect-only, --output-interval=1000 and full | ||
| 42 | # report mode, all three yield no coverage.db when the wrapper is SIGKILLed. | ||
| 43 | # That is why e2e.sh's hardkill signals the child and then WAITS for the | ||
| 44 | # wrapper rather than killing both at once, and why the daemons this suite | ||
| 45 | # tears down with SIGKILL would otherwise contribute nothing at all. | ||
| 46 | |||
| 47 | set -eu | ||
| 48 | MUXD="$1"; MUX="$2"; RAWMODE="$3"; DELAYPIPE="$4"; RENDER="$5"; PTYCLIENT="$6" | ||
| 49 | WSCLIENT="$7"; MUXWEB="$8" | ||
| 50 | # Same list as the e2e and soak steps, muxa included — see the note in | ||
| 51 | # build.zig; all three must be handed the same binaries in the same order. | ||
| 52 | MUXA="$9" | ||
| 53 | |||
| 54 | command -v kcov > /dev/null 2>&1 || { | ||
| 55 | echo "coverage FAIL: this target needs kcov (pacman -S kcov)" | ||
| 56 | echo " It is a ptrace tracer, so no rebuild is required —" | ||
| 57 | echo " it reads DWARF from the binaries already built." | ||
| 58 | exit 1 | ||
| 59 | } | ||
| 60 | |||
| 61 | E2E="$(dirname "$0")/e2e.sh" | ||
| 62 | SRC="$(cd "$(dirname "$0")/../src" && pwd)" | ||
| 63 | OUT="${COV_OUT:-$(cd "$(dirname "$0")/.." && pwd)/zig-out/coverage}" | ||
| 64 | SHIM="$OUT/shim" | ||
| 65 | RAW="$OUT/raw" | ||
| 66 | rm -rf "$OUT" | ||
| 67 | mkdir -p "$SHIM" "$RAW" | ||
| 68 | |||
| 69 | # One shim per wrapped binary, each writing to its own $$-suffixed directory: | ||
| 70 | # kcov cannot have two live processes share one output dir, and this suite | ||
| 71 | # runs a dozen daemons and hundreds of clients. They are merged below. | ||
| 72 | # COV_WRAP names which binaries to trace. `mux` is NOT in the default set, | ||
| 73 | # and that is a limitation rather than a preference: a traced `mux` that | ||
| 74 | # auto-starts a daemon can never exit. The daemon reparents to init but stays | ||
| 75 | # TRACED — PTRACE_O_TRACEFORK follows it there — and kcov waits for its last | ||
| 76 | # traced descendant, so it waits on a process designed never to end. The | ||
| 77 | # auto-start and `--via` legs wedge on this, and a SIGTERM will not free it. | ||
| 78 | # | ||
| 79 | # Excluding the `start` verb (below) does not cover it: the daemon is spawned | ||
| 80 | # by the client, not named on the shim's command line. --exit-first-process is | ||
| 81 | # kcov's answer and does fix this one, but it reparents the tracer, and then | ||
| 82 | # the real process is no longer a child of the pid the suite holds — which | ||
| 83 | # breaks hardkill and moves the hang one leg later. Daemons carry server.zig, | ||
| 84 | # so tracing them and not the client is the side of the trade worth taking. | ||
| 85 | # | ||
| 86 | # Set COV_WRAP="mux" to measure the client over a slice that avoids those | ||
| 87 | # legs (E2E_STOP_AFTER pairs with it), and merge the two runs by hand. | ||
| 88 | for spec in $(echo "${COV_WRAP:-muxd muxa muxweb}" | tr ' ' '\n' | while read -r w; do | ||
| 89 | case "$w" in | ||
| 90 | muxd) echo "muxd|$MUXD" ;; | ||
| 91 | mux) echo "mux|$MUX" ;; | ||
| 92 | muxa) echo "muxa|$MUXA" ;; | ||
| 93 | muxweb) echo "muxweb|$MUXWEB" ;; | ||
| 94 | "") ;; | ||
| 95 | *) echo "coverage FAIL: COV_WRAP names unknown binary '$w'" >&2; exit 1 ;; | ||
| 96 | esac | ||
| 97 | done); do | ||
| 98 | name=${spec%%|*} | ||
| 99 | real=${spec#*|} | ||
| 100 | real=$(cd "$(dirname "$real")" && pwd)/$(basename "$real") | ||
| 101 | cat > "$SHIM/$name" <<SHIM_EOF | ||
| 102 | #!/bin/sh | ||
| 103 | [ -n "\$MUX_KCOV_ACTIVE" ] && exec "$real" "\$@" | ||
| 104 | MUX_KCOV_ACTIVE=1; export MUX_KCOV_ACTIVE | ||
| 105 | case " \$* " in | ||
| 106 | *" start "*) exec "$real" "\$@" ;; | ||
| 107 | esac | ||
| 108 | exec kcov --collect-only --include-path=$SRC "$RAW/$name.\$\$" "$real" "\$@" | ||
| 109 | SHIM_EOF | ||
| 110 | chmod +x "$SHIM/$name" | ||
| 111 | done | ||
| 112 | |||
| 113 | # e2e.sh prepends `dirname "$MUXD"` to PATH so an auto-started daemon is the | ||
| 114 | # one under test. That directory is $SHIM here, which is what makes the | ||
| 115 | # daemons the suite starts BY NAME rather than by path get wrapped too. | ||
| 116 | # Scenario boundaries, stamped by e2e.sh's ok(). kcov writes a database when | ||
| 117 | # a traced process EXITS, so a database's mtime and this file together say | ||
| 118 | # which scenario was running when it was written — which is the only | ||
| 119 | # per-scenario attribution available from a single run. It is exact for the | ||
| 120 | # short-lived clients and coarse for the dozen daemons that outlive several | ||
| 121 | # scenarios and land wherever they were killed. | ||
| 122 | OK_LOG="$OUT/scenarios.tsv" | ||
| 123 | export E2E_OK_LOG="$OK_LOG" | ||
| 124 | |||
| 125 | # Tracing costs roughly half again in wall clock, and several legs assert on a | ||
| 126 | # reconnect or a handoff healing inside a real-time budget. Those budgets are | ||
| 127 | # generous at native speed and merely tight under a tracer, so the suite is | ||
| 128 | # told to wait longer — not to demand less. See e2e.sh's TIME_SCALE note. | ||
| 129 | export E2E_TIME_SCALE="${COV_TIME_SCALE:-4}" | ||
| 130 | |||
| 131 | set +e | ||
| 132 | # Each argument is the shim if that binary is wrapped, the real thing if not. | ||
| 133 | pick() { if [ -x "$SHIM/$1" ]; then echo "$SHIM/$1"; else echo "$2"; fi; } | ||
| 134 | "$E2E" "$(pick muxd "$MUXD")" "$(pick mux "$MUX")" "$RAWMODE" "$DELAYPIPE" \ | ||
| 135 | "$RENDER" "$PTYCLIENT" "$WSCLIENT" "$(pick muxweb "$MUXWEB")" \ | ||
| 136 | "$(pick muxa "$MUXA")" | ||
| 137 | E2E_RC=$? | ||
| 138 | set -e | ||
| 139 | |||
| 140 | # Reported, never fatal. A failing suite still collected everything up to | ||
| 141 | # the failure, and throwing that away would make the one run you most want | ||
| 142 | # to look at the one run that produces nothing. | ||
| 143 | [ "$E2E_RC" -eq 0 ] || echo "coverage NOTE: e2e exited $E2E_RC; report covers the run up to that point" | ||
| 144 | |||
| 145 | DIRS=$(find "$RAW" -mindepth 1 -maxdepth 1 -type d | wc -l) | ||
| 146 | [ "$DIRS" -gt 0 ] || { echo "coverage FAIL: no kcov databases in $RAW"; exit 1; } | ||
| 147 | kcov --merge --include-path="$SRC" "$OUT/merged" "$RAW"/* > /dev/null 2>&1 | ||
| 148 | |||
| 149 | JSON="$OUT/merged/kcov-merged/coverage.json" | ||
| 150 | [ -f "$JSON" ] || { echo "coverage FAIL: merge produced no $JSON"; exit 1; } | ||
| 151 | |||
| 152 | # Sorted by UNCOVERED lines, not by percentage: the question this report | ||
| 153 | # answers is "what is worth a test", and 0% of a 24-line file is not the | ||
| 154 | # same size of gap as 54% of server.zig. | ||
| 155 | echo | ||
| 156 | echo "coverage: $DIRS processes traced, e2e exited $E2E_RC" | ||
| 157 | echo | ||
| 158 | printf '%-22s %8s %13s %10s\n' file cov covered/total uncovered | ||
| 159 | awk -F'"' '/"file":/ { | ||
| 160 | n = split($4, p, "/") | ||
| 161 | printf "%-22s %7s%% %6d/%-6d %10d\n", p[n], $8, $12, $16, $16 - $12 | ||
| 162 | }' "$JSON" | sort -k4 -rn | ||
| 163 | # The totals live at the top level of the same file, past the files array, | ||
| 164 | # and are the only bare (unquoted) numbers in it. | ||
| 165 | awk -F'[:,]' ' | ||
| 166 | /^ "percent_covered"/ { gsub(/[ "]/, "", $2); pct = $2 } | ||
| 167 | /^ "covered_lines"/ { gsub(/[ "]/, "", $2); cov = $2 } | ||
| 168 | /^ "total_lines"/ { gsub(/[ "]/, "", $2); tot = $2 } | ||
| 169 | END { printf "\nTOTAL %s%% %s/%s lines\n", pct, cov, tot } | ||
| 170 | ' "$JSON" | ||
| 171 | echo | ||
| 172 | echo "html: file://$OUT/merged/kcov-merged/index.html" | ||
| 173 | echo "per-process databases: $RAW (mtimes attribute to $OK_LOG)" | ||
test/e2e.sh
| Old | New | ||
|---|---|---|---|
| @@ -508,8 +508,24 @@ want_stat_ge() { | |||
| 508 | # Wait until PATTERN shows up in FILE (default 15s). Timing that keys off the | 508 | # Wait until PATTERN shows up in FILE (default 15s). Timing that keys off the |
| 509 | # session's own output instead of a fixed sleep: the marker is proof the | 509 | # session's own output instead of a fixed sleep: the marker is proof the |
| 510 | # client is attached and idle, which is exactly the state the tear needs. | 510 | # client is attached and idle, which is exactly the state the tear needs. |
| 511 | # E2E_TIME_SCALE multiplies the polling BUDGETS below, and nothing else. It | ||
| 512 | # exists for test/coverage.sh: a ptrace-traced binary runs about half again | ||
| 513 | # slower, which is enough to blow a 25s convergence budget that is generous | ||
| 514 | # at native speed — and the failure reads as "the attach never converged" | ||
| 515 | # rather than "the tracer is slow", which is a lie about the product. | ||
| 516 | # | ||
| 517 | # Budgets only. Not the sleeps between polls, not a `timeout` that a scenario | ||
| 518 | # asserts on, and never a threshold: scaling this changes how long the suite | ||
| 519 | # is WILLING TO WAIT, never what it demands to see. A run at scale 4 that | ||
| 520 | # passes proves the same facts as a run at scale 1, just later. | ||
| 521 | TIME_SCALE="${E2E_TIME_SCALE:-1}" | ||
| 522 | case "$TIME_SCALE" in | ||
| 523 | ''|*[!0-9]*) echo "e2e FAIL: E2E_TIME_SCALE must be a positive integer"; exit 1 ;; | ||
| 524 | esac | ||
| 525 | [ "$TIME_SCALE" -ge 1 ] || { echo "e2e FAIL: E2E_TIME_SCALE must be >= 1"; exit 1; } | ||
| 526 | |||
| 511 | wait_for() { | 527 | wait_for() { |
| 512 | _file="$1"; _pat="$2"; _ticks=$(( ${3:-15} * 10 )); _i=0 | 528 | _file="$1"; _pat="$2"; _ticks=$(( ${3:-15} * 10 * TIME_SCALE )); _i=0 |
| 513 | while [ "$_i" -lt "$_ticks" ]; do | 529 | while [ "$_i" -lt "$_ticks" ]; do |
| 514 | if [ -f "$_file" ] && grep -q "$_pat" "$_file" 2>/dev/null; then return 0; fi | 530 | if [ -f "$_file" ] && grep -q "$_pat" "$_file" 2>/dev/null; then return 0; fi |
| 515 | sleep 0.1; _i=$((_i+1)) | 531 | sleep 0.1; _i=$((_i+1)) |
| @@ -517,12 +533,46 @@ wait_for() { | |||
| 517 | return 1 | 533 | return 1 |
| 518 | } | 534 | } |
| 519 | 535 | ||
| 536 | # hardkill PID — SIGKILL a pid and anything it fathered, children first. | ||
| 537 | # | ||
| 538 | # Plain `kill -9 "$pid"` is right only while the pid the suite holds IS the | ||
| 539 | # process under test. Under the coverage harness (test/coverage.sh) it is a | ||
| 540 | # kcov wrapper instead, and SIGKILL is the one signal a wrapper cannot | ||
| 541 | # forward: killing the node alone would leave the real daemon alive holding | ||
| 542 | # its socket, and every leg that waits for that daemon to die would hang | ||
| 543 | # rather than fail. Children first so a traced child's death is observed by | ||
| 544 | # its tracer, which is when kcov writes the coverage it has collected. | ||
| 545 | # | ||
| 546 | # With no wrapper in the picture there are no children and this is exactly | ||
| 547 | # `kill -9`, which is why the abort legs keep the semantics they assert on: | ||
| 548 | # the daemon still dies by SIGKILL, still without unlinking its socket. | ||
| 549 | hardkill() { | ||
| 550 | # The test is what the process IS, not whether it has children. A daemon | ||
| 551 | # has children too — a session shell per attach — and killing those first | ||
| 552 | # ends the session CLEANLY, which is the one thing the abort legs must not | ||
| 553 | # see: they assert on a client whose daemon vanished under it, and a tidy | ||
| 554 | # session exit takes a different path out of the client (measured: exit | ||
| 555 | # 128 where the leg wants 0). Only a wrapper gets the two-step treatment. | ||
| 556 | if [ "$(ps -o comm= -p "$1" 2>/dev/null)" = kcov ]; then | ||
| 557 | # A wrapper writes its coverage when it sees its child die, so give it | ||
| 558 | # that moment. Killing both at once loses the database entirely — | ||
| 559 | # measured: SIGKILLing kcov yields no coverage.db at any | ||
| 560 | # --output-interval, so the write only ever happens on a clean exit. | ||
| 561 | # Bounded at 1s, and it costs nothing when the wrapper exits at once. | ||
| 562 | _i=0 | ||
| 563 | while kill -0 "$1" 2>/dev/null && [ "$_i" -lt 20 ]; do | ||
| 564 | sleep 0.05; _i=$((_i + 1)) | ||
| 565 | done | ||
| 566 | fi | ||
| 567 | kill -9 "$1" 2>/dev/null || true | ||
| 568 | } | ||
| 569 | |||
| 520 | # Poll until nothing answers on a socket path (2s). Keyed off the daemon's | 570 | # Poll until nothing answers on a socket path (2s). Keyed off the daemon's |
| 521 | # own liveness rather than a fixed sleep, same reasoning as wait_for. | 571 | # own liveness rather than a fixed sleep, same reasoning as wait_for. |
| 522 | wait_gone() { | 572 | wait_gone() { |
| 523 | _i=0 | 573 | _i=0 |
| 524 | while "$MUXD" dump --sock "$1" > /dev/null 2>&1; do | 574 | while "$MUXD" dump --sock "$1" > /dev/null 2>&1; do |
| 525 | _i=$((_i + 1)); [ "$_i" -lt 40 ] || { echo "e2e FAIL: daemon on $1 never died"; exit 1; } | 575 | _i=$((_i + 1)); [ "$_i" -lt $(( 40 * TIME_SCALE )) ] || { echo "e2e FAIL: daemon on $1 never died"; exit 1; } |
| 526 | sleep 0.05 | 576 | sleep 0.05 |
| 527 | done | 577 | done |
| 528 | } | 578 | } |
| @@ -534,7 +584,7 @@ wait_gone() { | |||
| 534 | # to bind almost always said why; pass "" for the spawns that have none. | 584 | # to bind almost always said why; pass "" for the spawns that have none. |
| 535 | wait_sock() { | 585 | wait_sock() { |
| 536 | _i=0 | 586 | _i=0 |
| 537 | while [ ! -S "$1" ] && [ "$_i" -lt 50 ]; do sleep 0.1; _i=$((_i+1)); done | 587 | while [ ! -S "$1" ] && [ "$_i" -lt $(( 50 * TIME_SCALE )) ]; do sleep 0.1; _i=$((_i+1)); done |
| 538 | [ -S "$1" ] || { | 588 | [ -S "$1" ] || { |
| 539 | echo "e2e FAIL: $3" | 589 | echo "e2e FAIL: $3" |
| 540 | # An `if` rather than `[ -n "$2" ] && cat "$2"`, for converged_quiet's | 590 | # An `if` rather than `[ -n "$2" ] && cat "$2"`, for converged_quiet's |
| @@ -572,7 +622,7 @@ dump_session() { | |||
| 572 | # reach and fail 10s later blaming the wrong thing. | 622 | # reach and fail 10s later blaming the wrong thing. |
| 573 | wait_grid() { | 623 | wait_grid() { |
| 574 | _i=0 | 624 | _i=0 |
| 575 | while [ "$_i" -lt 100 ]; do | 625 | while [ "$_i" -lt $(( 100 * TIME_SCALE )) ]; do |
| 576 | dump_session "$1" "${4:-}" 2>/dev/null | grep -q "$2" && return 0 | 626 | dump_session "$1" "${4:-}" 2>/dev/null | grep -q "$2" && return 0 |
| 577 | sleep 0.1; _i=$((_i+1)) | 627 | sleep 0.1; _i=$((_i+1)) |
| 578 | done | 628 | done |
| @@ -589,7 +639,7 @@ wait_grid() { | |||
| 589 | # assertions further down. | 639 | # assertions further down. |
| 590 | wait_sessions() { | 640 | wait_sessions() { |
| 591 | _i=0 | 641 | _i=0 |
| 592 | while [ "$_i" -lt 100 ]; do | 642 | while [ "$_i" -lt $(( 100 * TIME_SCALE )) ]; do |
| 593 | "$MUXD" stats --sock "$1" 2>/dev/null | grep -q "sessions=$2" && return 0 | 643 | "$MUXD" stats --sock "$1" 2>/dev/null | grep -q "sessions=$2" && return 0 |
| 594 | sleep 0.1; _i=$((_i+1)) | 644 | sleep 0.1; _i=$((_i+1)) |
| 595 | done | 645 | done |
| @@ -851,6 +901,29 @@ OK_COUNT=0 | |||
| 851 | ok() { | 901 | ok() { |
| 852 | OK_COUNT=$((OK_COUNT + 1)) | 902 | OK_COUNT=$((OK_COUNT + 1)) |
| 853 | echo "e2e OK: $1" | 903 | echo "e2e OK: $1" |
| 904 | # Scenario boundaries, stamped for whoever needs to attribute something | ||
| 905 | # to the scenario that produced it. Nothing in this suite reads the file: | ||
| 906 | # test/coverage.sh maps each kcov database to the scenario that was | ||
| 907 | # running when the traced process wrote it, and the timestamps are also | ||
| 908 | # the only per-scenario timing this suite has ever been able to report. | ||
| 909 | # | ||
| 910 | # An `if` rather than `[ -n ... ] && printf`, for wait_sock's reason: a | ||
| 911 | # false guard as the last command in a function becomes that function's | ||
| 912 | # exit status, and under `set -e` every unstamped run would abort at its | ||
| 913 | # first passing scenario. | ||
| 914 | if [ -n "${E2E_OK_LOG:-}" ]; then | ||
| 915 | printf '%s\t%s\t%s\n' "$OK_COUNT" "$(date +%s.%N)" "$1" >> "$E2E_OK_LOG" | ||
| 916 | fi | ||
| 917 | # Prefix slicing. This suite is linear and stateful — the M13 scenarios | ||
| 918 | # run inside sessions the M10 scenarios created — so a prefix is the only | ||
| 919 | # slice that means anything, and a "run just scenario 40" filter would be | ||
| 920 | # a filter that lies. Exiting rather than skipping keeps that honest, and | ||
| 921 | # exiting 0 goes out through the trap, so a sliced run still tears down | ||
| 922 | # its daemons and still reports its leak verdict. | ||
| 923 | if [ -n "${E2E_STOP_AFTER:-}" ] && [ "$OK_COUNT" -ge "$E2E_STOP_AFTER" ]; then | ||
| 924 | echo "e2e STOP: sliced after $OK_COUNT scenarios (E2E_STOP_AFTER)" | ||
| 925 | exit 0 | ||
| 926 | fi | ||
| 854 | } | 927 | } |
| 855 | 928 | ||
| 856 | # ---- the leak sweep's two halves (hygiene kit, 6a) --------------------- | 929 | # ---- the leak sweep's two halves (hygiene kit, 6a) --------------------- |
| @@ -1427,7 +1500,7 @@ ok "styled content survives the paint path" | |||
| 1427 | { printf 'seq 1 60\n'; sleep 2; } | "$MUX" --sock "$SOCK" > "$OUT.kill" & | 1500 | { printf 'seq 1 60\n'; sleep 2; } | "$MUX" --sock "$SOCK" > "$OUT.kill" & |
| 1428 | CPID=$! | 1501 | CPID=$! |
| 1429 | sleep 1 | 1502 | sleep 1 |
| 1430 | kill -9 "$CPID" 2>/dev/null || true | 1503 | hardkill "$CPID" |
| 1431 | sleep 1 | 1504 | sleep 1 |
| 1432 | kill -0 "$DPID" || { echo "e2e FAIL: daemon died after client kill -9"; exit 1; } | 1505 | kill -0 "$DPID" || { echo "e2e FAIL: daemon died after client kill -9"; exit 1; } |
| 1433 | 1506 | ||
| @@ -1569,7 +1642,7 @@ set +e | |||
| 1569 | CLIPID=$! | 1642 | CLIPID=$! |
| 1570 | # Let the session establish, then take the daemon away mid-session. | 1643 | # Let the session establish, then take the daemon away mid-session. |
| 1571 | sleep 2 | 1644 | sleep 2 |
| 1572 | kill -9 "$D2PID" 2>/dev/null | 1645 | hardkill "$D2PID" |
| 1573 | D2PID="" | 1646 | D2PID="" |
| 1574 | wait "$CLIPID" | 1647 | wait "$CLIPID" |
| 1575 | RC=$? | 1648 | RC=$? |
| @@ -1658,7 +1731,7 @@ wait_for "$OUT.m7b" "m7b-pre-restart" 20 || { | |||
| 1658 | 1731 | ||
| 1659 | # SIGKILL leaves the socket file behind; the new daemon's stale-socket | 1732 | # SIGKILL leaves the socket file behind; the new daemon's stale-socket |
| 1660 | # recovery (ECONNREFUSED + S_ISSOCK -> unlink) is what lets it rebind here. | 1733 | # recovery (ECONNREFUSED + S_ISSOCK -> unlink) is what lets it rebind here. |
| 1661 | kill -9 "$D3PID" | 1734 | hardkill "$D3PID" |
| 1662 | sleep 0.5 | 1735 | sleep 0.5 |
| 1663 | "$MUXD" run --sock "$SOCK3" --shell /bin/sh > "$OUT.d3b.d" 2>&1 & | 1736 | "$MUXD" run --sock "$SOCK3" --shell /bin/sh > "$OUT.d3b.d" 2>&1 & |
| 1664 | D3PID=$! | 1737 | D3PID=$! |
| @@ -2030,7 +2103,7 @@ wait_for "$OUT.qk" "quic-pre-restart" 20 || { | |||
| 2030 | cat "$OUT.qk" "$OUT.qk.err" 2>/dev/null; exit 1; | 2103 | cat "$OUT.qk" "$OUT.qk.err" 2>/dev/null; exit 1; |
| 2031 | } | 2104 | } |
| 2032 | 2105 | ||
| 2033 | kill -9 "$D4PID" | 2106 | hardkill "$D4PID" |
| 2034 | D4PID="" | 2107 | D4PID="" |
| 2035 | sleep 0.5 | 2108 | sleep 0.5 |
| 2036 | "$MUXD" run --sock "$SOCK4" --shell /bin/sh \ | 2109 | "$MUXD" run --sock "$SOCK4" --shell /bin/sh \ |