Skip to content

Commit 26e198d

Browse files
committed
test: run issue 245 forensic repro
1 parent aabf59b commit 26e198d

10 files changed

Lines changed: 219 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
name: Forensic / issue-245 dictionary hot-reload
2+
3+
# Manual one-shot to verify the "newly-introduced typed Dictionary field
4+
# stays NIL after hot-reload" bug across Linux, macOS, and Windows Godot
5+
# 4.6.2-stable runners. Initial macOS run from a Mac confirmed the crash;
6+
# this workflow checks Linux + Windows + a second macOS for completeness.
7+
8+
on:
9+
workflow_dispatch:
10+
push:
11+
branches:
12+
- tmp/forensic-issue-245-run
13+
paths:
14+
- .github/workflows/forensic-issue-245.yml
15+
- docs/forensic/issue-245/**
16+
17+
jobs:
18+
synthetic:
19+
name: Synthetic / ${{ matrix.os }}
20+
runs-on: ${{ matrix.os }}
21+
strategy:
22+
fail-fast: false
23+
matrix:
24+
os: [ubuntu-latest, macos-latest, windows-latest]
25+
26+
steps:
27+
- uses: actions/checkout@v6
28+
29+
- uses: chickensoft-games/setup-godot@v2
30+
with:
31+
version: 4.6.2
32+
use-dotnet: false
33+
34+
- name: Run synthetic dictionary-injection test
35+
shell: bash
36+
run: |
37+
set +e
38+
godot --headless --editor \
39+
--path docs/forensic/issue-245/repro-dictinject \
40+
--quit-after 1500 > synthetic.log 2>&1
41+
rc=$?
42+
echo "::group::Full Godot output"
43+
cat synthetic.log
44+
echo "::endgroup::"
45+
echo "exit code: $rc"
46+
echo
47+
echo "===== Verdict ====="
48+
if grep -q "post-reload property names" synthetic.log; then
49+
grep "post-reload property names" synthetic.log
50+
fi
51+
if grep -q "inst.get('injected_dict')" synthetic.log; then
52+
grep "inst.get('injected_dict')" synthetic.log
53+
fi
54+
if grep -q "Program crashed with signal" synthetic.log; then
55+
echo "RESULT: CRASH (Dictionary::keys on null _p)"
56+
elif grep -q "DONE — no SIGABRT" synthetic.log; then
57+
echo "RESULT: OK (no crash, hot-reload initialized field correctly)"
58+
else
59+
echo "RESULT: INDETERMINATE (test did not complete normally; rc=$rc)"
60+
fi
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[plugin]
2+
3+
name="Repro DictInject"
4+
description="Synthetic test for Dictionary hot-reload field injection bug (#245)"
5+
author="dsarno"
6+
version="0.1"
7+
script="plugin.gd"
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
@tool
2+
extends EditorPlugin
3+
4+
# Synthetic test for #245: macOS Godot 4.6.2-mono leaves a newly-injected
5+
# Dictionary field with null _p after hot-reloading the script class on a
6+
# live instance. Models the v2.1.1 → v2.1.2 self-update path of godot-ai.
7+
#
8+
# Sequence on _enter_tree (deferred so editor is fully booted):
9+
# 1) stage repro_v1.gd content into res://repro.gd
10+
# 2) construct an instance of v1
11+
# 3) overwrite res://repro.gd with v2 content (adds Dictionary field)
12+
# 4) trigger filesystem rescan + script reload
13+
# 5) read instance.injected_dict.keys() — bug fires here
14+
15+
var _instance: RefCounted
16+
17+
18+
func _enter_tree() -> void:
19+
print("[repro] _enter_tree — scheduling test")
20+
get_tree().create_timer(2.0).timeout.connect(_step_1_stage_v1)
21+
22+
23+
func _exit_tree() -> void:
24+
pass
25+
26+
27+
func _read_text(p: String) -> String:
28+
var f := FileAccess.open(p, FileAccess.READ)
29+
if f == null:
30+
print("[repro] FAIL: cannot read %s" % p)
31+
return ""
32+
var s := f.get_as_text()
33+
f.close()
34+
return s
35+
36+
37+
func _write_text(p: String, content: String) -> void:
38+
var f := FileAccess.open(p, FileAccess.WRITE)
39+
if f == null:
40+
print("[repro] FAIL: cannot open %s for write" % p)
41+
return
42+
f.store_string(content)
43+
f.close()
44+
45+
46+
func _step_1_stage_v1() -> void:
47+
print("[repro] step 1: stage v1 at res://repro.gd")
48+
var v1: String = _read_text("res://repro_v1.gd")
49+
_write_text("res://repro.gd", v1)
50+
EditorInterface.get_resource_filesystem().scan()
51+
get_tree().create_timer(2.0).timeout.connect(_step_2_construct_v1)
52+
53+
54+
func _step_2_construct_v1() -> void:
55+
print("[repro] step 2: construct v1 instance")
56+
var script_v1: GDScript = load("res://repro.gd") as GDScript
57+
if script_v1 == null:
58+
print("[repro] FAIL: v1 class did not load")
59+
return
60+
_instance = script_v1.new()
61+
if _instance == null:
62+
print("[repro] FAIL: v1 instance is null")
63+
return
64+
print("[repro] v1 instance: %s" % _instance)
65+
var s: Variant = _instance.call("say")
66+
print("[repro] v1 .say() -> %s" % str(s))
67+
var prop_names: Array[String] = []
68+
for p in _instance.get_property_list():
69+
prop_names.append(p["name"])
70+
print("[repro] v1 property names: %s" % str(prop_names))
71+
get_tree().create_timer(1.0).timeout.connect(_step_3_overwrite_to_v2)
72+
73+
74+
func _step_3_overwrite_to_v2() -> void:
75+
print("[repro] step 3: overwrite res://repro.gd with v2 (adds Dictionary field)")
76+
var v2: String = _read_text("res://repro_v2.gd")
77+
_write_text("res://repro.gd", v2)
78+
EditorInterface.get_resource_filesystem().scan()
79+
get_tree().create_timer(3.0).timeout.connect(_step_4_force_reload)
80+
81+
82+
func _step_4_force_reload() -> void:
83+
print("[repro] step 4: force script reload + reimport")
84+
EditorInterface.get_resource_filesystem().reimport_files(PackedStringArray(["res://repro.gd"]))
85+
get_tree().create_timer(2.0).timeout.connect(_step_5_probe)
86+
87+
88+
func _step_5_probe() -> void:
89+
print("[repro] step 5: probe instance after hot-reload")
90+
if _instance == null:
91+
print("[repro] FAIL: instance was GC'd")
92+
_quit_with(1)
93+
return
94+
var prop_names: Array[String] = []
95+
for p in _instance.get_property_list():
96+
prop_names.append(p["name"])
97+
print("[repro] post-reload property names: %s" % str(prop_names))
98+
99+
var v: Variant = _instance.get("injected_dict")
100+
print("[repro] inst.get('injected_dict'): typeof=%d value=%s" % [typeof(v), str(v)])
101+
102+
if typeof(v) == TYPE_DICTIONARY:
103+
var d: Dictionary = v
104+
print("[repro] is Dictionary; calling .keys()...")
105+
# THIS is where the bug would crash (Dictionary::keys on null _p)
106+
var k: Array = d.keys()
107+
print("[repro] .keys() returned %s (size=%d)" % [str(k), k.size()])
108+
else:
109+
print("[repro] field is not Dictionary; type=%d" % typeof(v))
110+
111+
# Call the v2 .say() method which itself does injected_dict.keys()
112+
print("[repro] calling inst.say() (v2 body)...")
113+
var msg: Variant = _instance.call("say")
114+
print("[repro] inst.say() -> %s" % str(msg))
115+
116+
print("[repro] DONE — no SIGABRT. Bug did NOT reproduce in synthetic.")
117+
_quit_with(0)
118+
119+
120+
func _quit_with(code: int) -> void:
121+
get_tree().create_timer(0.5).timeout.connect(func(): get_tree().quit(code))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://fkcshrjxd5cs
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[gd_scene format=3 uid="uid://b00000000000y"]
2+
3+
[node name="Main" type="Node"]
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
; Engine configuration file for synthetic Dictionary-injection hot-reload test.
2+
3+
config_version=5
4+
5+
[application]
6+
7+
config/name="repro-dictinject"
8+
config/features=PackedStringArray("4.6")
9+
run/main_scene="res://main.tscn"
10+
11+
[editor_plugins]
12+
13+
enabled=PackedStringArray("res://addons/repro/plugin.cfg")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class_name Repro
2+
extends RefCounted
3+
4+
func say() -> String:
5+
return "v1: no injected_dict field"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://dylc8rkmwoca3
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class_name Repro
2+
extends RefCounted
3+
4+
var injected_dict: Dictionary = {}
5+
6+
func say() -> String:
7+
return "v2: injected_dict.keys() = %s" % str(injected_dict.keys())
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
uid://b2iks11w20k8c

0 commit comments

Comments
 (0)