OverviewFilesCommitsIssuesDocsReleasesPeers

hyperswarm-peer / integration / layer14_room_switch.gd

105 lines · gd ref:
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)