From 76c9e5cecc2a0571a560c2ad0098247e879ca77f Mon Sep 17 00:00:00 2001 From: Cort Fritz Date: Sun, 6 Sep 2026 22:07:33 -0700 Subject: [PATCH] Stop alert handlers reading the action name from freed memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both alert NIFs build their button handlers like this: NSString *action = btn[@"action"] ?: @"dismiss"; const char *act_c = [action UTF8String]; [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction *_) { mob_deliver_alert_action(act_c); }]]; The block captures a C pointer, so it retains nothing. `action` is owned by `buttons`, a local the enclosing dispatch block drops on return, and -UTF8String's buffer is autorelease-scoped on top of that. Both are gone well before the user taps. mob_deliver_alert_action then hands whatever occupies that memory to enif_make_atom, so the screen gets an {:alert, } that matches no clause — or a crash, if the bytes are not valid UTF-8. Capture the NSString instead and convert inside the handler, where the pointer only has to survive the enif_make_atom call. This is hard to see in testing: freed bytes usually still spell the old string, so it works until the allocator reuses them. AGENTS.md gets it as pre-empt rule 15. --- AGENTS.md | 8 ++++++++ ios/mob_nif.m | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index dbb129a..487ad43 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -294,6 +294,14 @@ These are the things we've burned ourselves on. Following them isn't optional. *the platform API didn't complain*. See `decisions/2026-08-09-tap-xy-reports-observed-effect.md`. +15. **Never capture `[nsstring UTF8String]` in a block that outlives the + scope.** The pointer belongs to the NSString (and is autorelease-scoped + besides), so a `const char *` captured for a callback — a `UIAlertAction` + handler, a completion block, anything the run loop calls back later — is + reading freed memory by the time it fires. Capture the object and convert + inside the block. Both alert NIFs did this, and it is invisible in testing + because freed bytes usually still spell the old string. + ## Where to look | Question | File | diff --git a/ios/mob_nif.m b/ios/mob_nif.m index 1f6740d..5c53aad 100644 --- a/ios/mob_nif.m +++ b/ios/mob_nif.m @@ -7350,11 +7350,16 @@ static ERL_NIF_TERM nif_alert_show(ErlNifEnv *env, int argc, const ERL_NIF_TERM as = UIAlertActionStyleCancel; if ([style isEqualToString:@"destructive"]) as = UIAlertActionStyleDestructive; - const char *act_c = [action UTF8String]; + // Capture the NSString, not [action UTF8String]. The C pointer aims + // into a string owned by `buttons`, a local ARC releases the moment + // this block returns — long before anyone taps — so the handler read + // freed memory and enif_make_atom built the action atom out of + // whatever was there. Capturing the object retains it for the life + // of the handler. [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction *_) { - mob_deliver_alert_action(act_c); + mob_deliver_alert_action([action UTF8String]); }]]; } UIViewController *vc = root_vc(); @@ -7399,11 +7404,10 @@ static ERL_NIF_TERM nif_action_sheet_show(ErlNifEnv *env, int argc, const ERL_NI as = UIAlertActionStyleCancel; if ([style isEqualToString:@"destructive"]) as = UIAlertActionStyleDestructive; - const char *act_c = [action UTF8String]; [ac addAction:[UIAlertAction actionWithTitle:label style:as handler:^(UIAlertAction *_) { - mob_deliver_alert_action(act_c); + mob_deliver_alert_action([action UTF8String]); }]]; } UIViewController *vc = root_vc();