diff --git a/client/scripts/atlas_standalone.gd b/client/scripts/atlas_standalone.gd index 4c6940b77..5c241c75d 100644 --- a/client/scripts/atlas_standalone.gd +++ b/client/scripts/atlas_standalone.gd @@ -105,7 +105,13 @@ func _exit_tree() -> void: ## else to show). atlas_app.gd is never modified. func _on_hud_app_changed(app_path: String, mode: int) -> void: if app_path == FRONT_APP and mode == HudGroups.Mode.GAMEPLAY: - HudGroups.open_app(FRONT_APP) + # Deferred, never synchronous: this handler runs INSIDE close_app()'s + # app_changed emit, and close_app() continues after the emit and resets + # _active_app to "" — a synchronous open_app() here gets clobbered, + # leaving the Atlas visible (open_app already raised its z) but + # is_app_active() false, which kills atlas_app's input guard: a + # keyboard soft-lock (PR #183 review, Tyre). + HudGroups.open_app.call_deferred(FRONT_APP) func _boot() -> void: diff --git a/client/tests/test_atlas_standalone.gd b/client/tests/test_atlas_standalone.gd index d8323e308..acfb6a670 100644 --- a/client/tests/test_atlas_standalone.gd +++ b/client/tests/test_atlas_standalone.gd @@ -12,11 +12,14 @@ extends GdUnitTestSuite ## Deliberately NOT covered here (would require a live server / real window, ## per the ticket's "VERIFY LIVE once" instruction rather than an automated ## suite): the full _boot() sequence, the attach-then-fallback-to-spawn -## control flow, the HudGroups close-interception behavior, and the -## orphan-on-exit server lifecycle. Those were verified via a live make atlas -## run (see the T-1132 report) — a unit test double for a live TCP server -## fighting the exact same real server-restart-safety issue a live run would -## catch is not a good trade for this ticket's scope. +## control flow, and the orphan-on-exit server lifecycle. Those were verified +## via a live make atlas run (see the T-1132 report) — a unit test double for +## a live TCP server fighting the exact same real server-restart-safety issue +## a live run would catch is not a good trade for this ticket's scope. +## The close-interception CONTRACT (deferred re-open, PR #183 review) IS +## pinned below at the HudGroups level — it needs no server, and the sync +## variant's soft-lock is exactly the kind of bug a live smoke plausibly +## misses. ## ## Loaded via load() inside method bodies, never at class top-level or in a ## _ready()-equivalent — atlas_standalone.gd's own _ready() references the @@ -29,6 +32,9 @@ extends GdUnitTestSuite ## not just for autoload scripts themselves. const SCRIPT_PATH := "res://scripts/atlas_standalone.gd" +# The companion's front app (see the close-interception contract tests below). +const FRONT := "implant/map" + func _script(): return load(SCRIPT_PATH) @@ -136,3 +142,50 @@ func test_parse_listening_line_unrelated_log_line_returns_negative_one() -> void assert_that( s._parse_listening_line("Waiting for client connection on port 9876") ).is_equal(-1) + + +# ============================================================================= +# Close-interception contract (PR #183 review, Tyre) — the companion's re-open +# of the front app MUST be deferred out of the app_changed emit frame. +# close_app() continues past its emit and resets _active_app to ""; a +# synchronous open_app() from the handler is silently clobbered, leaving the +# app visible (its z was already raised) but is_app_active() false — which +# kills the app's input guard: a keyboard soft-lock. Pinned here at the +# HudGroups level (no server, no window) so the deferred call in +# atlas_standalone._on_hud_app_changed can't be "simplified" back to sync. +# ============================================================================= + + +func _sync_reopen(app_path: String, mode: int) -> void: + if app_path == FRONT and mode == HudGroups.Mode.GAMEPLAY: + HudGroups.open_app(FRONT) + + +func _deferred_reopen(app_path: String, mode: int) -> void: + if app_path == FRONT and mode == HudGroups.Mode.GAMEPLAY: + HudGroups.open_app.call_deferred(FRONT) + + +func test_synchronous_reopen_from_close_emit_is_clobbered() -> void: + # Documents the bug class: this is the OLD interceptor shape, and it must + # keep failing to keep the app active — if HudGroups semantics ever change + # so sync re-open works, both this test and the deferred one below flag + # the contract shift for a deliberate look. + HudGroups.open_app(FRONT) + HudGroups.app_changed.connect(_sync_reopen) + HudGroups.close_app() + HudGroups.app_changed.disconnect(_sync_reopen) + assert_bool(HudGroups.is_app_active(FRONT)).is_false() + HudGroups.close_app() # restore clean autoload state for the next test + + +func test_deferred_reopen_survives_close_emit() -> void: + # The shipped interceptor shape (atlas_standalone.gd:_on_hud_app_changed). + HudGroups.open_app(FRONT) + HudGroups.app_changed.connect(_deferred_reopen) + HudGroups.close_app() + HudGroups.app_changed.disconnect(_deferred_reopen) + assert_bool(HudGroups.is_app_active(FRONT)).is_false() # not yet — deferred + await get_tree().process_frame + assert_bool(HudGroups.is_app_active(FRONT)).is_true() + HudGroups.close_app() # restore clean autoload state