Add opt-in support for SDL's main callbacks - #550
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
There was a problem hiding this comment.
Pull request overview
Adds an opt-in “SDL main callbacks” execution path to Cute Framework so apps can keep animating during window drag/resize (and support clean shutdown in callback-driven hosts, including web). This is implemented by buffering SDL events delivered via SDL_AppEvent and draining them at the start of cf_app_update, while keeping classic-loop behavior unchanged unless CF_APP_OPTIONS_MAIN_CALLBACKS_BIT is enabled.
Changes:
- Introduces buffered event ingestion via
cf_app_process_event(deep-copies text events) and drains buffered events during app update; classic polling remains the default. - Adds
CF_MAIN_USE_CALLBACKSglue ininclude/cute.hthat providesSDL_AppInit/Iterate/Event/Quitand validatesCF_APP_OPTIONS_MAIN_CALLBACKS_BITis set. - Adds a new
main_callbackssample, documentation, CI smoke coverage, and unit tests for buffering/deep-copy/cap/quit/option-bit behavior.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| test/test_app.cpp | Adds tests for callback-mode buffering, text deep-copy, cap behavior, quit handling, and option-bit semantics. |
| src/internal/cute_input_internal.h | Declares new internal helpers for draining/freeing buffered events. |
| src/internal/cute_app_internal.h | Adds buffered event storage + mutex to CF_App and defines CF_MAX_BUFFERED_EVENTS. |
| src/cute_input.cpp | Refactors event handling and implements event buffering, draining, and deep-copy/free for text events. |
| src/cute_app.cpp | Integrates buffered-event draining into update, adds null-safe queries, and avoids double SDL_Quit under main callbacks. |
| samples/main_callbacks.c | New sample demonstrating callback-driven main loop usage. |
| samples/CMakeLists.txt | Builds the new main_callbacks sample. |
| mkdocs.yml | Adds the new sample page to docs navigation. |
| include/cute.h | Adds CF_MAIN_USE_CALLBACKS entrypoint/docs and defines the SDL main callbacks. |
| include/cute_app.h | Adds CF_APP_OPTIONS_MAIN_CALLBACKS_BIT and public APIs cf_app_get_options / cf_app_process_event with docs. |
| docs/samples/main_callbacks.md | New docs page embedding the sample. |
| .github/workflows/build.yml | Adds CI smoke tests for the new sample. |
Suppressed comments (1)
src/cute_input.cpp:619
SDL_EVENT_TEXT_EDITINGassumesevent->edit.textis non-NULL and unconditionally dereferences it inwhile (*text) .... If the pointer is NULL (e.g., SDL sends NULL or deep-copying fails), this will crash. Handle NULL by leaving an empty IME composition string and resetting cursor/selection fields.
while (*text) app->ime_composition.add(*text++);
| case SDL_EVENT_TEXT_INPUT: | ||
| { | ||
| cf_input_text_add_utf8(event->text.text); | ||
| app->ime_composition.clear(); | ||
| app->ime_composition_cursor = 0; | ||
| app->ime_composition_selection_len = 0; | ||
| } break; |
There was a problem hiding this comment.
Fixed in 96c33df. s_deep_copy_event's SDL_strdup can return NULL under OOM, so both event->text.text (line 609) and event->edit.text (line 618, the suppressed comment) needed the guard. For TEXT_EDITING, the NULL path also zeroes ime_composition_cursor/ime_composition_selection_len rather than leaving them pointing into an empty composition string. Added test_app_main_callbacks_null_text_is_safe covering both event types.
| void cf_free_buffered_events() | ||
| { | ||
| for (int i = 0; i < app->buffered_events.count(); ++i) { | ||
| s_free_event(&app->buffered_events[i]); | ||
| } | ||
| app->buffered_events.clear(); | ||
| } |
There was a problem hiding this comment.
Fixed in b629011, matching cf_drain_buffered_events's move-out-then-free-unlocked pattern. This closes the iterate-while-mutated race on the array itself, but not the broader destroy-time race you flagged in the next comment -- see reply there.
| cf_free_buffered_events(); | ||
| destroy_mutex(&app->buffered_events_mutex); |
There was a problem hiding this comment.
This is the limitation the PR description already calls out: "cf_destroy_app frees the event buffer and destroys buffered_events_mutex while another thread could still be inside cf_app_process_event ... Narrow (mobile lifecycle events only) but real." Agreed it's real -- a proper fix needs a shutdown protocol (e.g. an in-flight counter the destroy path waits to drain, or moving the mutex/queue to a lifetime outside CF_App) that's a bigger design change than this PR's scope. Leaving it as a disclosed limitation for now rather than bolting on synchronization here; open to doing it as a follow-up if you'd rather it land before merge.
s_deep_copy_event's SDL_strdup can return NULL under OOM, and SDL_EVENT_TEXT_INPUT/SDL_EVENT_TEXT_EDITING handling dereferenced text.text/edit.text unconditionally. Flagged by Copilot review on PR RandyGaul#550.
It iterated and cleared app->buffered_events without the lock that cf_app_process_event and cf_drain_buffered_events both take, so a concurrent SDL_AppEvent dispatch could mutate the array mid-iteration. Flagged by Copilot review on PR RandyGaul#550. Narrows, but does not close, the broader destroy-time race the PR description already discloses: this function still runs after the point where SDL could still be dispatching into cf_app_process_event during shutdown.
s_deep_copy_event's SDL_strdup can return NULL under OOM, and SDL_EVENT_TEXT_INPUT/SDL_EVENT_TEXT_EDITING handling dereferenced text.text/edit.text unconditionally. Flagged by Copilot review on PR RandyGaul#550.
It iterated and cleared app->buffered_events without the lock that cf_app_process_event and cf_drain_buffered_events both take, so a concurrent SDL_AppEvent dispatch could mutate the array mid-iteration. Flagged by Copilot review on PR RandyGaul#550. Narrows, but does not close, the broader destroy-time race the PR description already discloses: this function still runs after the point where SDL could still be dispatching into cf_app_process_event during shutdown.
b629011 to
1fb3152
Compare
|
Documentation CI on this PR will stay red until #553 lands — the remaining warnings come from master's 3d headers (via the merge preview), not from this branch. |
s_deep_copy_event's SDL_strdup can return NULL under OOM, and SDL_EVENT_TEXT_INPUT/SDL_EVENT_TEXT_EDITING handling dereferenced text.text/edit.text unconditionally. Flagged by Copilot review on PR RandyGaul#550.
It iterated and cleared app->buffered_events without the lock that cf_app_process_event and cf_drain_buffered_events both take, so a concurrent SDL_AppEvent dispatch could mutate the array mid-iteration. Flagged by Copilot review on PR RandyGaul#550. Narrows, but does not close, the broader destroy-time race the PR description already discloses: this function still runs after the point where SDL could still be dispatching into cf_app_process_event during shutdown.
c6b6bc0 to
7670ebf
Compare
|
I think instead of define and include That or it should at least be in cute_app, not the umbrella header |
Pure refactor: the per-event switch moves into s_handle_event so it can be fed from sources other than the internal SDL_PollEvent loop (SDL's main callbacks, next commit). The two key-repeat `continue`s become `return`s; no behavior change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VyCqnpVTb6YAPyxTbDGGWw
Define CF_MAIN_USE_CALLBACKS before including cute.h and implement cf_main_init/cf_main_update/cf_main_quit; CF supplies the four SDL_App* callbacks. Opt in per-app with CF_APP_OPTIONS_MAIN_CALLBACKS_BIT, which stops CF polling an event queue it no longer owns. Three reasons this is worth having: - Live resize no longer freezes. Verified A/B on macOS with two samples sharing identical drawing code and window options, differing only in loop shape: the classic while-loop twin froze while the window edge was held, the callback-mode one kept animating. SDL runs a nested iterate from inside its resize handler; CF's GPU frame survives it. - The web build stops needing a main-loop fork. The same source runs on desktop and under emscripten with no #ifdef CF_EMSCRIPTEN, and cf_main_quit actually runs at shutdown -- something emscripten_set_main_loop with simulate_infinite_loop could never do. Confirmed in a browser, not just linked. - Mobile lifecycle events are dispatched immediately rather than waiting on a poll, which is the platform-correct entry point on iOS/Android. Events are deep-copied and buffered rather than applied on arrival. Applying immediately would race cf_app_update's begin-frame copy of key state to prev and eat every just_pressed transition. Deep-copying is required because SDL frees the temporary memory backing text events before the next update can drain the buffer. The buffer is mutex-guarded since SDL can dispatch events from other threads, and capped at 4096 with oldest-dropped so an app that stops updating cannot grow it forever. No SDL type appears in the public API: cf_app_process_event takes void*, so cute_app.h needs no SDL declaration. The glue in cute.h is the only in-tree caller and passes the right type by construction. Both ways to get this wrong are loud rather than silent. Omitting the option bit under CF_MAIN_USE_CALLBACKS would otherwise leave a window with no keyboard, mouse or quit, so startup fails with a message naming the bit. Including SDL_main.h before cute.h would otherwise swallow the callback machinery via SDL's include guard and link a program with no main at all, so that is now an #error. samples/main_callbacks.c demonstrates the shape and is the instrument for the live-resize check above; it is registered for the web samples nav and smoke-tested in CI on all three platforms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GNNQhbyTrq1P41FmjRPvUt
The motion handler wrote to a stack copy returned by cf_touch_get and never stored it back, so cf_touch_get/cf_touch_get_all reported the touch-down coordinates for an entire drag. Pre-existing bug surfaced by review of the event-pump split; the handler now updates the touch in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GNNQhbyTrq1P41FmjRPvUt
s_deep_copy_event's SDL_strdup can return NULL under OOM, and SDL_EVENT_TEXT_INPUT/SDL_EVENT_TEXT_EDITING handling dereferenced text.text/edit.text unconditionally. Flagged by Copilot review on PR RandyGaul#550.
It iterated and cleared app->buffered_events without the lock that cf_app_process_event and cf_drain_buffered_events both take, so a concurrent SDL_AppEvent dispatch could mutate the array mid-iteration. Flagged by Copilot review on PR RandyGaul#550. Narrows, but does not close, the broader destroy-time race the PR description already discloses: this function still runs after the point where SDL could still be dispatching into cf_app_process_event during shutdown.
The Documentation CI check flags backticked references to symbols with no docs page. CF_MAIN_USE_CALLBACKS is referenced throughout the new callbacks API, so promote cute.h's explanatory comment block into a real doc block on a dummy define (guarded so a user-supplied define is never clobbered). Also point cf_app_get_options' brief at the documented CF_AppOptionFlagBits enum instead of the undocumented CF_AppOptionFlags typedef. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JhrxqgfYE2bwuuwR3UiADA
bullno1 flagged on PR RandyGaul#550 that defining CF_MAIN_USE_CALLBACKS before including the umbrella header puts entry-point code -- symbols with external linkage that may only exist in one translation unit -- inside a header every TU includes. Including cute_main.h is now the opt-in itself, and it stays out of cute.h the way cute_debug_printf.h already does for a different reason. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0111WZiHxAfDcTkoLQ7Xg97Y
7670ebf to
5f34811
Compare
The header split renamed the opt-in but missed three comments outside the doc-parsed API surface.
|
Good call — split it out into a dedicated |
| * and passes the right type by construction. | ||
| * @related cf_app_update cf_make_app CF_AppOptionFlagBits | ||
| */ | ||
| CF_API void CF_CALL cf_app_process_event(void* event); |
There was a problem hiding this comment.
Instead of void* event, just forward declare SDL_Event:
union SDL_Event;
CF_API void CF_CALL cf_app_process_event(union SDL_Event* event);See cf_app_get_window
union SDL_Event; is enough to type the parameter without pulling SDL3's headers into cute_app.h, so the compiler can now check the argument instead of accepting any void*. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BgaAhtyzb1Q9bpL7bSGCZN
CF apps freeze while their window is being dragged or resized. SDL runs a nested iterate from inside its Cocoa/Win32 resize handler, but only when main callbacks are active — otherwise it just posts
SDL_EVENT_WINDOW_EXPOSED, which CF ignores.This adds an opt-in path: include
cute_main.hin one source file, implementcf_main_init/cf_main_update/cf_main_quit, and passCF_APP_OPTIONS_MAIN_CALLBACKS_BITtocf_make_app. CF supplies the fourSDL_App*callbacks. Existing apps are untouched — without the header the internal pump behaves exactly as before.cute_main.his deliberately not pulled in by the umbrellacute.h, since it defines symbols with external linkage that may only exist in one translation unit — bullno1 flagged that the original#define CF_MAIN_USE_CALLBACKS+cute.hscheme put that in the wrong place.It also removes the main-loop fork web games currently need. Today a CF game targeting emscripten hand-rolls
#ifdef CF_EMSCRIPTENaroundemscripten_set_main_loop(update, 60, true)against awhileloop for native —samples/metaballs.cppstill does, and porting it is a natural follow-up. Under callbacks one code path covers both. Two further wins there: frames are paced byrequestAnimationFramerather than pinned at 60, andcf_main_quitactually executes at shutdown, whichsimulate_infinite_loopstructurally cannot do. The pacing change does mean web games should drive animation offCF_DELTA_TIMErather than assuming 60hz.The freeze fix is measured, not reasoned from SDL's source: two samples with identical drawing code and window options, differing only in loop shape. The classic
whiletwin froze while the window edge was held; the callback-mode one kept animating, with CF's GPU frame surviving the nested iterate. The web behaviour above was checked in a browser, not just linked.Events are deep-copied and buffered rather than applied on arrival. Applying immediately would race
cf_app_update's begin-frame copy of key state toprevand eat everyjust_pressedtransition; deep-copying is needed because SDL frees the temporary memory behind text events before the next update drains the buffer. No SDL type appears in the public API —cf_app_process_eventtakesvoid*, socute_app.hgains no SDL declaration.Both ways to get this wrong are loud. Omitting the option bit fails at startup with a message naming it, rather than leaving a window that silently receives no input; including
SDL_main.hbeforecute_main.his an#errorrather than a program that links with nomain.One thing deliberately left alone:
cf_destroy_appfrees the event buffer and destroysbuffered_events_mutexwhile another thread could still be insidecf_app_process_event. Narrow (mobile lifecycle events only) but real.CI note: adds a smoke-test step for the new sample on all three platforms, reusing the existing
smoke_test.sh.🤖 Generated with Claude Code
https://claude.ai/code/session_01GNNQhbyTrq1P41FmjRPvUt