From 0a18e9668a525fa6304354ed1cb9574dc98e7c2e Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 13 Apr 2026 16:25:01 +0900 Subject: [PATCH 1/4] fix(godot): add initialization guard to prevent double plugin init When the plugin is both enabled in ProjectSettings and attached as an AutoLoad node, _ready() runs twice causing duplicate initialization, listeners, and state conflicts. - Add _is_initialized guard in _ready() to prevent double native plugin init - Add _is_connected guard in init_connection() to make it idempotent Closes hyochan/godot-iap#24 Co-Authored-By: Claude Opus 4.6 (1M context) --- libraries/godot-iap/addons/godot-iap/godot_iap.gd | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index 46d896a7..e568da59 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -27,11 +27,15 @@ signal developer_provided_billing_android(details: Dictionary) # Native plugin reference var _native_plugin: Object = null var _is_connected: bool = false +var _is_initialized: bool = false # Platform detection var _platform: String = "" func _ready() -> void: + if _is_initialized: + return + _is_initialized = true _platform = OS.get_name() _init_native_plugin() @@ -177,6 +181,9 @@ func _on_android_developer_provided_billing(details_json: String) -> void: ## @return bool - true if connection was successful func init_connection() -> bool: print("[GodotIap] init_connection called") + if _is_connected: + print("[GodotIap] Already connected, skipping init_connection") + return true if _native_plugin: if _platform == "Android": print("[GodotIap] Calling Android initConnection...") From 844a7a6cc3f6136fcb01d80b713b4326b86b7a14 Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 13 Apr 2026 17:17:00 +0900 Subject: [PATCH 2/4] test(godot): add tests for initialization guard and idempotent connection - Test _is_initialized guard prevents double _ready() execution - Test init_connection is idempotent when already connected Co-Authored-By: Claude Opus 4.6 (1M context) --- .../godot-iap/Example/tests/test_godot_iap.gd | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/libraries/godot-iap/Example/tests/test_godot_iap.gd b/libraries/godot-iap/Example/tests/test_godot_iap.gd index 63504f55..b96118d7 100644 --- a/libraries/godot-iap/Example/tests/test_godot_iap.gd +++ b/libraries/godot-iap/Example/tests/test_godot_iap.gd @@ -23,6 +23,10 @@ func _ready() -> void: func _run_all_tests() -> void: + # Initialization guard tests + test_ready_guard_prevents_double_init() + test_init_connection_idempotent() + # Connection tests test_init_connection_mock() test_end_connection_mock() @@ -42,6 +46,31 @@ func _run_all_tests() -> void: test_android_methods_mock() +# ============================================ +# Initialization Guard Tests +# ============================================ + +func test_ready_guard_prevents_double_init() -> void: + # _is_initialized should be true after first _ready() call + _assert_true(GodotIapPlugin._is_initialized, "_is_initialized should be true after _ready()") + + # Calling _ready() again should be a no-op (guard prevents re-init) + var platform_before = GodotIapPlugin._platform + GodotIapPlugin._ready() + var platform_after = GodotIapPlugin._platform + _assert_equal(platform_before, platform_after, "_ready() called twice should not change state") + + +func test_init_connection_idempotent() -> void: + # First call + var result1 = GodotIapPlugin.init_connection() + _assert_true(result1, "First init_connection should return true") + + # Second call should short-circuit via _is_connected guard + var result2 = GodotIapPlugin.init_connection() + _assert_true(result2, "Second init_connection should return true (idempotent)") + + # ============================================ # Connection Tests (Mock Mode) # ============================================ From 468d476a82ec4ddffe8b59d0af4ff31b92d28a24 Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 13 Apr 2026 17:17:48 +0900 Subject: [PATCH 3/4] fix(godot): use static guard and remove cached connection shortcut Address review feedback: - Make _is_initialized a static var so it guards across all instances - Remove _is_connected early return in init_connection() to avoid false-positive success on iOS async connections Co-Authored-By: Claude Opus 4.6 (1M context) --- libraries/godot-iap/Example/tests/test_godot_iap.gd | 11 +++++------ libraries/godot-iap/addons/godot-iap/godot_iap.gd | 5 +---- 2 files changed, 6 insertions(+), 10 deletions(-) diff --git a/libraries/godot-iap/Example/tests/test_godot_iap.gd b/libraries/godot-iap/Example/tests/test_godot_iap.gd index b96118d7..ff75ebb5 100644 --- a/libraries/godot-iap/Example/tests/test_godot_iap.gd +++ b/libraries/godot-iap/Example/tests/test_godot_iap.gd @@ -51,10 +51,10 @@ func _run_all_tests() -> void: # ============================================ func test_ready_guard_prevents_double_init() -> void: - # _is_initialized should be true after first _ready() call - _assert_true(GodotIapPlugin._is_initialized, "_is_initialized should be true after _ready()") + # Static _is_initialized should be true after first _ready() call + _assert_true(GodotIapWrapper._is_initialized, "static _is_initialized should be true after _ready()") - # Calling _ready() again should be a no-op (guard prevents re-init) + # Calling _ready() again should be a no-op (static guard prevents re-init) var platform_before = GodotIapPlugin._platform GodotIapPlugin._ready() var platform_after = GodotIapPlugin._platform @@ -62,13 +62,12 @@ func test_ready_guard_prevents_double_init() -> void: func test_init_connection_idempotent() -> void: - # First call + # Calling init_connection multiple times should not error var result1 = GodotIapPlugin.init_connection() _assert_true(result1, "First init_connection should return true") - # Second call should short-circuit via _is_connected guard var result2 = GodotIapPlugin.init_connection() - _assert_true(result2, "Second init_connection should return true (idempotent)") + _assert_true(result2, "Second init_connection should return true") # ============================================ diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index e568da59..bafc92b3 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -27,7 +27,7 @@ signal developer_provided_billing_android(details: Dictionary) # Native plugin reference var _native_plugin: Object = null var _is_connected: bool = false -var _is_initialized: bool = false +static var _is_initialized: bool = false # Platform detection var _platform: String = "" @@ -181,9 +181,6 @@ func _on_android_developer_provided_billing(details_json: String) -> void: ## @return bool - true if connection was successful func init_connection() -> bool: print("[GodotIap] init_connection called") - if _is_connected: - print("[GodotIap] Already connected, skipping init_connection") - return true if _native_plugin: if _platform == "Android": print("[GodotIap] Calling Android initConnection...") From e283d4ab07ae2dd4463d8cb517e46c481a8c97a2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 13 Apr 2026 17:21:03 +0900 Subject: [PATCH 4/4] test(godot): improve guard tests with signal count verification - Reorder tests: connection tests run before guard tests to avoid state leakage - Verify _ready() guard by checking signal connection count doesn't increase - Reset _is_connected before idempotent test for isolation - Add test_no_duplicate_signal_connections for PR test plan coverage Co-Authored-By: Claude Opus 4.6 (1M context) --- .../godot-iap/Example/tests/test_godot_iap.gd | 35 ++++++++++++++----- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/libraries/godot-iap/Example/tests/test_godot_iap.gd b/libraries/godot-iap/Example/tests/test_godot_iap.gd index ff75ebb5..497b93de 100644 --- a/libraries/godot-iap/Example/tests/test_godot_iap.gd +++ b/libraries/godot-iap/Example/tests/test_godot_iap.gd @@ -23,13 +23,14 @@ func _ready() -> void: func _run_all_tests() -> void: + # Connection tests (run BEFORE guard tests to avoid state leakage) + test_init_connection_mock() + test_end_connection_mock() + # Initialization guard tests test_ready_guard_prevents_double_init() test_init_connection_idempotent() - - # Connection tests - test_init_connection_mock() - test_end_connection_mock() + test_no_duplicate_signal_connections() # Product tests await test_fetch_products_mock() @@ -54,14 +55,21 @@ func test_ready_guard_prevents_double_init() -> void: # Static _is_initialized should be true after first _ready() call _assert_true(GodotIapWrapper._is_initialized, "static _is_initialized should be true after _ready()") - # Calling _ready() again should be a no-op (static guard prevents re-init) - var platform_before = GodotIapPlugin._platform + # Count connected signals before second _ready() call + var connected_before = GodotIapPlugin.purchase_updated.get_connections().size() GodotIapPlugin._ready() - var platform_after = GodotIapPlugin._platform - _assert_equal(platform_before, platform_after, "_ready() called twice should not change state") + var connected_after = GodotIapPlugin.purchase_updated.get_connections().size() + + # Guard should prevent _init_native_plugin() from running again, + # so signal connection count must not increase + _assert_equal(connected_before, connected_after, "_ready() called twice should not add duplicate signal connections") + _assert_true(GodotIapWrapper._is_initialized, "static _is_initialized should still be true after second _ready()") func test_init_connection_idempotent() -> void: + # Reset connection state to test fresh + GodotIapPlugin._is_connected = false + # Calling init_connection multiple times should not error var result1 = GodotIapPlugin.init_connection() _assert_true(result1, "First init_connection should return true") @@ -70,6 +78,17 @@ func test_init_connection_idempotent() -> void: _assert_true(result2, "Second init_connection should return true") +func test_no_duplicate_signal_connections() -> void: + # After multiple _ready() calls, signals should not have duplicate connections + var purchase_updated_count = GodotIapPlugin.purchase_updated.get_connections().size() + var purchase_error_count = GodotIapPlugin.purchase_error.get_connections().size() + + # In mock mode (no native plugin), there should be 0 native signal connections + # The key assertion: counts should be <= 1 (no duplicates) + _assert_true(purchase_updated_count <= 1, "purchase_updated should have at most 1 connection (got %d)" % purchase_updated_count) + _assert_true(purchase_error_count <= 1, "purchase_error should have at most 1 connection (got %d)" % purchase_error_count) + + # ============================================ # Connection Tests (Mock Mode) # ============================================