diff --git a/context/open-questions.md b/context/open-questions.md index f824e1d..313c8dd 100644 --- a/context/open-questions.md +++ b/context/open-questions.md @@ -558,19 +558,24 @@ register. ⚠ The audit also flags the damage-economy item as SELF-CONTRADICTOR numbers no longer point at that code). ## Multiplayer (Phase 7 / P6) -- **OPEN: console death froze peer replication BOTH ways (2026-07-14) [T4 mechanism].** Live 2-node - session: the btconsole.py emulator process died mid-match (cause unknown — its recv loop only - caught socket.timeout; any OSError killed threads silently; NOW hardened + `-u` + console.log) and - peer↔peer update replication froze in BOTH directions at ~that moment (each node's replicant held a - frozen stale velocity; own sims + rendering fine). The engine NEVER logged a console disconnect — - though `CheckBuffers` recv==0 dispatches `HostDisconnectedMessage` and the loop keeps servicing - other hosts — so either the console host's receive pad was full (recv skipped → close never seen) - or the dispatch doesn't reach `HostDisconnectedMessageHandler` in the port (routing gap). ⚠ Also - found (unfired, latent): that handler's ConsoleHostType branch closes **`gameListenerSocket`** (the - GAME listener) where the comment says it re-posts a CONSOLE listen (L4NET.CPP:969) — suspect - `consoleListenerSocket` was intended. Diagnose on recurrence: console.log (death reason) + - whether `[repltrn]` velocities freeze at console death. Why peer replication depends on the - console connection at all is the core unknown. +- **✅ Console-death peer-replication freeze — NOT REPRODUCIBLE + the one real bug FIXED + (2026-07-15) [T2].** The 2026-07-14 report (btconsole.py died mid-match → peer↔peer replication + froze both ways) did NOT reproduce in the current build under EITHER failure mode, tested directly + (BT_REPL_LOG on a circling peer, affinity-pinned): (1) a clean relay kill — the replicant kept + circling smoothly through the kill, NO disconnect logged, node survived; (2) a deliberately STUCK + console (`scratchpad/btconsole_stuck.py` — connects, starts the mission, then stops recv()'ing while + holding the socket OPEN = the exact "receive pad full → close never seen" mode hypothesized) — the + replicant kept moving the entire run. So **peer replication is INDEPENDENT of the console**: the + pods replicate peer-to-peer over the GAME socket; the console is a separate egg/mission/status + channel. The original freeze was a transient (plausibly the same single-box packet-jitter / + CPU-contention artifact root-caused 2026-07-15, `49d73dc`) or was fixed by later MP work. + **The flagged latent bug IS real and is now FIXED:** `HostDisconnectedMessageHandler`'s + ConsoleHostType branch closed **`gameListenerSocket`** (the GAME listener) on a CONSOLE disconnect — + a naming bug; the comment + the commented-out `OpenConnection` intended a CONSOLE re-listen, which + `CreateConsoleHost()` already does. Removed (L4NET.CPP:969). Harmless to established peer sockets + (why a live match keeps replicating through console loss), but it would have blocked a NEW peer + from joining after a console cycle. Verified: 2-node still connects (`All connections completed!`) + + the peer circles. Diag harness retained: `scratchpad/btconsole_stuck.py`. - ✅ Cross-pod COMBAT — DONE (tasks #46/#47: replicant targeting + damage rerouted to the owning master, full cross-pod kill verified). ✅ Replicant GAIT animation — DONE (task #50: replicant derives speedDemand from the replicated velocity, full stand→walk→run lifecycle verified). diff --git a/engine/MUNGA_L4/L4NET.CPP b/engine/MUNGA_L4/L4NET.CPP index cb4f407..bd638c7 100644 --- a/engine/MUNGA_L4/L4NET.CPP +++ b/engine/MUNGA_L4/L4NET.CPP @@ -966,9 +966,15 @@ void L4NetworkManager::HostDisconnectedMessageHandler(HostDisconnectedMessage* H // // Post a listen for a console so it can reconnect if it wants to. // - shutdown(gameListenerSocket, SD_BOTH); - closesocket(gameListenerSocket); - gameListenerSocket = NULL; + // FIX (task #50, 2026-07-15): this used to close the GAME listener here + // (shutdown/closesocket(gameListenerSocket)) on a CONSOLE disconnect -- a + // naming bug: the comment above + the commented-out OpenConnection below + // intended to re-post a CONSOLE listen, which CreateConsoleHost() (below) + // already does. Closing the game listener on console loss needlessly + // bounced the game-accept path (harmless to already-established peer + // sockets -- which is why a live 2-node match keeps replicating through a + // console loss -- but wrong, and it would have blocked a NEW peer from + // joining after a console cycle). Removed; CreateConsoleHost re-listens. //unsigned long console_socket = OpenConnection( // NETNUB_TCP_LISTEN, // ((L4Application *)application)->GetNetworkCommonFlatAddress(), // Local port diff --git a/scratchpad/btconsole_stuck.py b/scratchpad/btconsole_stuck.py new file mode 100644 index 0000000..a83fbe4 --- /dev/null +++ b/scratchpad/btconsole_stuck.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""btconsole_stuck.py -- a DELIBERATELY-STUCK console for reproducing the +'console death froze peer replication' open question (task #50 / open-questions.md). + +It behaves like tools/btconsole.py (connect, stream egg, send RunMission x2, drain +the pod->console stream) BUT after STUCK_AFTER seconds it STOPS draining while +holding the socket OPEN -- i.e. it stops calling recv(). This is the authentic +'receive pad full -> close never seen' failure mode (NOT a clean disconnect): the +pod keeps queueing pod->console packets, the TCP window closes, and we watch +whether the pod's CheckBuffers loop stalls the GAME peers behind the wedged +console host. + +Usage: python btconsole_stuck.py host:port host:port [STUCK_AFTER_secs] +Env: none. STUCK_AFTER default 12s after the 2nd RunMission. +""" +import socket, struct, sys, time, threading, os + +CHUNK = 1000 +PKT_FMT_HDR = " 4 else 12.0 + +def run_mission_packet(): + pkt = struct.pack(PKT_FMT_HDR, APPLICATION_CLIENT_ID, 0, CONSOLE_HOST_ID, 0) + pkt += struct.pack("= launch_at: + s.sendall(run_mission_packet()); launches_sent += 1 + launch_at = time.time() + LAUNCH_STEP_SECONDS + print(f"[{name}] RunMission #{launches_sent} sent", flush=True) + if launches_sent == 2: + stuck_at = time.time() + STUCK_AFTER + # *** THE FAILURE: once mission is running, stop draining after STUCK_AFTER *** + if stuck_at is not None and time.time() >= stuck_at: + print(f"[{name}] *** GOING STUCK: socket stays OPEN, no more recv() ***", flush=True) + while True: + time.sleep(60) # hold the socket open, never drain it again + try: + data = s.recv(4096) + if not data: + print(f"[{name}] pod closed the console socket", flush=True); return + except socket.timeout: + continue + except OSError as e: + print(f"[{name}] socket error {e!r}", flush=True); return + +def main(): + egg_path = sys.argv[1] + pods = sys.argv[2:4] + with open(egg_path, "rb") as f: + egg = egg_wire_bytes(f.read()) + ts = [threading.Thread(target=serve_pod, args=(p, egg), daemon=True) for p in pods] + for t in ts: t.start() + while True: time.sleep(1) + +if __name__ == "__main__": + main()