Repository navigation
feat(activity): finish the activity when the game quits (FP#979) #35
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,154 @@ | ||
| //! Finish the running activity when the game quits | ||
| //! (moca-tecnologia/flying-platform-labelle#979). | ||
| //! | ||
| //! `game.quit()` only flips the engine's `running` flag; the backend's frame | ||
| //! loop forwards that to `window.requestQuit()`. On desktop that closes the | ||
| //! window and the process ends. On Android a NativeActivity is not closed by | ||
| //! its native code returning: the backend's shell loop runs until the | ||
| //! framework destroys the activity. `finish` asks for that. | ||
| //! | ||
| //! ## What `finish` does | ||
| //! | ||
| //! 1. Chains the activity's `onDestroy` callback: the glue's own | ||
| //! `onDestroy` runs first (it waits for the backend's `android_main` to | ||
| //! return), then the process ends with `_exit(0)`. | ||
| //! 2. `ANativeActivity_finish(activity)`: posts to the UI thread, which calls | ||
| //! `Activity.finish()`. The usual lifecycle follows (PAUSE, TERM_WINDOW, | ||
| //! STOP, DESTROY), so the backend tears down exactly as it does when the | ||
| //! user backs out. | ||
| //! | ||
| //! ## Calling contract (why the `onDestroy` write is race-free) | ||
| //! | ||
| //! `finish` MUST be called from the android_native_app_glue **app thread** | ||
| //! (the thread running `android_main`), **while the activity has its window** | ||
| //! — i.e. after the glue delivered APP_CMD_INIT_WINDOW and before the caller | ||
| //! has processed APP_CMD_TERM_WINDOW. The bgfx shell's call site (after a | ||
| //! frame, only while it owns a live surface) satisfies this. | ||
| //! | ||
| //! Step 1 writes `callbacks->onDestroy`, which the UI thread reads when the | ||
| //! activity is destroyed. The contract orders that write before the read: | ||
| //! | ||
| //! * `NativeActivity.onDestroy` (UI thread) destroys the surface BEFORE it | ||
| //! calls the native `onDestroy` (`onSurfaceDestroyedNative`, then | ||
| //! `unloadNativeCode`). Any window the activity has is therefore torn down | ||
| //! first, on the same thread. | ||
| //! * The glue's `onNativeWindowDestroyed` (`android_app_set_window(NULL)`) | ||
| //! posts APP_CMD_TERM_WINDOW and blocks on the glue mutex/condvar until | ||
| //! the app thread has processed it (`android_app_post_exec_cmd` sets | ||
| //! `window = NULL` and broadcasts under that mutex). | ||
| //! * The app thread only processes TERM_WINDOW after `finish` returned | ||
| //! (program order), so the writes happen-before the unlock, which | ||
| //! happens-before the UI thread leaving `onNativeWindowDestroyed`, which | ||
| //! precedes its read of `callbacks->onDestroy`. | ||
| //! | ||
| //! So while the caller holds the window, no destruction can be concurrently | ||
| //! reading the slot. A caller on another thread, or one without a window, | ||
| //! gets no such ordering: don't. (A UI-thread install at `onCreate` would not | ||
| //! need the contract, but `ANativeActivity_onCreate` belongs to the backend's | ||
| //! glue, not to this package.) | ||
| //! | ||
| //! ## Why the process ends | ||
| //! | ||
| //! The engine is a process global that outlives an activity instance | ||
| //! (labelle-bgfx#143: a relaunch into a cached process restores the running | ||
| //! game rather than booting a new one). After a quit its `running` flag stays | ||
| //! false, so the next launch would restore a quit game and finish at once: the | ||
| //! app would look like it cannot start. Ending the process makes the next | ||
| //! launch a cold start, which is what quitting means on desktop too. sokol's | ||
| //! Android backend does the same (`exit(0)` at the end of its `onDestroy`). | ||
| //! | ||
| //! `_exit`, not `exit`: the engine's worker threads (asset loading, audio) | ||
| //! are still running, and `exit` would run C++ static destructors under them. | ||
| //! Nothing needs flushing — logging goes straight to logcat. | ||
| //! | ||
| //! Only the first successful call in a process does anything; later calls | ||
| //! return `false` without touching the activity. | ||
| const is_android = @import("root.zig").is_android; | ||
|
|
||
| /// `jni/activity_finish.c`. Declared unconditionally (extern decls are only | ||
| /// linked when referenced); only the Android branch references it. | ||
| extern "c" fn labelle_android_finish_activity(activity: *anyopaque) c_int; | ||
|
|
||
| /// Process-wide once-guard. Only ever touched through `request`'s atomics. | ||
| var requested: bool = false; | ||
|
|
||
| /// Finish `activity` and end the process once it has been destroyed. See the | ||
| /// file doc for the calling contract (glue app thread, window held). Returns | ||
| /// true when this call requested the finish; false for a null activity, a | ||
| /// repeated call, or off Android (where it is a no-op). | ||
| pub fn finish(activity: ?*anyopaque) bool { | ||
| if (comptime !is_android) return false; | ||
| return request(&requested, activity, jniFinish); | ||
| } | ||
|
|
||
| fn jniFinish(activity: *anyopaque) bool { | ||
| return labelle_android_finish_activity(activity) != 0; | ||
| } | ||
|
|
||
| /// The once-guard, split from the JNI half so a HOST test can drive it. | ||
| /// | ||
| /// * null `activity` → false, guard untouched (a later real call still runs). | ||
| /// * guard already taken → false, `doFinish` not called. | ||
| /// * otherwise take the guard with one cmpxchg, then call `doFinish`. If it | ||
| /// reports that nothing was done (no callbacks table), release the guard | ||
| /// so a later call can still finish. | ||
| fn request(flag: *bool, activity: ?*anyopaque, comptime doFinish: fn (*anyopaque) bool) bool { | ||
| const a = activity orelse return false; | ||
| if (@cmpxchgStrong(bool, flag, false, true, .acq_rel, .acquire) != null) return false; | ||
| if (doFinish(a)) return true; | ||
| @atomicStore(bool, flag, false, .release); | ||
| return false; | ||
| } | ||
|
|
||
| test "finish is a no-op off Android" { | ||
| const testing = @import("std").testing; | ||
| if (comptime is_android) return error.SkipZigTest; | ||
| var dummy: u8 = 0; | ||
| try testing.expect(!finish(null)); | ||
| try testing.expect(!finish(@ptrCast(&dummy))); | ||
| } | ||
|
|
||
| const TestFinish = struct { | ||
| var calls: u32 = 0; | ||
| var result: bool = true; | ||
| fn call(_: *anyopaque) bool { | ||
| calls += 1; | ||
| return result; | ||
| } | ||
| }; | ||
|
|
||
| test "request: first real call finishes once; repeats and null never reach the JNI half" { | ||
| const testing = @import("std").testing; | ||
| var flag = false; | ||
| var dummy: u8 = 0; | ||
| TestFinish.calls = 0; | ||
| TestFinish.result = true; | ||
|
|
||
| try testing.expect(!request(&flag, null, TestFinish.call)); | ||
| try testing.expectEqual(@as(u32, 0), TestFinish.calls); | ||
| try testing.expect(!flag); // null does not take the guard | ||
|
|
||
| try testing.expect(request(&flag, @ptrCast(&dummy), TestFinish.call)); | ||
| try testing.expectEqual(@as(u32, 1), TestFinish.calls); | ||
| try testing.expect(flag); | ||
|
|
||
| try testing.expect(!request(&flag, @ptrCast(&dummy), TestFinish.call)); | ||
| try testing.expectEqual(@as(u32, 1), TestFinish.calls); // not called again | ||
| } | ||
|
|
||
| test "request: a finish that did nothing releases the guard for a later call" { | ||
| const testing = @import("std").testing; | ||
| var flag = false; | ||
| var dummy: u8 = 0; | ||
| TestFinish.calls = 0; | ||
| TestFinish.result = false; | ||
|
|
||
| try testing.expect(!request(&flag, @ptrCast(&dummy), TestFinish.call)); | ||
| try testing.expectEqual(@as(u32, 1), TestFinish.calls); | ||
| try testing.expect(!flag); | ||
|
|
||
| TestFinish.result = true; | ||
| try testing.expect(request(&flag, @ptrCast(&dummy), TestFinish.call)); | ||
| try testing.expectEqual(@as(u32, 2), TestFinish.calls); | ||
| try testing.expect(flag); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| // Finish the running NativeActivity and end the process once it is destroyed | ||
| // (moca-tecnologia/flying-platform-labelle#979). The Zig side, `activity.zig`, | ||
| // owns the once-guard and documents the calling contract and why it is safe. | ||
| // | ||
| // In C for the same reason as the other files here: <android/native_activity.h> | ||
| // owns the `ANativeActivity` / `ANativeActivityCallbacks` layout. Off Android | ||
| // this is an empty TU. | ||
| #ifdef __ANDROID__ | ||
|
|
||
| #include <android/log.h> | ||
| #include <android/native_activity.h> | ||
| #include <stddef.h> | ||
| #include <unistd.h> | ||
|
|
||
| // The glue's own `onDestroy` (android_native_app_glue.c installs it in | ||
| // `ANativeActivity_onCreate`), saved when the hook is installed. | ||
| static void (*glue_on_destroy)(ANativeActivity *) = NULL; | ||
| // The activity whose `onDestroy` is hooked. Callbacks are per activity | ||
| // instance, so another instance in the same process (labelle-bgfx#143) keeps | ||
| // the glue's callback; the check below is belt and braces. | ||
| static ANativeActivity *finishing = NULL; | ||
|
|
||
| // Runs on the UI thread. The glue's `onDestroy` posts APP_CMD_DESTROY and | ||
| // BLOCKS until `android_main` has returned (the backend's normal destroy | ||
| // teardown), so by the time it returns the game loop is gone. Then end the | ||
| // process: see `activity.zig` for why, and why `_exit` rather than `exit`. | ||
| static void on_destroy_then_exit(ANativeActivity *na) { | ||
| if (glue_on_destroy != NULL) glue_on_destroy(na); | ||
| if (na != finishing) return; | ||
| __android_log_write(ANDROID_LOG_INFO, "labelle", "android: activity finished after a quit request; ending the process"); | ||
| _exit(0); | ||
| } | ||
|
|
||
| // Install the destroy hook, then ask for the finish. The caller (`activity.zig`) | ||
| // guarantees this runs at most once per process, on the glue's app thread, | ||
| // while the activity still has its window — the contract that orders these | ||
| // writes before the UI thread's read of `callbacks->onDestroy` (see | ||
| // `activity.zig`). Returns 1 when the finish was requested, 0 when the | ||
| // activity has no callbacks table (nothing was touched). | ||
| int labelle_android_finish_activity(void *activity_ptr) { | ||
| ANativeActivity *na = (ANativeActivity *)activity_ptr; | ||
| if (na == NULL || na->callbacks == NULL) return 0; | ||
| finishing = na; | ||
| glue_on_destroy = na->callbacks->onDestroy; | ||
| na->callbacks->onDestroy = on_destroy_then_exit; | ||
| __android_log_write(ANDROID_LOG_INFO, "labelle", "android: quit requested; finishing the activity"); | ||
| ANativeActivity_finish(na); | ||
| return 1; | ||
| } | ||
|
|
||
| #endif | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.