fix(network): prevent null multiplayer peer crashes offline/teardown and fix Nakama RPC session validation

This commit is contained in:
2026-07-10 17:36:26 +08:00
parent 2b4c9d9dcb
commit b30709de3d
84 changed files with 5291 additions and 982 deletions
+133
View File
@@ -0,0 +1,133 @@
extends Node
class Log:
enum {ERROR, INFO, DEBUG}
static func _s(lvl):
if lvl == ERROR:
return "ERROR"
elif lvl == INFO:
return "INFO"
elif lvl == DEBUG:
return "DEBUG"
return "WTF"
static func __log(lvl, msg, data):
print("======= %s: %s" % [_s(lvl), msg])
if not data.is_empty():
var json = JSON.new()
print(json.stringify(data, " ", true))
static func error(msg, data={}):
__log(ERROR, msg, data)
static func info(msg, data={}):
__log(INFO, msg, data)
static func debug(msg, data={}):
__log(DEBUG, msg, data)
const REAL_ASSERT = false
var _success = false
var _start_time = 0
var _quit = false
var _disabled = false
var __me = ""
# Override this to specify test initialization (called on _ready if not disabled)
# You can run your whole test here, and call done() when finished
func setup():
pass
# Override this to do cleanup, make assertions at the end of the test (called on _exit_tree)
# NOTE: You can still fail here if you like with fail() or by failing an assertion
func teardown():
pass
func _init():
__me = get_script()
while __me.get_base_script() != null:
__me = __me.get_base_script()
func _ready():
if _disabled:
set_process(false)
set_physics_process(false)
set_process_input(false)
set_process_unhandled_input(false)
set_process_unhandled_key_input(false)
done()
Log.info("SKIP: %s" % __get_source(self))
return
_start_time = Time.get_ticks_usec()
Log.info("RUNNING: %s" % __get_source(self))
setup()
func _exit_tree():
if _disabled:
return
teardown()
Log.info("%s: %s" % ["SUCCESS" if _success else "!!!!!!!!!!!!!!!!!!! FAILURE", __get_source(self)])
func __get_source(who):
if who == null or who.get_script() == null:
return "Unknown source: %s" % str(who)
return who.get_script().resource_path
func __get_caller():
for s in get_stack():
if __me.resource_path != s.source:
return s
return null
func __get_assertion():
var stack = get_stack()
stack.reverse()
for s in stack:
if __me.resource_path == s.source:
return s
return null
func __assert(v1, v2):
if not (v1 == v2):
Log.error("Assert Failed", {"caller": __get_caller(), "assertion": __get_assertion(), "full_stack": get_stack()})
print_tree_pretty()
_quit = true
if REAL_ASSERT:
assert(v1 == v2)
if v1 == v2:
return false
return true
func assert_time(max_time):
__assert(max_time > float(Time.get_ticks_usec() - _start_time) / 1000.0 / 1000.0, true)
### Returns true if the assertion failed, so you can do:
### if assert_cond(cond):
### return # Assertion failed!
func assert_cond(cond):
return __assert(cond, true)
func assert_false(cond):
return __assert(cond, false)
func assert_equal(v1, v2):
return __assert(v1, v2)
func done():
_success = true
_quit = true
func fail():
_success = false
_quit = true
__assert(false, true)
func disable():
_disabled = true
func is_disabled():
return _disabled
+2
View File
@@ -0,0 +1,2 @@
*
!.gitignore
+119
View File
@@ -0,0 +1,119 @@
; Engine configuration file.
; It's best edited using the editor UI and not directly,
; since the parameters that go here are not all obvious.
;
; Format:
; [section] ; section goes between []
; param=value ; assign values to parameters
config_version=5
_global_script_classes=[{
"base": "RefCounted",
"class": &"NakamaAPI",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaAPI.gd"
}, {
"base": "RefCounted",
"class": &"NakamaAsyncResult",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaAsyncResult.gd"
}, {
"base": "RefCounted",
"class": &"NakamaClient",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/client/NakamaClient.gd"
}, {
"base": "RefCounted",
"class": &"NakamaException",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaException.gd"
}, {
"base": "Node",
"class": &"NakamaHTTPAdapter",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/client/NakamaHTTPAdapter.gd"
}, {
"base": "RefCounted",
"class": &"NakamaLogger",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaLogger.gd"
}, {
"base": "RefCounted",
"class": &"NakamaMultiplayerBridge",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaMultiplayerBridge.gd"
}, {
"base": "MultiplayerPeerExtension",
"class": &"NakamaMultiplayerPeer",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaMultiplayerPeer.gd"
}, {
"base": "NakamaAsyncResult",
"class": &"NakamaRTAPI",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaRTAPI.gd"
}, {
"base": "RefCounted",
"class": &"NakamaRTMessage",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaRTMessage.gd"
}, {
"base": "RefCounted",
"class": &"NakamaSerializer",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/utils/NakamaSerializer.gd"
}, {
"base": "NakamaAsyncResult",
"class": &"NakamaSession",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaSession.gd"
}, {
"base": "RefCounted",
"class": &"NakamaSocket",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/socket/NakamaSocket.gd"
}, {
"base": "Node",
"class": &"NakamaSocketAdapter",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/socket/NakamaSocketAdapter.gd"
}, {
"base": "RefCounted",
"class": &"NakamaStorageObjectId",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaStorageObjectId.gd"
}, {
"base": "RefCounted",
"class": &"NakamaWriteStorageObject",
"language": &"GDScript",
"path": "res://addons/com.heroiclabs.nakama/api/NakamaWriteStorageObject.gd"
}]
_global_script_class_icons={
"NakamaAPI": "",
"NakamaAsyncResult": "",
"NakamaClient": "",
"NakamaException": "",
"NakamaHTTPAdapter": "",
"NakamaLogger": "",
"NakamaMultiplayerBridge": "",
"NakamaMultiplayerPeer": "",
"NakamaRTAPI": "",
"NakamaRTMessage": "",
"NakamaSerializer": "",
"NakamaSession": "",
"NakamaSocket": "",
"NakamaSocketAdapter": "",
"NakamaStorageObjectId": "",
"NakamaWriteStorageObject": ""
}
[application]
run/main_scene="res://tester.tscn"
config/features=PackedStringArray("4.0")
[autoload]
Nakama="*res://addons/com.heroiclabs.nakama/Nakama.gd"
Config="*res://utils/config.gd"
+22
View File
@@ -0,0 +1,22 @@
#!/bin/sh
END_STRING="======= TESTS END"
PROJECT_PATH="test_suite/"
GODOT_BIN="test_suite/bin/godot.elf"
OUT=`$GODOT_BIN --headless --debug --path test_suite/ -s res://runner.gd`
RUN=`echo $OUT | grep "$END_STRING"`
RES=`echo $OUT | grep FAILURE`
echo "$OUT"
if [ -z "$RUN" ]; then
echo "Run failed!"
exit 1
fi
if [ -z "$RES" ]; then
echo "Tests passed!"
exit 0
fi
echo "Tests failed!"
exit 1
+15
View File
@@ -0,0 +1,15 @@
extends SceneTree
func _init():
var me = ProjectSettings.localize_path(ProjectSettings.globalize_path(get_script().resource_path))
var dir = ProjectSettings.localize_path(me.get_base_dir())
if dir != "res://":
print("Must be run with the script dir as the res:// path")
print("Current path: ", ProjectSettings.globalize_path(dir))
print("RES:// path: ", ProjectSettings.globalize_path("res://"))
quit()
return
var n = load("res://tester.gd").new()
root.add_child(n)
current_scene = n
+7
View File
@@ -0,0 +1,7 @@
{
"HOST": "127.0.0.1",
"PORT": 7350,
"SCHEME": "http",
"SERVER_KEY": "defaultkey"
}
+56
View File
@@ -0,0 +1,56 @@
extends Node
var current = null
var tests = []
var frame_time = 0.0
var fixed_time = 0.0
var dirs = ["tests"]
func _ready():
while not dirs.is_empty():
var d = dirs.pop_front()
_expand(d, dirs)
print("Running %d tests:\n%s" % [tests.size(), tests])
func _process(delta):
frame_time += delta
_check_end()
func _physics_process(delta):
fixed_time += delta
_check_end()
func _check_end():
if current == null:
if tests.size() == 0:
_end_tests()
return
current = load(tests.pop_front()).new()
add_child(current)
elif current._quit:
if not current._success:
_end_tests()
current.queue_free()
current = null
func _end_tests():
print("======= TESTS END")
set_process(false)
set_physics_process(false)
await get_tree().create_timer(1.0).timeout
get_tree().call_deferred("quit")
func _expand(p_name, r_dirs):
var dir = DirAccess.open("res://")
if dir.change_dir(p_name) != OK:
print("Unable to chdir into: %s" % p_name)
return
dir.list_dir_begin()
var f = dir.get_next()
while f != "":
if dir.current_is_dir():
r_dirs.append("%s/%s" % [p_name, f])
if f.ends_with("_test.gd"):
tests.append("%s/%s" % [p_name, f])
f = dir.get_next()
dir.list_dir_end()
+12
View File
@@ -0,0 +1,12 @@
[gd_scene load_steps=2 format=3 uid="uid://bglgahpqv1ojs"]
[ext_resource type="Script" path="res://tester.gd" id="1"]
[node name="Control" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")
+32
View File
@@ -0,0 +1,32 @@
extends "res://base_test.gd"
func setup():
var username = str(randi_range(1000, 100000))
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
# POST
var session = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session.is_valid()) or assert_cond(!session.is_expired()):
return
# PUT
var update = await client.update_account_async(session, username)
if assert_false(update.is_exception()):
return
# GET
var account : NakamaAPI.ApiAccount = await client.get_account_async(session)
if assert_false(account.is_exception()):
return
if assert_cond(account.user.username == username):
return
# POST - DELETE
var group : NakamaAPI.ApiGroup = await client.create_group_async(session, "MyGroupName3")
if assert_false(group.is_exception()):
return
var delete = await client.delete_group_async(session, group.id)
if assert_false(delete.is_exception()):
return
# All good
done()
return
func _process(_delta):
assert_time(3)
+79
View File
@@ -0,0 +1,79 @@
extends "res://base_test.gd"
var content = {"My": "message"}
var match_string_props = {"region": "europe"}
var match_numeric_props = {"rank": 8}
var got_msg = false
var got_match = false
var socket1 = null
var socket2 = null
func setup():
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
var session1 = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session1.is_valid()):
return
var session2 = await client.authenticate_custom_async("MyIdentifier2")
if assert_cond(session1.is_valid()):
return
socket1 = Nakama.create_socket_from(client)
socket1.received_channel_message.connect(self._on_socket1_message)
socket1.received_matchmaker_matched.connect(self._on_socket1_matchmaker_matched)
var done = await socket1.connect_async(session1)
# Check that connection succeded
if assert_false(done.is_exception()):
return
socket2 = Nakama.create_socket_from(client)
done = await socket2.connect_async(session2)
# Check that connection succeded
if assert_false(done.is_exception()):
return
# Join room
var room1 = await socket1.join_chat_async("MyRoom", NakamaSocket.ChannelType.Room)
if assert_false(room1.is_exception()):
return
var room2 = await socket2.join_chat_async("MyRoom", NakamaSocket.ChannelType.Room)
if assert_false(room2.is_exception()):
return
# Socket 2 send message to socket 1
var msg_ack = await socket2.write_chat_message_async(room2.id, content)
if assert_false(msg_ack.is_exception()):
return
var ticket1 = await socket1.add_matchmaker_async("+properties.region:europe +properties.rank:>=7 +properties.rank:<=9", 2, 8, match_string_props, match_numeric_props)
if assert_false(ticket1.is_exception()):
return
var ticket2 = await socket2.add_matchmaker_async("+properties.region:europe +properties.rank:>=7 +properties.rank:<=9", 2, 8, match_string_props, match_numeric_props)
if assert_false(ticket2.is_exception()):
return
func _on_socket1_message(msg):
if assert_equal(msg.content, JSON.stringify(content)):
return
got_msg = true
check_end()
func _on_socket1_matchmaker_matched(p_matchmaker_matched):
if assert_equal(JSON.stringify(p_matchmaker_matched.users[0].string_properties), JSON.stringify(match_string_props)):
return
if assert_equal(JSON.stringify(p_matchmaker_matched.users[0].numeric_properties), JSON.stringify(match_numeric_props)):
return
if assert_equal(JSON.stringify(p_matchmaker_matched.users[1].string_properties), JSON.stringify(match_string_props)):
return
if assert_equal(JSON.stringify(p_matchmaker_matched.users[1].numeric_properties), JSON.stringify(match_numeric_props)):
return
got_match = true
check_end()
func _process(_delta):
assert_time(60)
func check_end():
if got_match and got_msg:
done()
+98
View File
@@ -0,0 +1,98 @@
extends "res://base_test.gd"
var content = {"My": "message"}
var match_props = {"region": "europe"}
var got_msg = false
var got_match = false
var socket1 : NakamaSocket = null
var socket2 : NakamaSocket = null
func setup():
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
var session1 = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session1.is_valid()):
return
var session2 = await client.authenticate_custom_async("MyIdentifier2")
if assert_cond(session2.is_valid()):
return
socket1 = Nakama.create_socket_from(client)
socket1.received_party_close.connect(self._on_party_close)
socket1.received_party_data.connect(self._on_party_data)
socket1.received_party_join_request.connect(self._on_party_join_request)
var conn = await socket1.connect_async(session1)
# Check that connection succeded
if assert_false(conn.is_exception()):
return
var party = await socket1.create_party_async(false, 2)
if assert_false(party.is_exception()):
return
#done()
socket2 = Nakama.create_socket_from(client)
socket2.received_party.connect(self._on_party)
socket2.received_party_close.connect(self._on_party_close)
socket2.received_party_join_request.connect(self._on_party_join_request)
socket2.received_party_leader.connect(self._on_party_leader)
socket2.received_party_presence.connect(self._on_party_presence)
var conn2 = await socket2.connect_async(session2)
# Check that connection succeded
if assert_false(conn2.is_exception()):
return
var join = await socket2.join_party_async(party.party_id)
if assert_false(join.is_exception()):
return
func _on_party_join_request(party_join_request : NakamaRTAPI.PartyJoinRequest):
prints("_on_party_join_request", party_join_request)
var requests : NakamaRTAPI.PartyJoinRequest = await socket1.list_party_join_requests_async(party_join_request.party_id)
if assert_false(requests.is_exception()):
return
if assert_cond(requests.presences.size() == 1):
return
await socket1.accept_party_member_async(party_join_request.party_id, party_join_request.presences[0])
await socket1.promote_party_member(party_join_request.party_id, party_join_request.presences[0])
func _on_party(party):
prints("_on_party", party)
func _on_party_close(party_close):
prints("_on_party_close", party_close)
func _on_party_data(data : NakamaRTAPI.PartyData):
prints("_on_party_data", data)
var left = await socket1.leave_party_async(data.party_id)
if assert_false(left.is_exception()):
return
func _on_party_leader(party_leader : NakamaRTAPI.PartyLeader):
prints("_on_party_leader", party_leader)
var ticket = await socket2.add_matchmaker_party_async(party_leader.party_id)
if assert_false(ticket.is_exception()):
return
_on_party_ticket(ticket)
func _on_party_ticket(ticket : NakamaRTAPI.PartyMatchmakerTicket):
prints("_on_party_ticket", ticket)
var removed = await socket2.remove_matchmaker_party_async(ticket.party_id, ticket.ticket)
if assert_false(removed.is_exception()):
return
var sent = await socket2.send_party_data_async(ticket.party_id, 1, "asd")
if assert_false(sent.is_exception()):
return
func _on_party_presence(party_presence : NakamaRTAPI.PartyPresenceEvent):
prints("_on_party_presence", party_presence)
var left = party_presence.leaves.size() == 1
if left:
var closed = await socket2.close_party_async(party_presence.party_id)
if assert_false(closed.is_exception()):
return
done()
+27
View File
@@ -0,0 +1,27 @@
extends "res://base_test.gd"
var _connected = false
func setup():
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
var session = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session.is_valid()):
return
var socket = Nakama.create_socket_from(client)
socket.connected.connect(self._on_socket_connected)
var conn = await socket.connect_async(session)
# Check that connection succeded
if assert_false(conn.is_exception()):
return
# Check that signal has been called
if assert_cond(_connected):
return
done()
func _on_socket_connected():
_connected = true
func _process(_delta):
assert_time(3)
+72
View File
@@ -0,0 +1,72 @@
extends "res://base_test.gd"
const COLLECTION = "ACollection"
const K1 = "Question"
const K2 = "Answer"
const V1 = {"question": "Ultimate question"}
const V2 = {"answer": "42"}
func setup():
var username = str(randi_range(1000, 100000))
var client = Nakama.create_client(Config.SERVER_KEY, Config.HOST, Config.PORT, Config.SCHEME)
var session = await client.authenticate_custom_async("MyIdentifier")
if assert_cond(session.is_valid()):
return
var objs = [
NakamaWriteStorageObject.new(COLLECTION, K1, 1, 1, to_json(V1), ""),
NakamaWriteStorageObject.new(COLLECTION, K2, 1, 1, to_json(V2), "")
]
var write : NakamaAPI.ApiStorageObjectAcks = await client.write_storage_objects_async(session, objs)
if assert_false(write.is_exception()):
return
var objs_ids = []
for a in write.acks:
#var obj : NakamaAPI.ApiStorageObjectAck = a
objs_ids.append(NakamaStorageObjectId.new(a.collection, a.key, a.user_id, a.version))
var read : NakamaAPI.ApiStorageObjects = await client.read_storage_objects_async(session, objs_ids)
if assert_false(read.is_exception()):
return
if assert_equal(read.objects.size(), 2):
return
if assert_equal(read.objects[0].collection, COLLECTION):
return
if assert_cond(read.objects[0].key in [K1, K2]):
return
if assert_cond(to_json(parse_json(read.objects[0].value)) in [to_json(V1), to_json(V2)]):
return
# Delete one
var del = await client.delete_storage_objects_async(session, [objs_ids[0]])
if assert_false(del.is_exception()):
return
# Confirm that one was deleted
var read2 : NakamaAPI.ApiStorageObjects = await client.read_storage_objects_async(session, objs_ids)
if assert_false(read2.is_exception()):
return
if assert_equal(read2.objects.size(), 1):
return
if assert_equal(read2.objects[0].collection, COLLECTION):
return
if assert_equal(read2.objects[0].key, objs_ids[1].key):
return
if assert_cond(to_json(parse_json(read2.objects[0].value)) in [to_json(V1), to_json(V2)]):
return
done()
return
func _process(_delta):
assert_time(3)
func to_json(value) -> String:
return JSON.stringify(value)
func parse_json(value):
var json = JSON.new()
if json.parse(value) != OK:
return null
return json.get_data()
+22
View File
@@ -0,0 +1,22 @@
extends Node
var HOST = "127.0.0.1"
var PORT = 7350
var SCHEME = "http"
var SERVER_KEY = "defaultkey"
func _ready():
var f = FileAccess.open("res://settings.json", FileAccess.READ)
if not f:
return
var json = JSON.new()
var error = json.parse(f.get_as_text())
var parsed = json.get_data()
if error != OK or typeof(parsed) != TYPE_DICTIONARY:
return
for k in parsed:
match k:
"HOST": HOST = parsed[k]
"PORT": PORT = parsed[k]
"SCHEME": SCHEME = parsed[k]
"SERVER_KEY": SERVER_KEY = parsed[k]