extends SceneTree
## Layer 14 — Peer lifetime under a room switch (no network required)
##
## The sequence a game performs when the player changes rooms, and nothing more:
## drop the peer that was serving the old room, make a new one, hand it to the
## overlay. hyper-tps does exactly this (menu_main.gd `_disconnect_mesh()` then
## `_start_mesh()`), and ContentOverlay.attach_peer() does exactly this:
##
## _attached_peer = peer # the OLD peer's last reference dies HERE
## _overlay.attach(peer) # ... and attach() calls detach() on the old
##
## HyperOverlay borrows a raw `UdxTransport *` from the peer, which is RefCounted.
## In that order the transport is already freed when detach() reaches it, so the
## teardown writes into freed memory and the NEXT peer's `uv_loop_init` aborts in
## glibc — a crash whose backtrace names the new peer and says nothing about the
## old one (hyper-games #32 §NEXT-STEPS-1).
##
## This layer needs no DHT, no bootstrap and no second machine: three cycles of
## create/attach/drop are enough, which is why it runs in seconds. A pass means
## the overlay outlives the peer it was attached to. A crash IS the failure —
## there is no assertion to print after glibc aborts the process.
##
## Usage: godot4 --headless --path project --script ../layer14_room_switch.gd
const CYCLES := 3
const SETTLE_TICKS := 20
const TICK := 0.05
var _pass := 0
var _fail := 0
func _init() -> void:
_start.call_deferred()
func _test(name: String, ok: bool) -> void:
if ok: _pass += 1; print(" PASS ", name)
else: _fail += 1; print(" FAIL ", name)
func _start() -> void:
print("=== Layer 14: Peer Lifetime Under Room Switch ===")
var dir := "/tmp/layer14"
DirAccess.make_dir_recursive_absolute(dir)
var ov: Object = ClassDB.instantiate(&"HyperOverlay")
_test("overlay opens", ov.open(dir))
# An asset to serve, so the post-switch overlay is exercised through on_loop()
# — the same borrowed transport pointer attach()/detach() use.
var asset := dir + "/asset.bin"
var wf := FileAccess.open(asset, FileAccess.WRITE)
wf.store_string("layer14")
wf.close()
# Stands in for ContentOverlay._attached_peer: the ONLY reference the game
# still holds once the menu has dropped its own.
var held: Object = null
for i in CYCLES:
var p: Object = ClassDB.instantiate(&"HyperswarmMultiplayerPeer")
var kp: Dictionary = p.generate_dht_keypair()
# Default bootstrap on purpose. The game passes none, and join_dht's
# custom-bootstrap path keeps its node list in function-local statics that
# a second peer rewrites — a separate lifetime bug that would confound this.
_test("cycle %d: join_dht" % i, p.join_dht(kp["public_key"], kp["secret_key"]) == OK)
# Let the transport thread reach uv_loop_init + bind before tearing it down;
# a peer that never started would not exercise the teardown at all.
for _n in SETTLE_TICKS:
p.poll()
ov.poll()
await create_timer(TICK).timeout
# THE ROOM SWITCH. Assignment order is the game's, not a convenience:
# the previous peer is released, and only then is the new one attached.
held = p
p = null
_test("cycle %d: overlay attaches to the new peer" % i, ov.attach(held))
# And the overlay must still work afterwards — serve() marshals through the
# same borrowed transport, so a dangling pointer is fatal here too.
var oid: String = ov.content_hash_of(asset)
_test("cycle %d: hashes an asset (%s)" % [i, oid.substr(0, 8)], oid.length() == 64)
ov.serve(oid, asset)
ov.poll()
_test("attached after %d switches" % CYCLES, ov.describe().contains("attached=true"))
ov.close()
ov.free()
held = null
_finish()
func _finish() -> void:
print("\n %d pass, %d fail" % [_pass, _fail])
var ok := _fail == 0
print("=== Layer 14: %s ===" % ("PASS" if ok else "FAILED"))
var f := FileAccess.open("/tmp/layer14_status.txt", FileAccess.WRITE)
if f: f.store_string("PASS" if ok else "FAIL"); f.close()
quit(0 if ok else 1)