Every gate wrote its server output into a per-run mktemp dir that its own cleanup trap deletes on exit — nothing to follow during the run, nothing to read after it. - chat -> /tmp/chat.log, web-app -> /tmp/web-app.log, site -> /tmp/site.log, log-watcher -> /tmp/log-watcher.log - truncated once at gate start, appended for the rest of the run, so one file holds the whole run in order - each gate PRINTS the path as its first line, with the tail -F command - legs are banner-separated and name their port and env (===== leg 2 - port 18902 - env WO_IO=epoll =====) Appending breaks readiness detection unless it is leg-scoped: - serve() used to grep the whole file for `listening`, which after the switch to append would match an EARLIER leg and return before the new server was up. It now records the line count first and searches only tail -n "+$LEGFROM"; the ASan scan is scoped the same way - log-watcher's checks grep per-invocation files, so those are kept and the output is teed into both — process substitution adds no pipeline stage, so $! is still the command's pid the gate kills and waits on - its one SYNCHRONOUS invocation appends after it finishes rather than teeing: the grep on the next line would race tee's flush Verified, all green: chat 11/0, web-app 46/0, site 21/0, log-watcher 7/0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
433 lines
17 KiB
Bash
Executable file
433 lines
17 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# scripts/chat-accept.sh — iteration 24's gate. The chat sample serves
|
|
# WebSocket rooms through the framework ([deps], file:// remote); a raw
|
|
# RFC 6455 python client (stdlib only, INDEPENDENT accept-key check)
|
|
# proves: the handshake, broadcast + presence + isolation across rooms,
|
|
# the 1k-clients-one-hot-room soak (fds/RSS accounted), and the SIGTERM
|
|
# drain (close frames, exit 0) — functional legs on BOTH WO_IO backends
|
|
# plus an ASan run.
|
|
set -uo pipefail
|
|
|
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
WOC="$ROOT/compiler/_build/default/bin/woc"
|
|
WOVM="$ROOT/runtime/wovm"
|
|
ASAN="$ROOT/runtime/build/wovm_asan"
|
|
PORT0="${CHAT_PORT:-18901}"
|
|
PORT="$PORT0"
|
|
SOAK_N="${CHAT_SOAK:-1000}"
|
|
|
|
pass=0; fail=0
|
|
ok() { echo "ok $1"; pass=$((pass + 1)); }
|
|
bad() { echo "FAIL $1 -- $2"; fail=$((fail + 1)); }
|
|
|
|
if [[ ! -x "$WOC" || ! -x "$WOVM" ]]; then
|
|
echo "chat-accept: build woc and wovm first" >&2; exit 1
|
|
fi
|
|
ulimit -n 8192 2>/dev/null || true
|
|
|
|
W="$(mktemp -d "${TMPDIR:-/tmp}/chat-accept.XXXXXX")"
|
|
SRV=""
|
|
# The example's server log lives at a STABLE path so a developer can
|
|
# `tail -F /tmp/chat.log` while this runs. It used to go to the per-run temp
|
|
# dir, which cleanup() deletes on exit — so there was nothing left to read and
|
|
# nothing to follow live. Truncated once here, then APPENDED by every leg with
|
|
# a banner, so one file holds the whole run in order.
|
|
SRVLOG="/tmp/chat.log"
|
|
: > "$SRVLOG"
|
|
LEG=0
|
|
LEGFROM=1
|
|
echo "server log: $SRVLOG (tail -F \"$SRVLOG\" to follow)"
|
|
|
|
cleanup() {
|
|
# kill EVERY server this run started, not merely the most recent $SRV: a leg
|
|
# that dies before clearing SRV used to orphan a listener, which then broke
|
|
# the next run on the same port. $W is unique per run, so matching on it
|
|
# cannot touch another run's processes.
|
|
[[ -n "$SRV" ]] && kill -9 "$SRV" 2>/dev/null
|
|
pkill -9 -f "$W/app/target/chat" 2>/dev/null
|
|
rm -rf "$W"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
cp -r "$ROOT/docs/examples/porch" "$W/fw"
|
|
git -C "$W/fw" init -q && git -C "$W/fw" add -A
|
|
git -C "$W/fw" -c user.email=t@t -c user.name=t commit -qm v01 && git -C "$W/fw" tag v0.1.0
|
|
cp -r "$ROOT/docs/examples/chat" "$W/app"
|
|
sed -i "s|https://github.com/shoneyj/porch|file://$W/fw|" "$W/app/wo.toml"
|
|
printf '[build]\nruntime = "%s"\n' "$WOVM" >> "$W/app/wo.toml"
|
|
|
|
if "$WOC" "$W/app" >"$W/build.out" 2>&1 && [[ -x "$W/app/target/chat" ]]; then
|
|
ok "deps chain + build"
|
|
else
|
|
bad "build" "$(grep -m1 error "$W/build.out" || head -1 "$W/build.out")"
|
|
echo "chat-accept: 1 checks, 1 failures"; exit 1
|
|
fi
|
|
|
|
# the raw client, shared by every leg
|
|
CLIENT="$W/wsc.py"
|
|
cat > "$CLIENT" <<'PYEOF'
|
|
import socket, base64, hashlib, os, time
|
|
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
BUF = {}
|
|
def connect(port, room, name, timeout=8, rcvbuf=None):
|
|
# rcvbuf: shrink THIS client's receive buffer so the server's socket fills
|
|
# quickly — how the WO_MAILBOX leg manufactures a genuinely slow member
|
|
# without sleeping. Must be set before connect() to take effect.
|
|
if rcvbuf is None:
|
|
s = socket.create_connection(("127.0.0.1", port), timeout=timeout)
|
|
else:
|
|
s = socket.socket()
|
|
s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
|
|
s.settimeout(timeout)
|
|
s.connect(("127.0.0.1", port))
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
s.sendall((f"GET /ws?room={room}&name={name} HTTP/1.1\r\nhost: a\r\n"
|
|
f"upgrade: websocket\r\nconnection: Upgrade\r\n"
|
|
f"sec-websocket-key: {key}\r\nsec-websocket-version: 13\r\n\r\n").encode())
|
|
d = b""
|
|
while b"\r\n\r\n" not in d: d += s.recv(2000)
|
|
head, _, rest = d.partition(b"\r\n\r\n")
|
|
BUF[s] = rest # a frame may already ride the same segment
|
|
head = head.decode()
|
|
assert " 101 " in head.splitlines()[0], head.splitlines()[0]
|
|
want = base64.b64encode(hashlib.sha1((key + GUID).encode()).digest()).decode()
|
|
assert want in head, "accept-key mismatch (independent check)"
|
|
return s
|
|
def _take(s, n, timeout):
|
|
s.settimeout(timeout)
|
|
b = BUF.get(s, b"")
|
|
while len(b) < n:
|
|
c = s.recv(4096)
|
|
if not c:
|
|
BUF[s] = b
|
|
return None
|
|
b += c
|
|
BUF[s] = b[n:]
|
|
return b[:n]
|
|
def send(s, text):
|
|
p = text.encode(); mask = os.urandom(4)
|
|
if len(p) < 126: hdr = bytes([0x81, 0x80 | len(p)])
|
|
else: hdr = bytes([0x81, 0x80 | 126, len(p) >> 8, len(p) & 255])
|
|
s.sendall(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(p)))
|
|
def recv(s, timeout=5):
|
|
h = _take(s, 2, timeout)
|
|
if h is None: return (-2, "") # EOF
|
|
b0, b1 = h[0], h[1]
|
|
ln = b1 & 0x7F
|
|
if ln == 126:
|
|
e = _take(s, 2, timeout); ln = (e[0] << 8) | e[1]
|
|
d = _take(s, ln, timeout) if ln else b""
|
|
return (b0 & 0x0F), (d or b"").decode(errors="replace")
|
|
PYEOF
|
|
|
|
serve() { # serve PORT [env...] — start + wait for THIS server's listener line
|
|
PORT="$1"; shift
|
|
LEG=$((LEG + 1))
|
|
printf '\n===== leg %d — port %s — %s =====\n' "$LEG" "$PORT" "${*:-default env}" >>"$SRVLOG"
|
|
# readiness is searched only in THIS leg's slice: the log is appended, never
|
|
# truncated, so a 'listening' line from an earlier leg would lie
|
|
LEGFROM=$(( $(wc -l < "$SRVLOG") + 1 ))
|
|
"$@" "$W/app/target/chat" "$PORT" >>"$SRVLOG" 2>&1 &
|
|
SRV=$!
|
|
for _ in $(seq 1 80); do
|
|
tail -n "+$LEGFROM" "$SRVLOG" 2>/dev/null | grep -q listening && return 0
|
|
sleep 0.1
|
|
done
|
|
return 1
|
|
}
|
|
|
|
functional() { # $1 = leg name
|
|
timeout 30 python3 - "$PORT" <<'PYEOF'
|
|
import sys; sys.path.insert(0, sys.argv[0].rsplit("/",1)[0])
|
|
port = int(sys.argv[1])
|
|
import importlib.util, os
|
|
spec = importlib.util.spec_from_file_location("wsc", os.environ["WSC"])
|
|
wsc = importlib.util.module_from_spec(spec); spec.loader.exec_module(wsc)
|
|
a = wsc.connect(port, "lobby", "alice")
|
|
assert wsc.recv(a) == (1, "* alice joined")
|
|
b = wsc.connect(port, "lobby", "bob")
|
|
assert wsc.recv(a) == (1, "* bob joined")
|
|
assert wsc.recv(b) == (1, "* bob joined")
|
|
c = wsc.connect(port, "other", "carol")
|
|
assert wsc.recv(c) == (1, "* carol joined")
|
|
wsc.send(a, "hello room")
|
|
assert wsc.recv(a) == (1, "alice: hello room")
|
|
assert wsc.recv(b) == (1, "alice: hello room")
|
|
import socket
|
|
try:
|
|
k, t = wsc.recv(c, timeout=0.8); assert False, f"leak into other room: {t}"
|
|
except socket.timeout: pass
|
|
b.close()
|
|
k, t = wsc.recv(a)
|
|
assert (k, t) == (1, "* bob left"), (k, t)
|
|
a.close(); c.close()
|
|
print("functional-ok")
|
|
PYEOF
|
|
}
|
|
|
|
# ---- 2. functional on both backends ----
|
|
export WSC="$CLIENT"
|
|
serve "$((PORT0 + 0))" env WO_IO=uring || bad "serve-uring" "no listener"
|
|
r="$(functional uring)"; [[ "$r" == *functional-ok* ]] \
|
|
&& ok "uring: handshake(key verified) + presence + broadcast + isolation + leave" \
|
|
|| bad "uring-functional" "$r"
|
|
kill -TERM "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null; SRV=""
|
|
|
|
serve "$((PORT0 + 1))" env WO_IO=epoll || bad "serve-epoll" "no listener"
|
|
r="$(functional epoll)"; [[ "$r" == *functional-ok* ]] \
|
|
&& ok "epoll: the same matrix" || bad "epoll-functional" "$r"
|
|
kill -TERM "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null; SRV=""
|
|
|
|
# ---- 3. the soak: N clients, ONE hot room ----
|
|
serve "$((PORT0 + 2))" || bad "serve-soak" "no listener"
|
|
fds_before="$(ls /proc/$SRV/fd 2>/dev/null | wc -l)"
|
|
fds_prev=99999
|
|
r="$(timeout 180 python3 - "$PORT" "$SOAK_N" <<'PYEOF'
|
|
import asyncio, sys, os, time, base64, hashlib
|
|
port, N = int(sys.argv[1]), int(sys.argv[2])
|
|
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
MARK = "the-hot-room-marker"
|
|
sem = asyncio.Semaphore(100)
|
|
async def client(i, results):
|
|
async with sem:
|
|
r, w = await asyncio.open_connection("127.0.0.1", port)
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
w.write((f"GET /ws?room=hot&name=c{i} HTTP/1.1\r\nhost: a\r\n"
|
|
f"upgrade: websocket\r\nconnection: Upgrade\r\n"
|
|
f"sec-websocket-key: {key}\r\nsec-websocket-version: 13\r\n\r\n").encode())
|
|
await w.drain()
|
|
d = b""
|
|
while b"\r\n\r\n" not in d: d += await r.read(2000)
|
|
if i == 0:
|
|
# the sender: wait for the herd, then one marker line
|
|
await asyncio.sleep(0)
|
|
results["sender_ready"].set()
|
|
try:
|
|
buf = b""
|
|
deadline = time.time() + 150
|
|
while time.time() < deadline:
|
|
try:
|
|
c = await asyncio.wait_for(r.read(8192), timeout=5)
|
|
except asyncio.TimeoutError:
|
|
if results["sent"].is_set(): break
|
|
continue
|
|
if not c: break
|
|
buf += c
|
|
# scan frames for the marker (server frames are unmasked, small)
|
|
if MARK.encode() in buf:
|
|
results["got"] += 1
|
|
return
|
|
finally:
|
|
w.close()
|
|
async def main():
|
|
results = {"got": 0, "sender_ready": asyncio.Event(), "sent": asyncio.Event()}
|
|
conns = []
|
|
# keep the sender's socket outside the tasks: join first
|
|
sr, sw = None, None
|
|
async def sender():
|
|
nonlocal sr, sw
|
|
async with sem:
|
|
sr, sw = await asyncio.open_connection("127.0.0.1", port)
|
|
key = base64.b64encode(os.urandom(16)).decode()
|
|
sw.write((f"GET /ws?room=hot&name=sender HTTP/1.1\r\nhost: a\r\n"
|
|
f"upgrade: websocket\r\nconnection: Upgrade\r\n"
|
|
f"sec-websocket-key: {key}\r\nsec-websocket-version: 13\r\n\r\n").encode())
|
|
await sw.drain()
|
|
d = b""
|
|
while b"\r\n\r\n" not in d: d += await sr.read(2000)
|
|
await sender()
|
|
tasks = [asyncio.create_task(client(i, results)) for i in range(N)]
|
|
await asyncio.sleep(max(2.0, N / 250)) # let the herd join + drain presence
|
|
p = MARK.encode(); mask = os.urandom(4)
|
|
hdr = bytes([0x81, 0x80 | len(p)])
|
|
sw.write(hdr + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(p)))
|
|
await sw.drain()
|
|
results["sent"].set()
|
|
t0 = time.time()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
el = int((time.time() - t0) * 1000)
|
|
sw.close()
|
|
print(f"{results['got']}|{N}|{el}")
|
|
asyncio.run(main())
|
|
PYEOF
|
|
)"
|
|
got="${r%%|*}"; rest="${r#*|}"; n="${rest%%|*}"; el="${rest#*|}"
|
|
[[ "$got" == "$n" ]] \
|
|
&& ok "soak: the marker reached all $got/$n hot-room clients (${el}ms after send)" \
|
|
|| bad "soak" "$r"
|
|
# leave-broadcast storms take a moment to settle after 1k closes
|
|
for _ in $(seq 1 20); do
|
|
fds_w1="$(ls /proc/$SRV/fd 2>/dev/null | wc -l)"
|
|
[[ "$fds_w1" -le "$fds_prev" ]] && break
|
|
fds_prev="$fds_w1"
|
|
sleep 0.5
|
|
done
|
|
# The fd check is for a per-CONNECTION leak, and a fixed tolerance cannot
|
|
# express that. Shards initialise LAZILY (runtime/src/vm.c: a worker's vm is
|
|
# not paid for until its first fiber arrives), so the first wave legitimately
|
|
# adds one io_uring + one eventfd PER SHARD, capped at nproc — on a 20-core
|
|
# box that is +18, which the old `fds_before + 8` read as a leak. Measured
|
|
# 2026-08-27: 26 -> 44 after 20 clients, then still 44 after 40 more.
|
|
#
|
|
# So assert the invariant itself: a SECOND wave must not raise the count.
|
|
# Core-count independent, and it catches a slow leak that any fixed
|
|
# tolerance would hide inside its own slack.
|
|
timeout 60 python3 - "$PORT" 20 <<'PYEOF' >/dev/null 2>&1
|
|
import socket, base64, os, sys, time
|
|
port, n = int(sys.argv[1]), int(sys.argv[2])
|
|
socks = []
|
|
for i in range(n):
|
|
s = socket.create_connection(("127.0.0.1", port), timeout=8)
|
|
k = base64.b64encode(os.urandom(16)).decode()
|
|
s.sendall((f"GET /ws?room=fdwave&name=w{i} HTTP/1.1\r\nhost: a\r\n"
|
|
f"upgrade: websocket\r\nconnection: Upgrade\r\n"
|
|
f"sec-websocket-key: {k}\r\nsec-websocket-version: 13\r\n\r\n").encode())
|
|
h = b""
|
|
while b"\r\n\r\n" not in h:
|
|
h += s.recv(4096)
|
|
socks.append(s)
|
|
time.sleep(0.5)
|
|
for s in socks:
|
|
s.close()
|
|
PYEOF
|
|
fds_after="$fds_w1"
|
|
for _ in $(seq 1 20); do
|
|
fds_after="$(ls /proc/$SRV/fd 2>/dev/null | wc -l)"
|
|
[[ "$fds_after" -le "$fds_w1" ]] && break
|
|
sleep 0.5
|
|
done
|
|
rss_kb="$(awk '/VmRSS/{print $2}' /proc/$SRV/status 2>/dev/null)"
|
|
[[ "$fds_after" -le "$fds_w1" ]] \
|
|
&& ok "no per-connection fd leak (start $fds_before, after $SOAK_N: $fds_w1, after 20 more: $fds_after)" \
|
|
|| bad "soak-fds" "second wave grew fds: $fds_w1 -> $fds_after (start $fds_before)"
|
|
[[ -n "$rss_kb" && "$rss_kb" -lt 819200 ]] \
|
|
&& ok "soak RSS bounded (${rss_kb}KB < 800MB)" || bad "soak-rss" "${rss_kb}KB"
|
|
|
|
# ---- 4. drain: SIGTERM with clients connected -> close frames, exit 0 ----
|
|
# Starts its OWN server. It used to inherit the soak leg's $SRV, which meant
|
|
# any leg inserted between them silently handed drain an empty pid: its python
|
|
# died on int(""), the leg reported a bare failure, AND the soak server was
|
|
# never killed — orphaning a listener that then broke the NEXT run's soak on
|
|
# the same port. No leg may depend on another leg's server.
|
|
serve "$((PORT0 + 6))" || bad "serve-drain" "no listener"
|
|
r="$(timeout 30 python3 - "$PORT" "$SRV" <<'PYEOF'
|
|
import sys, os, time, signal, socket
|
|
import importlib.util
|
|
spec = importlib.util.spec_from_file_location("wsc", os.environ["WSC"])
|
|
wsc = importlib.util.module_from_spec(spec); spec.loader.exec_module(wsc)
|
|
port, srv = int(sys.argv[1]), int(sys.argv[2])
|
|
a = wsc.connect(port, "lobby", "alice"); wsc.recv(a)
|
|
b = wsc.connect(port, "lobby", "bob"); wsc.recv(a); wsc.recv(b)
|
|
os.kill(srv, signal.SIGTERM)
|
|
def drained(s):
|
|
try:
|
|
while True:
|
|
k, _ = wsc.recv(s, timeout=5)
|
|
if k == 8: return "close-frame"
|
|
if k == -2: return "eof"
|
|
except socket.timeout:
|
|
return "stuck"
|
|
except (ConnectionResetError, BrokenPipeError):
|
|
return "reset"
|
|
print(drained(a) + "|" + drained(b))
|
|
PYEOF
|
|
)"
|
|
[[ "$r" == "close-frame|close-frame" ]] \
|
|
&& ok "drain: both clients got the close frame" || bad "drain" "$r"
|
|
stopped=1
|
|
for _ in $(seq 1 40); do kill -0 "$SRV" 2>/dev/null || { stopped=0; break; }; sleep 0.1; done
|
|
[[ $stopped -eq 0 ]] && ok "SIGTERM exits 0" || bad "stop" "still running"
|
|
SRV=""
|
|
|
|
# ---- 4b. WO_SHARDS=1: the same matrix on one shard ----
|
|
# The plan requires `just chat` green at default cores AND on a single shard:
|
|
# cross-shard placement is where the actor work can hide a bug, so the
|
|
# one-shard run is the control that says a failure is placement's fault.
|
|
serve "$((PORT0 + 4))" env WO_SHARDS=1 || bad "serve-shards1" "no listener"
|
|
r="$(functional shards1)"; [[ "$r" == *functional-ok* ]] \
|
|
&& ok "WO_SHARDS=1: the same matrix on a single shard" \
|
|
|| bad "shards1-functional" "$r"
|
|
kill -TERM "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null; SRV=""
|
|
|
|
# ---- 4c. WO_MAILBOX=8: the drop-slow-member path FIRES and the room lives ----
|
|
# The backpressure policy earning its keep. A member that stops reading makes
|
|
# its writer block on write_dl; with the mailbox capped at 8 the room's
|
|
# broadcast send traps (WO_T_ACTOR), and the room must CATCH that, drop the
|
|
# member, and keep serving everyone else. Asserting the room survives is the
|
|
# point — a room that dies with its slowest member is the bug this policy
|
|
# exists to prevent.
|
|
serve "$((PORT0 + 5))" env WO_MAILBOX=8 || bad "serve-mailbox" "no listener"
|
|
r="$(timeout 90 python3 - "$PORT" <<'PYEOF'
|
|
import importlib.util, os, socket, sys, time
|
|
spec = importlib.util.spec_from_file_location("wsc", os.environ["WSC"])
|
|
wsc = importlib.util.module_from_spec(spec); spec.loader.exec_module(wsc)
|
|
port = int(sys.argv[1])
|
|
|
|
fast = wsc.connect(port, "bp", "fast")
|
|
wsc.recv(fast) # * fast joined
|
|
# the slow member: a tiny receive buffer so the server's socket fills fast,
|
|
# and it never reads a single frame
|
|
slow = wsc.connect(port, "bp", "slow", rcvbuf=2048)
|
|
wsc.recv(fast) # * slow joined
|
|
|
|
# storm: big frames the slow member never drains
|
|
blob = "x" * 1024
|
|
for i in range(400):
|
|
try:
|
|
wsc.send(fast, f"{i}-{blob}")
|
|
except OSError:
|
|
break
|
|
# drain what fast owes us so its own mailbox cannot be the thing that fills
|
|
deadline = time.time() + 20
|
|
seen = 0
|
|
while time.time() < deadline:
|
|
try:
|
|
k, t = wsc.recv(fast, timeout=0.5)
|
|
seen += 1
|
|
except Exception:
|
|
break
|
|
|
|
# the room must still be alive and serving the fast member
|
|
survivor = wsc.connect(port, "bp", "late")
|
|
ok_join = False
|
|
deadline = time.time() + 15
|
|
while time.time() < deadline:
|
|
try:
|
|
k, t = wsc.recv(fast, timeout=1.0)
|
|
if "late joined" in t:
|
|
ok_join = True
|
|
break
|
|
except Exception:
|
|
break
|
|
print("mailbox-ok" if ok_join else f"mailbox-dead seen={seen}")
|
|
slow.close(); fast.close(); survivor.close()
|
|
PYEOF
|
|
)"
|
|
[[ "$r" == *mailbox-ok* ]] \
|
|
&& ok "WO_MAILBOX=8: slow member dropped, room survived and kept serving" \
|
|
|| bad "mailbox-backpressure" "$r"
|
|
kill -TERM "$SRV" 2>/dev/null; wait "$SRV" 2>/dev/null; SRV=""
|
|
|
|
# ---- 5. the ASan leg: functional matrix, zero leaks ----
|
|
if [[ -x "$ASAN" ]]; then
|
|
sed -i "s|runtime = \".*\"|runtime = \"$ASAN\"|" "$W/app/wo.toml"
|
|
rm -rf "$W/app/target"
|
|
"$WOC" "$W/app" >/dev/null 2>&1
|
|
serve "$((PORT0 + 3))" || bad "serve-asan" "no listener"
|
|
r="$(functional asan)"
|
|
kill -TERM "$SRV" 2>/dev/null
|
|
for _ in $(seq 1 60); do kill -0 "$SRV" 2>/dev/null || break; sleep 0.1; done
|
|
SRV=""
|
|
if [[ "$r" == *functional-ok* ]] \
|
|
&& ! tail -n "+$LEGFROM" "$SRVLOG" | grep -q "AddressSanitizer\|LeakSanitizer"; then
|
|
ok "ASan run clean (functional + drain, zero leaks)"
|
|
else
|
|
bad "asan" "$(tail -n "+$LEGFROM" "$SRVLOG" | grep -m1 -E 'ERROR|SUMMARY' || echo "$r")"
|
|
fi
|
|
else
|
|
bad "asan" "runtime/build/wovm_asan missing — make -C runtime wovm-asan"
|
|
fi
|
|
|
|
echo
|
|
printf 'chat-accept: %d checks, %d failures\n' "$((pass + fail))" "$fail"
|
|
[[ $fail -eq 0 ]]
|