From ef9d838d0a3bd982e8eb8c84fbfaa32fe61895a8 Mon Sep 17 00:00:00 2001 From: James Date: Fri, 12 Jun 2026 17:13:33 -0500 Subject: [PATCH] Use async HTTP requests for UI and stats (#835) Replace blocking HTTP calls in menu code paths with asynchronous requests so the client no longer freezes (or shows a black screen at launch) while waiting on the stats API: - Add Net_HttpGetInstanceAsync and Net_HttpGetInstancesAsync, async counterparts to the JSON instance helpers, and expose them through the cgame import. - Fetch the GUID hash asynchronously at startup; the result is applied on the main thread via a NOTIFICATION_GUID_HASHED MVC event. - Fetch the leaderboard asynchronously; completed results are staged and applied in respondToEvent on NOTIFICATION_LEADERBOARD_FETCHED. - Fetch player stats asynchronously; completed responses are staged and applied in respondToEvent on NOTIFICATION_STATS_FETCHED. Stats also refresh when the GUID hash arrives. - Generation counters discard stale responses when a newer fetch has been issued; completion callbacks never touch UI state directly. - Add check_http unit tests covering both new async JSON helpers. --- src/cgame/cgame.h | 26 ++ .../ui/home/LeaderboardViewController.c | 99 +++++++- .../default/ui/home/StatsViewController.c | 231 ++++++++++++------ src/client/cl_cgame.c | 2 + src/client/cl_input.c | 8 +- src/client/cl_main.c | 61 +++-- src/client/cl_main.h | 1 + src/client/cl_types.h | 3 + src/net/net_http.c | 152 ++++++++++++ src/net/net_http.h | 47 ++++ src/tests/check_http.c | 144 +++++++++++ 11 files changed, 677 insertions(+), 97 deletions(-) diff --git a/src/cgame/cgame.h b/src/cgame/cgame.h index 85da16cff2..da58681274 100644 --- a/src/cgame/cgame.h +++ b/src/cgame/cgame.h @@ -207,6 +207,32 @@ typedef struct cg_import_s { */ void (*HttpGetAsync)(const char *url, Net_HttpCallback callback, void *user_data); + /** + * @brief Performs an asynchronous HTTP `GET` request and deserializes a single JSON object. + * @param url The URL to fetch. + * @param properties The JsonProperty descriptors for the destination struct. + * @param size The size of the destination struct. + * @param callback Invoked on a background thread when the request completes. + * @param user_data User data pointer passed through to the callback. + * @remarks The parsed instance is valid only for the duration of the callback; copy it if needed. + */ + void (*HttpGetInstanceAsync)(const char *url, const JsonProperty *properties, + size_t size, Net_HttpInstanceCallback callback, void *user_data); + + /** + * @brief Performs an asynchronous HTTP `GET` request and deserializes a JSON array. + * @param url The URL to fetch. + * @param properties The JsonProperty descriptors for the destination struct array. + * @param stride The byte distance between consecutive structs. + * @param count The capacity of the destination array. + * @param callback Invoked on a background thread when the request completes. + * @param user_data User data pointer passed through to the callback. + * @remarks The parsed instances are valid only for the duration of the callback; copy them if needed. + */ + void (*HttpGetInstancesAsync)(const char *url, const JsonProperty *properties, + size_t stride, size_t count, + Net_HttpInstancesCallback callback, void *user_data); + /** * @} * @defgroup filesystem Filesystem diff --git a/src/cgame/default/ui/home/LeaderboardViewController.c b/src/cgame/default/ui/home/LeaderboardViewController.c index cef6ad7a5d..9d406bdf3b 100644 --- a/src/cgame/default/ui/home/LeaderboardViewController.c +++ b/src/cgame/default/ui/home/LeaderboardViewController.c @@ -76,9 +76,54 @@ static const char *formatTime(int32_t seconds) { } /** - * @brief Fetches leaderboard rows using the given sort column. + * @brief Pending leaderboard fetch results, shared with the HTTP session thread. */ -static bool fetchLeaderboard(LeaderboardViewController *this, const TableColumn *column) { +static struct { + SDL_Mutex *lock; + uint32_t generation; + LeaderboardEntry entries[LEADERBOARD_MAX_ENTRIES]; + size_t num_entries; +} leaderboard_fetch; + +/** + * @brief Net_HttpInstancesCallback for `fetchLeaderboard`. Runs on the HTTP session + * thread, so the parsed rows are staged and marshaled to the main thread via an + * MVC notification. + */ +static void fetchLeaderboardComplete(int32_t status, void *instances, size_t count, void *user_data) { + + if (status != 200) { + return; + } + + const uint32_t generation = (uint32_t) (uintptr_t) user_data; + + SDL_LockMutex(leaderboard_fetch.lock); + + if (generation != leaderboard_fetch.generation) { + SDL_UnlockMutex(leaderboard_fetch.lock); + return; + } + + memset(leaderboard_fetch.entries, 0, sizeof(leaderboard_fetch.entries)); + + leaderboard_fetch.num_entries = count < LEADERBOARD_MAX_ENTRIES ? count : LEADERBOARD_MAX_ENTRIES; + if (instances && leaderboard_fetch.num_entries) { + memcpy(leaderboard_fetch.entries, instances, leaderboard_fetch.num_entries * sizeof(LeaderboardEntry)); + } + + SDL_UnlockMutex(leaderboard_fetch.lock); + + SDL_PushEvent(&(SDL_Event) { + .user.type = MVC_NOTIFICATION_EVENT, + .user.code = NOTIFICATION_LEADERBOARD_FETCHED + }); +} + +/** + * @brief Asynchronously fetches leaderboard rows using the given sort column. + */ +static void fetchLeaderboard(LeaderboardViewController *this, const TableColumn *column) { const char *sort = column ? sortParamForColumn(column->identifier) : NULL; const char *dir = (column && column->order == OrderAscending) ? "asc" : "desc"; @@ -90,12 +135,13 @@ static bool fetchLeaderboard(LeaderboardViewController *this, const TableColumn g_snprintf(url, sizeof(url), QUETOO_STATS_URL "?limit=%d&ai=0", LEADERBOARD_MAX_ENTRIES); } - size_t num_entries = 0; - const int32_t status = cgi.HttpGetInstances(url, leaderboard_properties, - this->entries, sizeof(LeaderboardEntry), - LEADERBOARD_MAX_ENTRIES, &num_entries); - this->num_entries = num_entries; - return status == 200; + SDL_LockMutex(leaderboard_fetch.lock); + const uint32_t generation = ++leaderboard_fetch.generation; + SDL_UnlockMutex(leaderboard_fetch.lock); + + cgi.HttpGetInstancesAsync(url, leaderboard_properties, + sizeof(LeaderboardEntry), LEADERBOARD_MAX_ENTRIES, + fetchLeaderboardComplete, (void *) (uintptr_t) generation); } /** @@ -181,8 +227,6 @@ static void didSetSortColumn(TableView *tableView) { LeaderboardViewController *this = tableView->delegate.self; fetchLeaderboard(this, tableView->sortColumn); - $(this->leaderboard, reloadData); - selectOwnRow(this); } #pragma mark - ViewController @@ -196,6 +240,10 @@ static void loadView(ViewController *self) { LeaderboardViewController *this = (LeaderboardViewController *) self; + if (leaderboard_fetch.lock == NULL) { + leaderboard_fetch.lock = SDL_CreateMutex(); + } + Outlet outlets[] = MakeOutlets( MakeOutlet("leaderboard", &this->leaderboard) ); @@ -222,6 +270,34 @@ static void loadView(ViewController *self) { } +/** + * @see ViewController::respondToEvent(ViewController *, const SDL_Event *) + */ +static void respondToEvent(ViewController *self, const SDL_Event *event) { + + LeaderboardViewController *this = (LeaderboardViewController *) self; + + if (event->type == MVC_NOTIFICATION_EVENT) { + + if (event->user.code == NOTIFICATION_LEADERBOARD_FETCHED) { + + SDL_LockMutex(leaderboard_fetch.lock); + this->num_entries = leaderboard_fetch.num_entries; + memcpy(this->entries, leaderboard_fetch.entries, sizeof(this->entries)); + SDL_UnlockMutex(leaderboard_fetch.lock); + + $(this->leaderboard, reloadData); + selectOwnRow(this); + + } else if (event->user.code == NOTIFICATION_GUID_HASHED) { + $(this->leaderboard, reloadData); + selectOwnRow(this); + } + } + + super(ViewController, self, respondToEvent, event); +} + /** * @see ViewController::viewWillAppear(ViewController *) */ @@ -232,8 +308,6 @@ static void viewWillAppear(ViewController *self) { LeaderboardViewController *this = (LeaderboardViewController *) self; fetchLeaderboard(this, this->leaderboard->sortColumn); - $(this->leaderboard, reloadData); - selectOwnRow(this); } #pragma mark - Class lifecycle @@ -243,6 +317,7 @@ static void viewWillAppear(ViewController *self) { */ static void initialize(Class *clazz) { ((ViewControllerInterface *) clazz->interface)->loadView = loadView; + ((ViewControllerInterface *) clazz->interface)->respondToEvent = respondToEvent; ((ViewControllerInterface *) clazz->interface)->viewWillAppear = viewWillAppear; } diff --git a/src/cgame/default/ui/home/StatsViewController.c b/src/cgame/default/ui/home/StatsViewController.c index 8ef9db7813..1ae99d7843 100644 --- a/src/cgame/default/ui/home/StatsViewController.c +++ b/src/cgame/default/ui/home/StatsViewController.c @@ -136,6 +136,139 @@ static void loadWeapons(StatsViewController *this, const Array *array) { } } +/** + * @brief Pending stats fetch results, shared with the HTTP session thread. + */ +static struct { + SDL_Mutex *lock; + uint32_t generation; + int32_t status; + void *body; + size_t length; +} stats_fetch; + +/** + * @brief Net_HttpCallback for `refresh`. Runs on the HTTP session thread, so the + * response body is staged and marshaled to the main thread via an MVC notification. + */ +static void fetchStatsComplete(int32_t status, void *body, size_t length, void *user_data) { + + const uint32_t generation = (uint32_t) (uintptr_t) user_data; + + SDL_LockMutex(stats_fetch.lock); + + if (generation != stats_fetch.generation) { + SDL_UnlockMutex(stats_fetch.lock); + return; + } + + g_free(stats_fetch.body); + + stats_fetch.status = status; + stats_fetch.body = NULL; + stats_fetch.length = 0; + + if (body && length) { + stats_fetch.body = g_malloc(length); + memcpy(stats_fetch.body, body, length); + stats_fetch.length = length; + } + + SDL_UnlockMutex(stats_fetch.lock); + + SDL_PushEvent(&(SDL_Event) { + .user.type = MVC_NOTIFICATION_EVENT, + .user.code = NOTIFICATION_STATS_FETCHED + }); +} + +/** + * @brief Loads stats from the given response body, updating all tiles and tables. + */ +static void loadStats(StatsViewController *this, int32_t status, void *body, size_t length) { + + if (status == 200) { + Data *data = $$(Data, dataWithConstMemory, body, length); + StatsSummary summary = { 0 }; + (void) $$(JSONSerialization, instanceFromData, stats_summary_properties, data, &summary); + ident json = $$(JSONSerialization, objectFromData, data, 0); + release(data); + + if (json) { + Dictionary *dict = cast(Dictionary, json); + if (dict) { + this->rank = summary.rank; + this->frags = summary.frags; + this->deaths = summary.deaths; + this->time_played = summary.time_played; + + const Dictionary *nemesis = $(dict, objectForKeyPathWithClass, "nemesis", _Dictionary()); + if (nemesis) { + const String *name = $(nemesis, objectForKeyPathWithClass, "name", _String()); + if (name) { + g_strlcpy(this->nemesis, name->chars, sizeof(this->nemesis)); + } + } + + loadWeapons(this, $(dict, objectForKeyPathWithClass, "kills_by_weapon", _Array())); + + } else if (!cast(Null, json)) { + Cg_Warn("Unexpected stats response type: %s\n", classnameof(json)); + } + + updateTiles(this); + release(json); + } else { + this->rank = summary.rank; + this->frags = summary.frags; + this->deaths = summary.deaths; + this->time_played = summary.time_played; + updateTiles(this); + } + } else { + $(this->rankLabel->text, setText, "—"); + $(this->fragsLabel->text, setText, "Error"); + $(this->deathsLabel->text, setText, "—"); + $(this->kdLabel->text, setText, "—"); + $(this->timeLabel->text, setText, "—"); + Cg_Warn("Failed to fetch stats: HTTP %d\n", status); + } + + $(this->weaponsTable, reloadData); +} + +/** + * @brief Clears the view and asynchronously fetches stats for the local player. + */ +static void refresh(StatsViewController *this) { + + clearStats(this); + + const char *guid_hashed = cgi.GetCvarString("guid_hashed"); + + if (!guid_hashed || !guid_hashed[0]) { + $(this->rankLabel->text, setText, "—"); + $(this->fragsLabel->text, setText, "Sign in"); + $(this->deathsLabel->text, setText, "—"); + $(this->kdLabel->text, setText, "—"); + $(this->timeLabel->text, setText, "—"); + $(this->weaponsTable, reloadData); + return; + } + + updateTiles(this); + $(this->weaponsTable, reloadData); + + char url[256]; + g_snprintf(url, sizeof(url), QUETOO_STATS_URL "/%s", guid_hashed); + + SDL_LockMutex(stats_fetch.lock); + const uint32_t generation = ++stats_fetch.generation; + SDL_UnlockMutex(stats_fetch.lock); + + cgi.HttpGetAsync(url, fetchStatsComplete, (void *) (uintptr_t) generation); +} + #pragma mark - TableViewDataSource /** @@ -181,6 +314,10 @@ static void loadView(ViewController *self) { StatsViewController *this = (StatsViewController *) self; + if (stats_fetch.lock == NULL) { + stats_fetch.lock = SDL_CreateMutex(); + } + Outlet outlets[] = MakeOutlets( MakeOutlet("nameLabel", &this->nameLabel), MakeOutlet("rankLabel", &this->rankLabel), @@ -211,87 +348,44 @@ static void loadView(ViewController *self) { } /** - * @see ViewController::viewWillAppear(ViewController *) + * @see ViewController::respondToEvent(ViewController *, const SDL_Event *) */ -static void viewWillAppear(ViewController *self) { - - super(ViewController, self, viewWillAppear); +static void respondToEvent(ViewController *self, const SDL_Event *event) { StatsViewController *this = (StatsViewController *) self; - const char *guid_hashed = cgi.GetCvarString("guid_hashed"); + if (event->type == MVC_NOTIFICATION_EVENT) { - if (!guid_hashed || !guid_hashed[0]) { - clearStats(this); + if (event->user.code == NOTIFICATION_STATS_FETCHED) { - $(this->rankLabel->text, setText, "—"); - $(this->fragsLabel->text, setText, "Sign in"); - $(this->deathsLabel->text, setText, "—"); - $(this->kdLabel->text, setText, "—"); - $(this->timeLabel->text, setText, "—"); - $(this->weaponsTable, reloadData); - return; - } + SDL_LockMutex(stats_fetch.lock); + const int32_t status = stats_fetch.status; + void *body = stats_fetch.body; + const size_t length = stats_fetch.length; + stats_fetch.body = NULL; + stats_fetch.length = 0; + SDL_UnlockMutex(stats_fetch.lock); - clearStats(this); + loadStats(this, status, body, length); - void *body = NULL; - size_t length = 0; + g_free(body); - char url[256]; - g_snprintf(url, sizeof(url), QUETOO_STATS_URL "/%s", guid_hashed); - - const int32_t status = cgi.HttpGet(url, &body, &length); - if (status == 200) { - Data *data = $$(Data, dataWithConstMemory, body, length); - StatsSummary summary = { 0 }; - (void) $$(JSONSerialization, instanceFromData, stats_summary_properties, data, &summary); - ident json = $$(JSONSerialization, objectFromData, data, 0); - release(data); - - if (json) { - Dictionary *dict = cast(Dictionary, json); - if (dict) { - this->rank = summary.rank; - this->frags = summary.frags; - this->deaths = summary.deaths; - this->time_played = summary.time_played; - - const Dictionary *nemesis = $(dict, objectForKeyPathWithClass, "nemesis", _Dictionary()); - if (nemesis) { - const String *name = $(nemesis, objectForKeyPathWithClass, "name", _String()); - if (name) { - g_strlcpy(this->nemesis, name->chars, sizeof(this->nemesis)); - } - } - - loadWeapons(this, $(dict, objectForKeyPathWithClass, "kills_by_weapon", _Array())); - - } else if (!cast(Null, json)) { - Cg_Warn("Unexpected stats response type: %s\n", classnameof(json)); - } - - updateTiles(this); - release(json); - } else { - this->rank = summary.rank; - this->frags = summary.frags; - this->deaths = summary.deaths; - this->time_played = summary.time_played; - updateTiles(this); + } else if (event->user.code == NOTIFICATION_GUID_HASHED) { + refresh(this); } - } else { - $(this->rankLabel->text, setText, "—"); - $(this->fragsLabel->text, setText, "Error"); - $(this->deathsLabel->text, setText, "—"); - $(this->kdLabel->text, setText, "—"); - $(this->timeLabel->text, setText, "—"); - Cg_Warn("Failed to fetch stats: HTTP %d\n", status); } - cgi.Free(body); + super(ViewController, self, respondToEvent, event); +} - $(this->weaponsTable, reloadData); +/** + * @see ViewController::viewWillAppear(ViewController *) + */ +static void viewWillAppear(ViewController *self) { + + super(ViewController, self, viewWillAppear); + + refresh((StatsViewController *) self); } #pragma mark - Class lifecycle @@ -301,6 +395,7 @@ static void viewWillAppear(ViewController *self) { */ static void initialize(Class *clazz) { ((ViewControllerInterface *) clazz->interface)->loadView = loadView; + ((ViewControllerInterface *) clazz->interface)->respondToEvent = respondToEvent; ((ViewControllerInterface *) clazz->interface)->viewWillAppear = viewWillAppear; } diff --git a/src/client/cl_cgame.c b/src/client/cl_cgame.c index fd5ad1bc4f..741512016e 100644 --- a/src/client/cl_cgame.c +++ b/src/client/cl_cgame.c @@ -182,6 +182,8 @@ void Cl_InitCgame(void) { import.HttpGetInstance = Net_HttpGetInstance; import.HttpGetInstances = Net_HttpGetInstances; import.HttpGetAsync = Net_HttpGetAsync; + import.HttpGetInstanceAsync = Net_HttpGetInstanceAsync; + import.HttpGetInstancesAsync = Net_HttpGetInstancesAsync; import.OpenFile = Fs_OpenRead; import.SeekFile = Fs_Seek; diff --git a/src/client/cl_input.c b/src/client/cl_input.c index be02710cff..a56e43bc8a 100644 --- a/src/client/cl_input.c +++ b/src/client/cl_input.c @@ -453,10 +453,14 @@ void Cl_HandleEvents(void) { if (SDL_PollEvent(&event)) { if (Cl_HandleSystemEvent(&event) == false) { - + + if (event.type == MVC_NOTIFICATION_EVENT && event.user.code == NOTIFICATION_GUID_HASHED) { + Cl_GuidHashedEvent(&event); + } + Ui_HandleEvent(&event); Cl_HandleEvent(&event); - + cls.cgame->HandleEvent(&event); } } else { diff --git a/src/client/cl_main.c b/src/client/cl_main.c index fb201bbce9..62a6871656 100644 --- a/src/client/cl_main.c +++ b/src/client/cl_main.c @@ -60,29 +60,59 @@ cvar_t *qport; cvar_t *cl_draw_net_messages; /** - * @brief Requests the server-side GUID hash once at startup and stores it in `guid_hashed`. + * @brief Net_HttpInstanceCallback for `Cl_InitGuidHash`. Runs on the HTTP session + * thread, so the parsed hash is marshaled to the main thread via an MVC notification. + */ +static void Cl_GuidHashComplete(int32_t status, void *instance, void *user_data) { + + char *guid_hashed = NULL; + + if (status == 200 && instance) { + const GuidHashResponse *response = instance; + if (response->guid[0]) { + guid_hashed = g_strdup(response->guid); + } else { + Com_Warn("GUID hash response missing guid field\n"); + } + } + + SDL_PushEvent(&(SDL_Event) { + .user.type = MVC_NOTIFICATION_EVENT, + .user.code = NOTIFICATION_GUID_HASHED, + .user.data1 = guid_hashed + }); +} + +/** + * @brief Applies the GUID hash from a `NOTIFICATION_GUID_HASHED` event on the main thread. + */ +void Cl_GuidHashedEvent(const SDL_Event *event) { + + char *guid_hashed = event->user.data1; + + if (guid_hashed) { + Cvar_ForceSetString("guid_hashed", guid_hashed); + g_free(guid_hashed); + } +} + +/** + * @brief Asynchronously requests the server-side GUID hash once at startup and stores + * it in `guid_hashed` when the response arrives. */ static void Cl_InitGuidHash(void) { + Cvar_ForceSetString("guid_hashed", ""); + if (!guid || !guid->string[0]) { - Cvar_ForceSetString("guid_hashed", ""); return; } - Cvar_ForceSetString("guid_hashed", ""); - char url[256]; g_snprintf(url, sizeof(url), QUETOO_GUID_URL "?guid=%s", guid->string); - GuidHashResponse response = { 0 }; - - if (Net_HttpGetInstance(url, guid_hash_properties, &response) == 200) { - if (response.guid[0]) { - Cvar_ForceSetString("guid_hashed", response.guid); - } else { - Com_Warn("GUID hash response missing guid field\n"); - } - } + Net_HttpGetInstanceAsync(url, guid_hash_properties, sizeof(GuidHashResponse), + Cl_GuidHashComplete, NULL); } cl_static_t cls; @@ -770,8 +800,6 @@ void Cl_Init(void) { Net_Config(NS_UDP_CLIENT, true); - Cl_InitGuidHash(); - S_Init(); R_Init(); @@ -784,6 +812,9 @@ void Cl_Init(void) { Cl_InitCgame(); + // after Ui_Init, so that MVC_NOTIFICATION_EVENT is registered before the completion fires + Cl_InitGuidHash(); + Cl_SetKeyDest(KEY_UI); Com_Print("Client initialized\n"); diff --git a/src/client/cl_main.h b/src/client/cl_main.h index 05d1240455..04ceb8bfc1 100644 --- a/src/client/cl_main.h +++ b/src/client/cl_main.h @@ -63,5 +63,6 @@ extern cl_static_t cls; void Cl_SendDisconnect(void); void Cl_Reconnect_f(void); void Cl_ClearState(void); +void Cl_GuidHashedEvent(const SDL_Event *event); #endif /* __CL_LOCAL_H__ */ diff --git a/src/client/cl_types.h b/src/client/cl_types.h index 1cb4574764..0f034c30cf 100644 --- a/src/client/cl_types.h +++ b/src/client/cl_types.h @@ -687,6 +687,9 @@ typedef enum { NOTIFICATION_SERVER_PARSED, NOTIFICATION_ENTITY_PARSED, NOTIFICATION_ENTITY_SELECTED, + NOTIFICATION_GUID_HASHED, + NOTIFICATION_LEADERBOARD_FETCHED, + NOTIFICATION_STATS_FETCHED, } cl_notification_t; /** diff --git a/src/net/net_http.c b/src/net/net_http.c index 55ec308cf5..8c4edfa39d 100644 --- a/src/net/net_http.c +++ b/src/net/net_http.c @@ -243,6 +243,158 @@ void Net_HttpGetAsync(const char *url_string, Net_HttpCallback callback, void *u $((URLSessionTask *) task, resume); } +typedef struct { + const JsonProperty *properties; + size_t size; + Net_HttpInstanceCallback callback; + void *user_data; +} Net_HttpGetInstanceAsync_State; + +/** + * @brief URLSessionTaskCompletion for `Net_HttpGetInstanceAsync`. + */ +static void Net_HttpGetInstanceAsync_Completion(URLSessionTask *task, bool success) { + + int32_t status = task->response->httpStatusCode; + + const char *url_string = task->request->url->urlString->chars; + + Com_Debug(DEBUG_NET, "%s: HTTP %d\n", url_string, status); + + Net_HttpGetInstanceAsync_State *state = (Net_HttpGetInstanceAsync_State *) task->data; + + void *instance = NULL; + + if (status == 200) { + const Data *data = ((URLSessionDataTask *) task)->data; + if (Net_HttpJsonFirstByte(data) == '{') { + instance = Mem_Malloc(state->size); + if ($$(JSONSerialization, instanceFromData, state->properties, data, instance) != 1) { + Com_Warn("%s: Failed to parse JSON object\n", url_string); + Mem_Free(instance); + instance = NULL; + status = 0; + } + } else { + Com_Warn("%s: Failed to parse JSON object\n", url_string); + status = 0; + } + } else if (status) { + Com_Warn("%s: HTTP %d\n", url_string, status); + } else { + Com_Warn("%s: HTTP request failed\n", url_string); + } + + state->callback(status, instance, state->user_data); + + Mem_Free(instance); + Mem_Free(state); + release(task); +} + +/** + * @brief Initiates an asynchronous HTTP `GET` request, deserializing a single JSON object. + */ +void Net_HttpGetInstanceAsync(const char *url_string, const JsonProperty *properties, + size_t size, Net_HttpInstanceCallback callback, void *user_data) { + + assert(url_string); + assert(properties); + assert(callback); + + Com_Debug(DEBUG_NET, "%s\n", url_string); + + URL *url = $(alloc(URL), initWithCharacters, url_string); + URLSession *session = Net_HttpSession(); + URLSessionDataTask *task = $(session, dataTaskWithURL, url, Net_HttpGetInstanceAsync_Completion); + release(url); + + Net_HttpGetInstanceAsync_State *state = Mem_Malloc(sizeof(Net_HttpGetInstanceAsync_State)); + state->properties = properties; + state->size = size; + state->callback = callback; + state->user_data = user_data; + task->urlSessionTask.data = state; + + $((URLSessionTask *) task, resume); +} + +typedef struct { + const JsonProperty *properties; + size_t stride; + size_t count; + Net_HttpInstancesCallback callback; + void *user_data; +} Net_HttpGetInstancesAsync_State; + +/** + * @brief URLSessionTaskCompletion for `Net_HttpGetInstancesAsync`. + */ +static void Net_HttpGetInstancesAsync_Completion(URLSessionTask *task, bool success) { + + int32_t status = task->response->httpStatusCode; + + const char *url_string = task->request->url->urlString->chars; + + Com_Debug(DEBUG_NET, "%s: HTTP %d\n", url_string, status); + + Net_HttpGetInstancesAsync_State *state = (Net_HttpGetInstancesAsync_State *) task->data; + + void *instances = NULL; + size_t instances_count = 0; + + if (status == 200) { + const Data *data = ((URLSessionDataTask *) task)->data; + if (Net_HttpJsonFirstByte(data) == '[') { + instances = Mem_Malloc(state->stride * state->count); + instances_count = $$(JSONSerialization, instancesFromData, state->properties, data, + instances, state->stride, state->count); + } else { + Com_Warn("%s: Failed to parse JSON array\n", url_string); + status = 0; + } + } else if (status) { + Com_Warn("%s: HTTP %d\n", url_string, status); + } else { + Com_Warn("%s: HTTP request failed\n", url_string); + } + + state->callback(status, instances, instances_count, state->user_data); + + Mem_Free(instances); + Mem_Free(state); + release(task); +} + +/** + * @brief Initiates an asynchronous HTTP `GET` request, deserializing a JSON array. + */ +void Net_HttpGetInstancesAsync(const char *url_string, const JsonProperty *properties, + size_t stride, size_t count, + Net_HttpInstancesCallback callback, void *user_data) { + + assert(url_string); + assert(properties); + assert(callback); + + Com_Debug(DEBUG_NET, "%s\n", url_string); + + URL *url = $(alloc(URL), initWithCharacters, url_string); + URLSession *session = Net_HttpSession(); + URLSessionDataTask *task = $(session, dataTaskWithURL, url, Net_HttpGetInstancesAsync_Completion); + release(url); + + Net_HttpGetInstancesAsync_State *state = Mem_Malloc(sizeof(Net_HttpGetInstancesAsync_State)); + state->properties = properties; + state->stride = stride; + state->count = count; + state->callback = callback; + state->user_data = user_data; + task->urlSessionTask.data = state; + + $((URLSessionTask *) task, resume); +} + typedef struct { Net_HttpCallback callback; void *user_data; diff --git a/src/net/net_http.h b/src/net/net_http.h index 80e0a4c20e..a66e25173a 100644 --- a/src/net/net_http.h +++ b/src/net/net_http.h @@ -34,6 +34,25 @@ typedef struct JsonProperty JsonProperty; */ typedef void (*Net_HttpCallback)(int32_t status, void *body, size_t length, void *user_data); +/** + * @brief The asynchronous HTTP JSON object callback type. + * @param status The HTTP response code, or `0` if the response could not be parsed. + * @param instance The parsed struct instance, or `NULL` on failure. Valid only for the + * duration of the callback. + * @param user_data The user data pointer passed to `Net_HttpGetInstanceAsync`. + */ +typedef void (*Net_HttpInstanceCallback)(int32_t status, void *instance, void *user_data); + +/** + * @brief The asynchronous HTTP JSON array callback type. + * @param status The HTTP response code, or `0` if the response could not be parsed. + * @param instances The parsed struct instances, or `NULL` on failure. Valid only for the + * duration of the callback. + * @param count The number of parsed instances. + * @param user_data The user data pointer passed to `Net_HttpGetInstancesAsync`. + */ +typedef void (*Net_HttpInstancesCallback)(int32_t status, void *instances, size_t count, void *user_data); + /** * @brief Synchronously `GET` the specified URL string. * @param url_string The URL string to `GET`. @@ -78,6 +97,34 @@ void Net_HttpClearCache(void); */ void Net_HttpGetAsync(const char *url_string, Net_HttpCallback callback, void *user_data); +/** + * @brief Asynchronously `GET` the specified URL string and deserialize a single JSON object. + * @details The callback runs on the HTTP session thread; marshal results to the main + * thread (e.g. via `SDL_PushEvent`) before touching game or UI state. + * @param url_string The URL string to `GET`. + * @param properties The JsonProperty descriptors for the destination struct. + * @param size The size of the destination struct. + * @param callback The completion callback. + * @param user_data User data pointer passed through to the callback. + */ +void Net_HttpGetInstanceAsync(const char *url_string, const JsonProperty *properties, + size_t size, Net_HttpInstanceCallback callback, void *user_data); + +/** + * @brief Asynchronously `GET` the specified URL string and deserialize a JSON array. + * @details The callback runs on the HTTP session thread; marshal results to the main + * thread (e.g. via `SDL_PushEvent`) before touching game or UI state. + * @param url_string The URL string to `GET`. + * @param properties The JsonProperty descriptors for the destination struct array. + * @param stride The byte distance between consecutive structs. + * @param count The capacity of the destination array. + * @param callback The completion callback. + * @param user_data User data pointer passed through to the callback. + */ +void Net_HttpGetInstancesAsync(const char *url_string, const JsonProperty *properties, + size_t stride, size_t count, + Net_HttpInstancesCallback callback, void *user_data); + /** * @brief Asynchronously `POST` data to the specified URL string. * @param url_string The URL string to `POST` to. diff --git a/src/tests/check_http.c b/src/tests/check_http.c index 40cac84e67..27cf6cacb3 100644 --- a/src/tests/check_http.c +++ b/src/tests/check_http.c @@ -471,6 +471,148 @@ START_TEST(check_Net_HttpGetInstances_json) { } END_TEST +typedef struct { + SDL_AtomicInt complete; + int32_t status; + http_instance_t instance; + bool had_instance; +} http_instance_async_t; + +static void http_instance_async_callback(int32_t status, void *instance, void *user_data) { + http_instance_async_t *ctx = user_data; + + ctx->status = status; + if (instance) { + ctx->instance = *(http_instance_t *) instance; + ctx->had_instance = true; + } + + SDL_SetAtomicInt(&ctx->complete, 1); +} + +START_TEST(check_Net_HttpGetInstanceAsync_json) { + + Net_Init(); + + const in_port_t port = 39984; + + const int32_t listen_sock = Net_SocketListen(NULL, port, 1); + ck_assert_msg(listen_sock >= 0, "Net_SocketListen failed on port %d", port); + + const char payload[] = "{\"guid\":\"abc123\",\"rank\":7}"; + + http_server_t server = { + .listen_sock = listen_sock, + .port = port, + .payload = payload, + .payload_len = sizeof(payload) - 1, + .ok = false, + }; + + SDL_Thread *thread = SDL_CreateThread(http_server_thread, "http_server", &server); + ck_assert_msg(thread != NULL, "SDL_CreateThread failed"); + + char url[128]; + g_snprintf(url, sizeof(url), "http://127.0.0.1:%d/test.json", port); + + http_instance_async_t ctx = { 0 }; + Net_HttpGetInstanceAsync(url, http_instance_properties, sizeof(http_instance_t), + http_instance_async_callback, &ctx); + + for (int32_t i = 0; !SDL_GetAtomicInt(&ctx.complete); i++) { + ck_assert_msg(i < 500, "Timed out awaiting async completion"); + SDL_Delay(10); + } + + ck_assert_int_eq(ctx.status, 200); + ck_assert(ctx.had_instance); + ck_assert_str_eq(ctx.instance.guid, "abc123"); + ck_assert_int_eq(ctx.instance.rank, 7); + + int thread_status; + SDL_WaitThread(thread, &thread_status); + ck_assert_int_eq(thread_status, 0); + ck_assert(server.ok); + ck_assert_str_eq(server.parsed_method, "GET"); + ck_assert_str_eq(server.parsed_path, "test.json"); + + Net_CloseSocket(listen_sock); + Net_Shutdown(); + +} END_TEST + +typedef struct { + SDL_AtomicInt complete; + int32_t status; + http_item_t items[2]; + size_t num_items; +} http_items_async_t; + +static void http_items_async_callback(int32_t status, void *instances, size_t count, void *user_data) { + http_items_async_t *ctx = user_data; + + ctx->status = status; + ctx->num_items = count < 2 ? count : 2; + if (instances && ctx->num_items) { + memcpy(ctx->items, instances, ctx->num_items * sizeof(http_item_t)); + } + + SDL_SetAtomicInt(&ctx->complete, 1); +} + +START_TEST(check_Net_HttpGetInstancesAsync_json) { + + Net_Init(); + + const in_port_t port = 39985; + + const int32_t listen_sock = Net_SocketListen(NULL, port, 1); + ck_assert_msg(listen_sock >= 0, "Net_SocketListen failed on port %d", port); + + const char payload[] = "[{\"name\":\"one\",\"value\":1},{\"name\":\"two\",\"value\":2}]"; + + http_server_t server = { + .listen_sock = listen_sock, + .port = port, + .payload = payload, + .payload_len = sizeof(payload) - 1, + .ok = false, + }; + + SDL_Thread *thread = SDL_CreateThread(http_server_thread, "http_server", &server); + ck_assert_msg(thread != NULL, "SDL_CreateThread failed"); + + char url[128]; + g_snprintf(url, sizeof(url), "http://127.0.0.1:%d/test.json", port); + + http_items_async_t ctx = { 0 }; + Net_HttpGetInstancesAsync(url, http_item_properties, sizeof(http_item_t), 2, + http_items_async_callback, &ctx); + + for (int32_t i = 0; !SDL_GetAtomicInt(&ctx.complete); i++) { + ck_assert_msg(i < 500, "Timed out awaiting async completion"); + SDL_Delay(10); + } + + ck_assert_int_eq(ctx.status, 200); + ck_assert_uint_eq(ctx.num_items, 2); + ck_assert_str_eq(ctx.items[0].name, "one"); + ck_assert_int_eq(ctx.items[0].value, 1); + ck_assert_str_eq(ctx.items[1].name, "two"); + ck_assert_int_eq(ctx.items[1].value, 2); + + int thread_status; + SDL_WaitThread(thread, &thread_status); + ck_assert_int_eq(thread_status, 0); + ck_assert(server.ok); + ck_assert_str_eq(server.parsed_method, "GET"); + ck_assert_str_eq(server.parsed_path, "test.json"); + + Net_CloseSocket(listen_sock); + Net_Shutdown(); + +} END_TEST + /** * @brief Test entry point. */ @@ -508,6 +650,8 @@ int32_t main(int32_t argc, char **argv) { tcase_add_test(tcase, check_Net_Http_roundtrip); tcase_add_test(tcase, check_Net_HttpGetInstance_json); tcase_add_test(tcase, check_Net_HttpGetInstances_json); + tcase_add_test(tcase, check_Net_HttpGetInstanceAsync_json); + tcase_add_test(tcase, check_Net_HttpGetInstancesAsync_json); suite_add_tcase(suite, tcase); // Run with CK_NOFORK because Net_HttpGet uses libcurl, which is not fork-safe