From e15a35f01334443371a13f4a8064d93e3b2ca7f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Wed, 30 Sep 2026 10:20:09 -0300 Subject: [PATCH 1/2] feat(activity): finish the activity when the game quits `activity.finish(activity)` calls ANativeActivity_finish (thread-safe) and chains the activity's onDestroy so that, after the glue's own onDestroy has waited for the backend's android_main to return, the process ends with _exit(0). The engine is a process global that outlives activity instances (labelle-bgfx#143), so without ending the process a relaunch would restore the quit game and finish again at once. First call per process only; a no-op off Android. Part of moca-tecnologia/flying-platform-labelle#979 / labelle-toolkit/labelle-bgfx#172. Claude-Session: https://claude.ai/code/session_01LpszrcSrLLijQcxxvgWyUQ --- README.md | 1 + build.zig | 1 + src/activity.zig | 60 +++++++++++++++++++++++++++++++++++++++ src/jni/activity_finish.c | 54 +++++++++++++++++++++++++++++++++++ src/root.zig | 4 +++ 5 files changed, 120 insertions(+) create mode 100644 src/activity.zig create mode 100644 src/jni/activity_finish.c diff --git a/README.md b/README.md index 7801aec..eaec38c 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ v0.1.x shipped the runtime services only. The runtime extraction, phase 1a–1d | `relayout` | `forceWindowRelayout(activity)`: re-apply the window attributes so a stuck 1x1 restored window relayouts (UI thread only) | bgfx #127 | | `aaudio` | the AAudio output device (`ensureStarted` / `stop` / `framesMixed`): the `labelle-audio` `DeviceSink` a backend's PCM mixer drives; PCM_I16 stereo 48 kHz. `ensureStarted` opens the stream and only *requests* the start — it never blocks on the audio server (no `AAudioStream_waitForStateChange`). A device-owned control thread logs `android: AAudio stream started (…)` once per start, on the stream's **first mixer pull** (its first data callback) — which may precede AAudio's own STARTED state flip (the legacy AudioTrack path pre-fills first), and never on `requestStart` alone. A device disconnect (headset / Bluetooth route change) is reported by the error callback and the control thread closes and reopens the stream **itself**, with no further `ensureStarted`, logging `android: AAudio stream disconnected; reopening`; a reopen that fails is retried on the control thread with exponential backoff (100 ms doubling to 5 s, ~60 s in total), then given up (one `giving up` line) until the next `ensureStarted`. Setup failures warn once per failure episode and otherwise retry silently. Pure NDK C API, links `libaaudio` | bgfx #306 | | `immersive` | `enable(activity)` / `applyUiThread(activity)`: hide the status and navigation bars in immersive-sticky mode (`WindowInsetsController` on API 30+, `setSystemUiVisibility` on API 28/29). `enable` chains `onContentRectChanged`/`onWindowFocusChanged` for sokol; `applyUiThread` is the UI-thread entry the bgfx shell calls on each focus gain. The generated `main.zig` passes the activity from labelle-core's backend seam | labelle-engine `src/android.zig` (engine#902) | +| `activity` | `finish(activity)`: the game quit (`game.quit()`), so finish the activity (`ANativeActivity_finish`, any thread) and, once the backend's normal destroy teardown has run, end the process (`_exit(0)`) so the next launch is a cold start rather than a restore of a quit game. First call per process only | FP #979 | | `video` | MediaCodec decode: `VideoDecoder` (AMediaExtractor + AMediaCodec → AImageReader YUV_420_888 on a worker thread, a ring of tightened Y/U/V planes for the backend's GPU-YUV upload) and `decodeTrack` (the mp4's audio track → 48 kHz stereo i16 `Pcm`: multichannel layouts are downmixed ITU-R BS.775-style — L' = L + 0.707·C + 0.707·Ls, R' likewise, LFE dropped, normalised so it cannot clip; mono duplicated, stereo untouched; the layout comes from the output format's `channel-mask`, else the channel count's default — and the PCM is placed on the media timeline from the track's first presentation time: silence prepended when the audio starts late, the head trimmed when it starts early, so PCM frame 0 is media time 0, the player's clock origin), plus the pure, host-tested `yuv` (CPU YUV→RGBA; `Matrix` BT.601/BT.709, limited/full range — the decoder picks it from the stream's `ColorSpace`) and `planes` (row de-pad / NV12 de-interleave) helpers the desktop decoders share; pure NDK C API, links `libmediandk` | bgfx `src/video/{android,android_audio,yuv,planes}.zig` (FP #549) | Every JNI service takes the running `ANativeActivity*` as an opaque pointer; the JNI walks (`src/jni/*.c`) read `->vm` / `->clazz` through the NDK's own header, so both backends pass what they already hold. `aaudio` declares its `MixCallback` structurally (`*const fn (out: []i16, channels: u8) void`) so the package has no dependency on labelle-audio; the consumer asserts equality at comptime. `build.zig` also exports `addAndroidSysroot` / `resolveNdk` / `nativeAppGlueDir` / `isAndroidTarget` for consumers' build scripts. diff --git a/build.zig b/build.zig index d5f5bb5..748de63 100644 --- a/build.zig +++ b/build.zig @@ -137,6 +137,7 @@ pub fn build(b: *std.Build) void { "src/jni/debuggable.c", "src/jni/window_relayout.c", "src/jni/renderer_query.c", + "src/jni/activity_finish.c", }, .flags = &.{ "-std=c11", "-Wall" }, }); diff --git a/src/activity.zig b/src/activity.zig new file mode 100644 index 0000000..a6fc3eb --- /dev/null +++ b/src/activity.zig @@ -0,0 +1,60 @@ +//! 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. `ANativeActivity_finish(activity)`. The NDK documents it as callable +//! from any thread: it 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. +//! 2. It 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)`. +//! +//! ## 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. +//! +//! ## Where to call it +//! +//! From the backend's shell, once it sees the game's quit request, with the +//! running `ANativeActivity*` (opaque here). Any thread. Only the first call in +//! a process does anything; later calls return `false`. +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; + +/// Finish `activity` and end the process once it has been destroyed (see the +/// file doc). 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 labelle_android_finish_activity(activity) != 0; +} + +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))); +} diff --git a/src/jni/activity_finish.c b/src/jni/activity_finish.c new file mode 100644 index 0000000..1951e89 --- /dev/null +++ b/src/jni/activity_finish.c @@ -0,0 +1,54 @@ +// Finish the running NativeActivity and end the process once it is destroyed +// (moca-tecnologia/flying-platform-labelle#979). The Zig side is +// `activity.zig`; read it for the why. +// +// In C for the same reason as the other files here: +// owns the `ANativeActivity` / `ANativeActivityCallbacks` layout. Off Android +// this is an empty TU. +#ifdef __ANDROID__ + +#include +#include +#include +#include +#include + +// 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. A LATER activity instance in the +// same process (labelle-bgfx#143) keeps the glue's callback, so only a +// finished activity's destroy ends the process. +static ANativeActivity *finishing = NULL; +static atomic_flag requested = ATOMIC_FLAG_INIT; + +// 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); +} + +// Returns 1 when this call finished the activity, 0 when it did nothing (no +// activity, or a finish was already requested in this process). +int labelle_android_finish_activity(void *activity_ptr) { + ANativeActivity *na = (ANativeActivity *)activity_ptr; + if (na == NULL || na->callbacks == NULL) return 0; + if (atomic_flag_test_and_set(&requested)) return 0; + // Hook BEFORE asking for the finish: `ANativeActivity_finish` only posts a + // message to the UI thread, which is where `onDestroy` later runs, and the + // post orders these stores before it. The glue never rewrites this slot + // after onCreate, so saving and replacing it here does not race it. + 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 diff --git a/src/root.zig b/src/root.zig index 03152f1..1007253 100644 --- a/src/root.zig +++ b/src/root.zig @@ -19,6 +19,8 @@ //! * `renderer` — resolve the renderer at launch and export //! `LABELLE_BGFX_RENDERER` (labelle-android#27, labelle-bgfx#172); //! `crash_guard` is its D11 Vulkan crash guard (labelle-android#28). +//! * `activity` — finish the running activity when the game quits, then +//! end the process (moca-tecnologia/flying-platform-labelle#979). //! //! Every file is reached from here by relative import inside this module. //! Consumers `@import("labelle_android")` and pass the running @@ -52,6 +54,7 @@ pub const video = @import("video.zig"); pub const immersive = @import("immersive.zig"); pub const renderer = @import("renderer.zig"); pub const crash_guard = @import("crash_guard.zig"); +pub const activity = @import("activity.zig"); test { // Explicit refs: a lazily-referenced file's tests are otherwise never @@ -65,4 +68,5 @@ test { _ = immersive; _ = renderer; _ = crash_guard; + _ = activity; } From 177ab5ffb00cd8965d1f2461ae801d131354d0fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexandre=20Mondaini=20Calv=C3=A3o?= Date: Wed, 30 Sep 2026 11:21:25 -0300 Subject: [PATCH 2/2] fix(activity): narrow finish() to the app thread with the window held Codex (PR #35): finish() was advertised as callable from any thread, but it writes callbacks->onDestroy, which the UI thread reads on destruction; an any-thread caller has no ordering against a concurrent destroy. There is no UI-thread init point in this package (ANativeActivity_onCreate belongs to the backend's glue), so the contract is narrowed and documented: call from the glue app thread while the activity holds its window. NativeActivity.onDestroy destroys the surface before the native onDestroy, and the glue's onNativeWindowDestroyed blocks until the app thread has processed TERM_WINDOW, so the hook write happens-before the read. The once-guard moves to Zig (`request`) with host tests: null never takes it, repeats never reach the JNI half, and a finish that did nothing releases it. Part of moca-tecnologia/flying-platform-labelle#979 / labelle-toolkit/labelle-bgfx#172. Claude-Session: https://claude.ai/code/session_01LpszrcSrLLijQcxxvgWyUQ --- README.md | 2 +- src/activity.zig | 122 +++++++++++++++++++++++++++++++++----- src/jni/activity_finish.c | 25 ++++---- 3 files changed, 120 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index eaec38c..27fe870 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ v0.1.x shipped the runtime services only. The runtime extraction, phase 1a–1d | `relayout` | `forceWindowRelayout(activity)`: re-apply the window attributes so a stuck 1x1 restored window relayouts (UI thread only) | bgfx #127 | | `aaudio` | the AAudio output device (`ensureStarted` / `stop` / `framesMixed`): the `labelle-audio` `DeviceSink` a backend's PCM mixer drives; PCM_I16 stereo 48 kHz. `ensureStarted` opens the stream and only *requests* the start — it never blocks on the audio server (no `AAudioStream_waitForStateChange`). A device-owned control thread logs `android: AAudio stream started (…)` once per start, on the stream's **first mixer pull** (its first data callback) — which may precede AAudio's own STARTED state flip (the legacy AudioTrack path pre-fills first), and never on `requestStart` alone. A device disconnect (headset / Bluetooth route change) is reported by the error callback and the control thread closes and reopens the stream **itself**, with no further `ensureStarted`, logging `android: AAudio stream disconnected; reopening`; a reopen that fails is retried on the control thread with exponential backoff (100 ms doubling to 5 s, ~60 s in total), then given up (one `giving up` line) until the next `ensureStarted`. Setup failures warn once per failure episode and otherwise retry silently. Pure NDK C API, links `libaaudio` | bgfx #306 | | `immersive` | `enable(activity)` / `applyUiThread(activity)`: hide the status and navigation bars in immersive-sticky mode (`WindowInsetsController` on API 30+, `setSystemUiVisibility` on API 28/29). `enable` chains `onContentRectChanged`/`onWindowFocusChanged` for sokol; `applyUiThread` is the UI-thread entry the bgfx shell calls on each focus gain. The generated `main.zig` passes the activity from labelle-core's backend seam | labelle-engine `src/android.zig` (engine#902) | -| `activity` | `finish(activity)`: the game quit (`game.quit()`), so finish the activity (`ANativeActivity_finish`, any thread) and, once the backend's normal destroy teardown has run, end the process (`_exit(0)`) so the next launch is a cold start rather than a restore of a quit game. First call per process only | FP #979 | +| `activity` | `finish(activity)`: the game quit (`game.quit()`), so finish the activity (`ANativeActivity_finish`; call from the glue app thread while the activity holds its window — see `activity.zig`) and, once the backend's normal destroy teardown has run, end the process (`_exit(0)`) so the next launch is a cold start rather than a restore of a quit game. First call per process only | FP #979 | | `video` | MediaCodec decode: `VideoDecoder` (AMediaExtractor + AMediaCodec → AImageReader YUV_420_888 on a worker thread, a ring of tightened Y/U/V planes for the backend's GPU-YUV upload) and `decodeTrack` (the mp4's audio track → 48 kHz stereo i16 `Pcm`: multichannel layouts are downmixed ITU-R BS.775-style — L' = L + 0.707·C + 0.707·Ls, R' likewise, LFE dropped, normalised so it cannot clip; mono duplicated, stereo untouched; the layout comes from the output format's `channel-mask`, else the channel count's default — and the PCM is placed on the media timeline from the track's first presentation time: silence prepended when the audio starts late, the head trimmed when it starts early, so PCM frame 0 is media time 0, the player's clock origin), plus the pure, host-tested `yuv` (CPU YUV→RGBA; `Matrix` BT.601/BT.709, limited/full range — the decoder picks it from the stream's `ColorSpace`) and `planes` (row de-pad / NV12 de-interleave) helpers the desktop decoders share; pure NDK C API, links `libmediandk` | bgfx `src/video/{android,android_audio,yuv,planes}.zig` (FP #549) | Every JNI service takes the running `ANativeActivity*` as an opaque pointer; the JNI walks (`src/jni/*.c`) read `->vm` / `->clazz` through the NDK's own header, so both backends pass what they already hold. `aaudio` declares its `MixCallback` structurally (`*const fn (out: []i16, channels: u8) void`) so the package has no dependency on labelle-audio; the consumer asserts equality at comptime. `build.zig` also exports `addAndroidSysroot` / `resolveNdk` / `nativeAppGlueDir` / `isAndroidTarget` for consumers' build scripts. diff --git a/src/activity.zig b/src/activity.zig index a6fc3eb..a538cc5 100644 --- a/src/activity.zig +++ b/src/activity.zig @@ -9,14 +9,43 @@ //! //! ## What `finish` does //! -//! 1. `ANativeActivity_finish(activity)`. The NDK documents it as callable -//! from any thread: it posts to the UI thread, which calls +//! 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. -//! 2. It 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)`. +//! +//! ## 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 //! @@ -32,25 +61,45 @@ //! are still running, and `exit` would run C++ static destructors under them. //! Nothing needs flushing — logging goes straight to logcat. //! -//! ## Where to call it -//! -//! From the backend's shell, once it sees the game's quit request, with the -//! running `ANativeActivity*` (opaque here). Any thread. Only the first call in -//! a process does anything; later calls return `false`. +//! 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; +extern "c" fn labelle_android_finish_activity(activity: *anyopaque) c_int; -/// Finish `activity` and end the process once it has been destroyed (see the -/// file doc). 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). +/// 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; @@ -58,3 +107,48 @@ test "finish is a no-op off Android" { 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); +} diff --git a/src/jni/activity_finish.c b/src/jni/activity_finish.c index 1951e89..0785880 100644 --- a/src/jni/activity_finish.c +++ b/src/jni/activity_finish.c @@ -1,6 +1,6 @@ // Finish the running NativeActivity and end the process once it is destroyed -// (moca-tecnologia/flying-platform-labelle#979). The Zig side is -// `activity.zig`; read it for the why. +// (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: // owns the `ANativeActivity` / `ANativeActivityCallbacks` layout. Off Android @@ -9,18 +9,16 @@ #include #include -#include #include #include // 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. A LATER activity instance in the -// same process (labelle-bgfx#143) keeps the glue's callback, so only a -// finished activity's destroy ends the process. +// 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; -static atomic_flag requested = ATOMIC_FLAG_INIT; // 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 @@ -33,16 +31,15 @@ static void on_destroy_then_exit(ANativeActivity *na) { _exit(0); } -// Returns 1 when this call finished the activity, 0 when it did nothing (no -// activity, or a finish was already requested in this process). +// 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; - if (atomic_flag_test_and_set(&requested)) return 0; - // Hook BEFORE asking for the finish: `ANativeActivity_finish` only posts a - // message to the UI thread, which is where `onDestroy` later runs, and the - // post orders these stores before it. The glue never rewrites this slot - // after onCreate, so saving and replacing it here does not race it. finishing = na; glue_on_destroy = na->callbacks->onDestroy; na->callbacks->onDestroy = on_destroy_then_exit;