Fix guest TCP recv() stalling when v86 NE2000 TX ring wraps (#363)

* Fix guest TCP recv() stalling under concurrent traffic

v86's fake_network stores TCPConnection routing fields (hsrc/hdest/
psrc/pdest) as zero-copy subarrays of the SYN frame, which is itself a
view into the NE2000 TX ring. Win95's driver uses a 12-slot ring; once
it wraps (any concurrent SMB/NBNS/ping while waiting for an upstream
reply), pump() emits segments with whatever IP now occupies that slot,
the guest RSTs them, and recv() blocks forever.

- libv86.js: copy the four address arrays at TCPConnection construction
  (matches felixrieseberg/v86@dd13099c on fake-network-copy-tcp-addrs,
  now merged into windows95-base)
- tools/probe-tcp.sh + net/tcp-trace.ts + tcp-relay.ts test stub +
  debug-harness WIN95_PROBE_RUN2: end-to-end regression harness
  (boot → ping -t → telnet → async write after ring wrap → assert ACK).
  All env-gated, no production-path change.
- docs/v86-patches.md: tracker for all fork patches + upstream PR state
- update-v86 SKILL.md: cross-link and new fork-branch entry

* Drop checked-in upstream PR description

Belongs on the GitHub PR, not in the repo.
This commit is contained in:
Felix Rieseberg
2026-04-12 08:03:03 -07:00
committed by GitHub
parent 3c63139fae
commit b74e6c7b0a
8 changed files with 273 additions and 2 deletions

View File

@@ -23,7 +23,9 @@ fallbacks, no fetching from copy.sh.
## The fork branch
v86 should be checked out on **`felixrieseberg/v86:windows95-base`**.
That branch merges four feature branches, each upstreamable on its own:
That branch merges the feature branches tracked in
[`docs/v86-patches.md`](../../../docs/v86-patches.md) — keep that table
in sync with this list. Each is upstreamable on its own:
- **`electron-renderer-fs-loader`** (PR #1540) — `src/lib.js` uses
`require("fs")` instead of `await import("node:fs/promises")`. Dynamic
@@ -42,6 +44,14 @@ That branch merges four feature branches, each upstreamable on its own:
69 so `W95TOOLS.EXE` (guest-tools/agent) can sync `CF_TEXT` with
the host. Consumes `mouse-absolute` and `vmware-clipboard-host` bus
events; emits `vmware-absolute-mouse` and `vmware-clipboard-guest`.
- **`fake-network-copy-tcp-addrs`** — `src/browser/fake_network.js`
copies the four address subarrays (`hsrc/hdest/psrc/pdest`) when a
`TCPConnection` is created from an inbound SYN. Upstream stores them
as zero-copy views into the NE2000 TX ring; once the guest's 12-slot
TX ring wraps (any concurrent traffic — SMB, NBNS, ping), `pump()`
builds segments with whatever IP now occupies that slot, the guest
RSTs them as belonging to no TCB, and `recv()` blocks forever.
Exercised by `tools/probe-tcp.sh`.
- **`vga-defer-vbe-disable-v86`** — `src/vga.js` defers `dispi[4]=0`
written from V86 mode until a legacy attribute-mode write reaches the
hardware. Win9x's VDD virtualises ports 3B03DF for a windowed DOS VM

25
docs/v86-patches.md Normal file
View File

@@ -0,0 +1,25 @@
# v86 patches carried by windows95
windows95 builds v86 from a fork branch rather than upstream master.
Each fix lives on its own feature branch in `felixrieseberg/v86`, has an
upstream PR against `copy/v86`, and is merged into the integration
branch `felixrieseberg/v86:windows95-base` (which is what
`tools/update-v86.js` builds from). When a PR is merged upstream, drop
its row here, delete the feature branch, and rebase `windows95-base`.
| Fix | Branch | Upstream PR | windows95-base | Why we need it |
|---|---|---|---|---|
| Node fs loader in Electron renderer | [`electron-renderer-fs-loader`](https://github.com/felixrieseberg/v86/tree/electron-renderer-fs-loader) | [copy/v86#1540](https://github.com/copy/v86/pull/1540) | ✅ | `await import("node:fs/promises")` fails in an Electron renderer; disk images don't load. |
| IDE shared Command Block registers | [`ide-shared-registers`](https://github.com/felixrieseberg/v86/tree/ide-shared-registers) | [copy/v86#1541](https://github.com/copy/v86/pull/1541) | ✅ | Win9x hangs at splash on disks >535 MiB because per-device register writes violate the ATA shared-register-file spec. |
| VMware absolute-pointer backdoor | [`vmware-abspointer`](https://github.com/felixrieseberg/v86/tree/vmware-abspointer) | [copy/v86#1542](https://github.com/copy/v86/pull/1542) | ✅ | Port 0x5658 GETVERSION + ABSPOINTER_* so VBMOUSE can track the host cursor 1:1 without pointer lock. |
| VMware text-clipboard backdoor | [`vmware-clipboard`](https://github.com/felixrieseberg/v86/tree/vmware-clipboard) | — *(stacked on #1542)* | ✅ | Legacy backdoor commands 69 so `W95TOOLS.EXE` can sync `CF_TEXT` with the host. |
| Defer V86-mode VBE disable | [`vga-defer-vbe-disable-v86`](https://github.com/felixrieseberg/v86/tree/vga-defer-vbe-disable-v86) | [copy/v86#1543](https://github.com/copy/v86/pull/1543) | ✅ | Opening a windowed DOS box leaks vgabios's VBE-disable past Win9x's VDD and turns the screen to planar garbage. |
| fake_network: copy TCP addrs | [`fake-network-copy-tcp-addrs`](https://github.com/felixrieseberg/v86/tree/fake-network-copy-tcp-addrs) | — | ✅ | `TCPConnection` routing fields alias the NE2000 TX ring; concurrent guest traffic retargets async replies and the guest RSTs them. |
## Adding a fix
1. Branch off `origin/master` in `../v86`, commit, push to `fork`.
2. Open the PR against `copy/v86`.
3. `git checkout windows95-base && git merge --no-ff <branch> && git push fork windows95-base`
4. `node tools/update-v86.js` in this repo to rebuild `libv86.js` / `v86.wasm`.
5. Add a row above and a bullet in `.claude/skills/update-v86/SKILL.md`.

View File

@@ -183,7 +183,10 @@ export function startProbe(emulator: any) {
const scriptCmd = process.env.WIN95_PROBE_SCRIPT;
// WIN95_PROBE_RUN='telnet 1.2.3.4 7777' → literal text into Start→Run,
// Enter, then optional WIN95_PROBE_RUN_AFTER keystrokes after _RUN_WAIT ms.
// WIN95_PROBE_RUN2 fires a second Start→Run sequence after _RUN2_WAIT ms,
// for two-process scenarios (e.g., background ping + telnet).
const runCmd = process.env.WIN95_PROBE_RUN;
const runCmd2 = process.env.WIN95_PROBE_RUN2;
const runAfter = process.env.WIN95_PROBE_RUN_AFTER;
// WIN95_PROBE_DOSBOX=1 → after desktop, open COMMAND.COM, type `dir`,
// optionally Alt+Enter to fullscreen. Regression test for the windowed
@@ -272,6 +275,19 @@ export function startProbe(emulator: any) {
{ type: "text", text: runCmd },
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
...(runCmd2 ? [
{ type: "wait", ms: Number(process.env.WIN95_PROBE_RUN2_WAIT) || 3000 },
{ type: "chord", keys: [
{ dn: SC.CTRL_DN, up: SC.CTRL_UP },
{ dn: SC.ESC_DN, up: SC.ESC_UP },
]},
{ type: "wait", ms: 1200 },
{ type: "keys", dn: SC.R_DN, up: SC.R_UP },
{ type: "wait", ms: 1000 },
{ type: "text", text: runCmd2 },
{ type: "wait", ms: 400 },
{ type: "keys", dn: SC.ENTER_DN, up: SC.ENTER_UP },
] : []),
...(runAfter ? [
{ type: "wait", ms: Number(process.env.WIN95_PROBE_RUN_WAIT) || 6000 },
{ type: "text", text: runAfter },

View File

@@ -18,6 +18,7 @@ import { Win95Window } from "./app";
import { resetState } from "./utils/reset-state";
import { setupSmbShare } from "./smb";
import { setupTcpRelay } from "./net/tcp-relay";
import { setupTcpTrace } from "./net/tcp-trace";
import { setupDnsShim } from "./net/dns-shim";
import { setupClipboardSync } from "./clipboard";
import { startProbe } from "./debug-harness";
@@ -394,6 +395,7 @@ export class Emulator extends React.Component<{}, EmulatorState> {
// Raw TCP egress for ports the fetch adapter ignores (everything but 80).
setupTcpRelay(window["emulator"]);
setupTcpTrace(window["emulator"]);
// Serve a host folder over SMB on port 139. Read-only, traversal/symlink
// guarded. In Win95: Start → Run → \\HOST\HOST. The env var wins so the

View File

@@ -191,7 +191,7 @@ function Hb(a,b){let c={};var d=(new DataView(a.buffer,a.byteOffset,a.byteLength
a.rst=!!(d&4),a.psh=!!(d&8),a.ack=!!(d&16),a.urg=!!(d&32),a.ece=!!(d&64),a.cwr=!!(d&128),c.tcp=a,c.tcp_data=e.subarray(4*a.doff);else if(17===d){a=new DataView(e.buffer,e.byteOffset,e.byteLength);a={sport:a.getUint16(0),dport:a.getUint16(2),len:a.getUint16(4),checksum:a.getUint16(6),data:e.subarray(8),data_s:(new TextDecoder).decode(e.subarray(8))};if(67===a.dport||67===a.sport){e=e.subarray(8);d=new DataView(e.buffer,e.byteOffset,e.byteLength);e.subarray(44,236);d={op:d.getUint8(0),htype:d.getUint8(1),
hlen:d.getUint8(2),hops:d.getUint8(3),xid:d.getUint32(4),secs:d.getUint16(8),flags:d.getUint16(10),ciaddr:d.getUint32(12),yiaddr:d.getUint32(16),siaddr:d.getUint32(20),giaddr:d.getUint32(24),chaddr:e.subarray(28,44),magic:d.getUint32(236),options:[]};e=e.subarray(240);for(l=0;l<e.length;++l)g=l,0!==e[l]&&(++l,h=e[l],l+=h,d.options.push(e.subarray(g,g+h+2)));c.dhcp=d;c.dhcp_options=d.options}else 53===a.dport||53===a.sport?Ib(e.subarray(8),c):123===a.dport&&(d=e.subarray(8),d=new DataView(d.buffer,
d.byteOffset,d.byteLength),c.ntp={flags:d.getUint8(0),stratum:d.getUint8(1),poll:d.getUint8(2),precision:d.getUint8(3),root_delay:d.getUint32(4),root_disp:d.getUint32(8),ref_id:d.getUint32(12),ref_ts_i:d.getUint32(16),ref_ts_f:d.getUint32(20),ori_ts_i:d.getUint32(24),ori_ts_f:d.getUint32(28),rec_ts_i:d.getUint32(32),rec_ts_f:d.getUint32(36),trans_ts_i:d.getUint32(40),trans_ts_f:d.getUint32(44)});c.udp=a}}else 2054===d?(d=new DataView(a.buffer,a.byteOffset,a.byteLength),a={htype:d.getUint16(0),ptype:d.getUint16(2),
oper:d.getUint16(6),sha:a.subarray(8,14),spa:a.subarray(14,18),tha:a.subarray(18,24),tpa:a.subarray(24,28)},c.arp=a):34525!==d&&y(d);if(c.ipv4)if(c.tcp)a:{a=`${c.ipv4.src.join(".")}:${c.tcp.sport}:${c.ipv4.dest.join(".")}:${c.tcp.dport}`;if(c.tcp.syn&&!c.tcp.ack){b.tcp_conn[a]&&delete b.tcp_conn[a];d=new Jb(b);d.state="syn-received";d.tuple=a;d.last=c;d.hsrc=c.eth.dest;d.psrc=c.ipv4.dest;d.sport=c.tcp.dport;d.hdest=c.eth.src;d.dport=c.tcp.sport;d.pdest=c.ipv4.src;b.bus.pair.send("tcp-connection",
oper:d.getUint16(6),sha:a.subarray(8,14),spa:a.subarray(14,18),tha:a.subarray(18,24),tpa:a.subarray(24,28)},c.arp=a):34525!==d&&y(d);if(c.ipv4)if(c.tcp)a:{a=`${c.ipv4.src.join(".")}:${c.tcp.sport}:${c.ipv4.dest.join(".")}:${c.tcp.dport}`;if(c.tcp.syn&&!c.tcp.ack){b.tcp_conn[a]&&delete b.tcp_conn[a];d=new Jb(b);d.state="syn-received";d.tuple=a;d.last=c;d.hsrc=new Uint8Array(c.eth.dest);d.psrc=new Uint8Array(c.ipv4.dest);d.sport=c.tcp.dport;d.hdest=new Uint8Array(c.eth.src);d.dport=c.tcp.sport;d.pdest=new Uint8Array(c.ipv4.src);b.bus.pair.send("tcp-connection",
d);if(b.on_tcp_connection)b.on_tcp_connection(d,c);if(b.tcp_conn[a])break a}if(b.tcp_conn[a])b.tcp_conn[a].process(c);else{a=c.tcp.ackn;if(c.tcp.fin||c.tcp.syn)a+=1;d={};d.eth={ethertype:2048,src:b.router_mac,dest:c.eth.src};d.ipv4={proto:6,src:c.ipv4.dest,dest:c.ipv4.src};d.tcp={sport:c.tcp.dport,dport:c.tcp.sport,seq:a,ackn:c.tcp.seq+(c.tcp.syn?1:0),winsize:c.tcp.winsize,rst:!0,ack:c.tcp.syn};b.receive(Eb(b.eth_encoder_buf,d))}}else if(c.udp)if(c.dns)if("static"===b.dns_method){a={};a.eth={ethertype:2048,
src:b.router_mac,dest:c.eth.src};a.ipv4={proto:17,src:b.router_ip,dest:c.ipv4.src};a.udp={sport:53,dport:c.udp.sport};d=[];for(e=0;e<c.dns.questions.length;++e)switch(l=c.dns.questions[e],l.type){case 1:d.push({name:l.name,type:l.type,class:l.class,ttl:600,data:[192,168,87,1]})}a.dns={id:c.dns.id,flags:33152,questions:c.dns.questions,answers:d};b.receive(Eb(b.eth_encoder_buf,a))}else Fb(c,b);else c.dhcp?Gb(c,b):c.ntp?(a=Date.now()+rb,d=a%1E3/1E3*sb,e={},e.eth={ethertype:2048,src:b.router_mac,dest:c.eth.src},
e.ipv4={proto:17,src:c.ipv4.dest,dest:c.ipv4.src},e.udp={sport:123,dport:c.udp.sport},e.ntp=Object.assign({},c.ntp),e.ntp.flags=36,e.ntp.poll=10,e.ntp.ori_ts_i=c.ntp.trans_ts_i,e.ntp.ori_ts_f=c.ntp.trans_ts_f,e.ntp.rec_ts_i=a/1E3,e.ntp.rec_ts_f=d,e.ntp.trans_ts_i=a/1E3,e.ntp.trans_ts_f=d,e.ntp.stratum=2,b.receive(Eb(b.eth_encoder_buf,e))):8===c.udp.dport&&(a={},a.eth={ethertype:2048,src:b.router_mac,dest:c.eth.src},a.ipv4={proto:17,src:c.ipv4.dest,dest:c.ipv4.src},a.udp={sport:c.udp.dport,dport:c.udp.sport,

View File

@@ -45,6 +45,13 @@ const log = (...a: unknown[]) => {
// Ports already claimed by other in-process handlers.
const RESERVED_PORTS = new Set([80, 139]);
// WIN95_TCP_TEST_PORT=<n> short-circuits that port to an in-process fake
// upstream that writes a banner asynchronously (i.e., from outside a CPU
// tick) and then echoes everything back. Lets the probe harness exercise
// the recv() path deterministically without a real network endpoint.
const TEST_PORT = Number(process.env.WIN95_TCP_TEST_PORT) || 0;
const TEST_BANNER_BYTES = Number(process.env.WIN95_TCP_TEST_BYTES) || 3000;
// Destinations we never relay to. The emulated LAN (192.168.86/87) has nothing
// real behind it; loopback and link-local would let guest software poke at the
// host's own services or cloud-metadata endpoints. The rest of RFC1918 is left
@@ -71,6 +78,34 @@ export function setupTcpRelay(emulator: V86) {
conn.accept();
log(`${ip}:${port} (${conn.tuple})`);
if (TEST_PORT && port === TEST_PORT) {
const mode = process.env.WIN95_TCP_TEST_MODE || "banner";
const banner = Buffer.alloc(TEST_BANNER_BYTES, 0x41);
banner.write(`HELLO from tcp-relay test, ${TEST_BANNER_BYTES}B\r\n`);
let n = 0;
conn.on("data", (d) => {
if (d.length === 0) return;
n += d.length;
log(`test ← guest ${d.length}B (total ${n})`);
if (mode === "after-data" && n >= 1) {
setTimeout(() => {
log(`test → guest ${banner.length}B banner (async, after-data)`);
conn.write(banner);
}, 10);
}
});
if (mode === "banner")
setTimeout(
() => {
log(`test → guest ${banner.length}B banner (async)`);
conn.write(banner);
},
Number(process.env.WIN95_TCP_TEST_DELAY) || 50,
);
conn.on_shutdown = conn.on_close = () => log("test guest closed");
return;
}
let connected = false;
let pending: Buffer[] | null = [];
let upstreamGone = false;

View File

@@ -0,0 +1,118 @@
// Deep packet trace for the v86 userspace TCP ↔ NE2000 path. Activated by
// WIN95_TCP_TRACE=<port> — every guest-TX and host-RX TCP frame on that
// dest port is decoded and appended to /tmp/win95-tcp-trace.log, and the
// NE2000 receive() is wrapped so we can see whether frames injected from
// outside a CPU tick ever land in the RX ring (vs. being filtered out).
import * as fs from "fs";
const TRACE_FILE =
process.env.WIN95_TCP_TRACE_FILE || "/tmp/win95-tcp-trace.log";
interface V86 {
bus: { register(name: string, fn: (arg: unknown) => void): void };
v86?: { cpu?: { devices?: { net?: unknown } } };
}
function flags(b: number): string {
let s = "";
if (b & 1) s += "F";
if (b & 2) s += "S";
if (b & 4) s += "R";
if (b & 8) s += "P";
if (b & 16) s += "A";
return s || ".";
}
function decodeTcp(f: Uint8Array, port: number) {
if (f.length < 54) return null;
if (((f[12] << 8) | f[13]) !== 0x0800) return null;
const ihl = (f[14] & 0x0f) * 4;
if (f[14 + 9] !== 6) return null;
const ipLen = (f[16] << 8) | f[17];
const t = 14 + ihl;
const sport = (f[t] << 8) | f[t + 1];
const dport = (f[t + 2] << 8) | f[t + 3];
if (sport !== port && dport !== port) return null;
const seq =
(f[t + 4] * 0x1000000 + (f[t + 5] << 16) + (f[t + 6] << 8) + f[t + 7]) >>>
0;
const ack =
(f[t + 8] * 0x1000000 + (f[t + 9] << 16) + (f[t + 10] << 8) + f[t + 11]) >>>
0;
const doff = (f[t + 12] >> 4) * 4;
const fl = f[t + 13];
const win = (f[t + 14] << 8) | f[t + 15];
const dlen = ipLen - ihl - doff;
return { sport, dport, seq, ack, fl: flags(fl), win, dlen, frameLen: f.length };
}
export function setupTcpTrace(emulator: V86) {
const port = Number(process.env.WIN95_TCP_TRACE);
if (!port) return;
const t0 = Date.now();
fs.writeFileSync(TRACE_FILE, `--- trace port ${port} ---\n`);
const w = (s: string) => {
const line = `[${((Date.now() - t0) / 1000).toFixed(3)}s] ${s}\n`;
try {
fs.appendFileSync(TRACE_FILE, line);
} catch {}
};
emulator.bus.register("net0-send", (raw: unknown) => {
const f = raw as Uint8Array;
const r = decodeTcp(f, port);
if (r)
w(
`guest→ ${r.sport}>${r.dport} seq=${r.seq} ack=${r.ack} ${r.fl} win=${r.win} len=${r.dlen}`,
);
else if (f.length >= 14) {
const et = (f[12] << 8) | f[13];
const dst =
et === 0x0800 ? f.subarray(30, 34).join(".") : et === 0x0806 ? "arp" : "?";
w(
`guest→ [other] len=${f.length}@${f.byteOffset} et=0x${et.toString(16)} dst=${dst}`,
);
}
});
// Wrap NE2000 receive() to see if/why a frame is dropped at the NIC layer.
const arm = () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const ne2k = (emulator as any).v86?.cpu?.devices?.net;
if (!ne2k) return false;
const orig = ne2k.receive.bind(ne2k);
ne2k.receive = function (a: Uint8Array) {
const r = decodeTcp(a, port);
if (r) {
const pages = 1 + ((Math.max(60, a.length) + 4) >> 8);
const avail =
this.boundary > this.curpg
? this.boundary - this.curpg
: this.pstop - this.curpg + this.boundary - this.pstart;
const macOk =
a[0] === this.mac[0] && a[1] === this.mac[1] && a[2] === this.mac[2];
const drop =
this.cr & 1
? "STP"
: !macOk && a[0] !== 0xff && !(this.rxcr & 16)
? "MAC"
: avail < pages && this.boundary !== 0
? "FULL"
: "";
w(
` ne2k.recv frame=${a.length} sip=${a.slice(26, 30).join(".")} cr=0x${this.cr.toString(16)}` +
` cur=${this.curpg} bnd=${this.boundary}${drop ? " DROP:" + drop : ""}`,
);
}
return orig(a);
};
w("ne2k.receive wrapped");
return true;
};
if (!arm()) {
const p = setInterval(() => arm() && clearInterval(p), 100);
setTimeout(() => clearInterval(p), 10000);
}
}

65
tools/probe-tcp.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/bin/bash
# Probe the tcp-relay recv() path: boot Win95, telnet to a fake upstream
# on port 7777, and dump the per-frame trace. PASS = guest ACKs the async
# banner (i.e., recv() returned).
set -e
cd "$(dirname "$0")/.."
PORT=7777
TRACE=/tmp/win95-tcp-trace.log
RELAY="${TMPDIR:-/tmp}/win95-tcp-relay.log"
STATUS=/tmp/win95-probe.json
TIMEOUT=${TIMEOUT:-90}
pkill -9 -f "windows95.*electron" 2>/dev/null || true
sleep 1
rm -f "$HOME/Library/Application Support/windows95/state-v4.bin"
rm -f "$STATUS" /tmp/win95-probe.done /tmp/win95-screen.png "$TRACE" "$RELAY"
rm -rf dist
node tools/vite-build.js > /tmp/win95-build.log 2>&1 || {
echo "BUILD FAILED"; tail -30 /tmp/win95-build.log; exit 1
}
WIN95_PROBE=1 \
WIN95_PROBE_RUN="${RUN:-ping -t 8.8.8.8}" \
WIN95_PROBE_RUN2="${RUN2:-telnet 1.1.1.1 $PORT}" \
WIN95_PROBE_RUN2_WAIT="${RUN2_WAIT:-3000}" \
WIN95_PROBE_RUN_AFTER="${RUN_AFTER:-}" \
WIN95_PROBE_RUN_WAIT="${RUN_WAIT:-6000}" \
WIN95_TCP_TEST_PORT=$PORT \
WIN95_TCP_TEST_MODE="${MODE:-banner}" \
WIN95_TCP_TEST_DELAY="${DELAY:-50}" \
WIN95_TCP_TEST_BYTES="${BYTES:-3000}" \
WIN95_TCP_TRACE=$PORT \
WIN95_SMB_SHARE="$HOME/Downloads" \
./node_modules/.bin/electron . > /tmp/win95-electron.log 2>&1 &
PID=$!
echo "electron pid=$PID, waiting up to ${TIMEOUT}s…"
VERDICT=TIMEOUT
for i in $(seq 1 "$TIMEOUT"); do
kill -0 $PID 2>/dev/null || { VERDICT=CRASHED; break; }
if [ -f /tmp/win95-probe.done ] && grep -q FAIL /tmp/win95-probe.done; then
VERDICT="BOOT_$(cat /tmp/win95-probe.done)"; break
fi
if [ -f "$TRACE" ] && grep -q '→ guest .* banner' "$RELAY" 2>/dev/null; then
# Banner was written; give guest 8 s to ACK, then decide.
sleep 8
if grep -Eq 'guest→.* ack=(279[8-9]|2[89][0-9]{2}|[3-9][0-9]{3}|[1-9][0-9]{4,}) ' "$TRACE"; then
VERDICT=PASS
else
VERDICT=FAIL
fi
break
fi
sleep 1
done
kill $PID 2>/dev/null || true
wait $PID 2>/dev/null || true
echo "─── relay ───"; [ -f "$RELAY" ] && cat "$RELAY"
echo "─── trace ───"; [ -f "$TRACE" ] && cat "$TRACE"
echo "═══ $VERDICT ═══"
[ "$VERDICT" = PASS ]