From 045ca0929a83501987584df78894269cde93ac2a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 27 Sep 2026 11:10:12 +0000 Subject: [PATCH] fix(gateway): hold the agent lock when deleting a session DELETE /api/sessions ran on the HTTP thread while agent_run still held the loaded transcript. session_save then recreated the row, so a dashboard delete during a reply restored the conversation. dispatch_delete_session takes the same mutex /reset already uses. Co-authored-by: esadrianno --- Makefile | 2 +- src/core/agent.h | 1 + src/core/dispatch.c | 12 ++++++++++++ src/core/dispatch.h | 14 ++++++++++++++ src/gateway/routes.c | 3 ++- tests/test_dispatch.c | 24 ++++++++++++++++++++++++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index f40f391..5b06385 100644 --- a/Makefile +++ b/Makefile @@ -372,7 +372,7 @@ $(HTTP_O): src/gateway/http.c src/gateway/http.h src/gateway/http_lws.h src/gate $(HTTP_LWS_O): src/gateway/http_lws.c src/gateway/http_lws.h src/gateway/asap_http_body.h src/gateway/routes.h src/gateway/auth.h src/gateway/static.h src/gateway/ws.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/http_lws.c -$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/routes_hardware.h src/gateway/http.h src/gateway/http_lws.h src/gateway/auth.h src/gateway/rate_limit.h src/tools/context.h src/asap/manifest.h src/asap/envelope.h src/asap/server.h src/asap/log.h src/core/bootstrap.h src/core/agent.h src/core/config.h src/core/config_patch.h src/core/reload.h src/core/memory.h src/core/skill.h src/providers/provider.h src/channels/channel.h src/tools/cron.h src/tools/tool.h +$(ROUTES_O): src/gateway/routes.c src/gateway/routes.h src/gateway/routes_hardware.h src/gateway/http.h src/gateway/http_lws.h src/gateway/auth.h src/gateway/rate_limit.h src/tools/context.h src/asap/manifest.h src/asap/envelope.h src/asap/server.h src/asap/log.h src/core/bootstrap.h src/core/agent.h src/core/config.h src/core/config_patch.h src/core/dispatch.h src/core/reload.h src/core/memory.h src/core/skill.h src/providers/provider.h src/channels/channel.h src/tools/cron.h src/tools/tool.h $(CC) $(CFLAGS) $(INC) $(GATEWAY_CFLAGS) -pthread -c -o $@ src/gateway/routes.c $(ROUTES_HARDWARE_O): src/gateway/routes_hardware.c src/gateway/routes_hardware.h src/gateway/routes.h src/gateway/http_lws.h src/gateway/uri_match.h src/hardware/hardware.h src/hardware/hardware_gpio_snapshot.h src/hardware/hardware_tegrastats.h src/hardware/board_detect.h src/core/config.h diff --git a/src/core/agent.h b/src/core/agent.h index c530aef..e1c79da 100644 --- a/src/core/agent.h +++ b/src/core/agent.h @@ -54,6 +54,7 @@ int agent_run(const config_t *cfg, const char *session_id, const char *user_mess * Every agent_run() caller (main-loop handle_message, inbound ASAP HTTP, * WebSocket dispatcher) must hold this mutex for the duration of agent_run(). * /reset in handle_message must also hold it around session_delete(). + * Dashboard DELETE /api/sessions/:id must use dispatch_delete_session(). * After a successful cron ch->send, handle_message re-takes it around * cron_ack_delivery() (SQLite amalgamation is SQLITE_THREADSAFE=0). * Inbound mcp.tool_call must hold it around tool execute (see #60). diff --git a/src/core/dispatch.c b/src/core/dispatch.c index 44b1479..68d2427 100644 --- a/src/core/dispatch.c +++ b/src/core/dispatch.c @@ -43,6 +43,18 @@ static int send_then_maybe_ack_cron(const channel_t *ch, const channel_incoming_ return maybe_ack_cron(ch, msg); } +int dispatch_delete_session(const char *session_id) +{ + int rc; + if (!session_id || session_id[0] == '\0') + return -1; + /* Serialize with agent_run so session_save cannot recreate the row. */ + agent_lock(); + rc = session_delete(session_id); + agent_unlock(); + return rc; +} + int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg) { const char *text = msg->text ? msg->text : ""; diff --git a/src/core/dispatch.h b/src/core/dispatch.h index 36ac069..e94bb48 100644 --- a/src/core/dispatch.h +++ b/src/core/dispatch.h @@ -18,6 +18,20 @@ extern "C" { */ int handle_message(const channel_t *ch, const channel_incoming_msg_t *msg); +/** + * Delete one session while holding the agent mutex. + * + * Dashboard `DELETE /api/sessions/:id` runs on the gateway thread. agent_run() + * loads history, talks to the model, then session_save()s. Without this lock + * the delete lands in that window and the save puts the old transcript back. + * + * Example: `if (dispatch_delete_session(id) != 0) respond 404;` + * + * @param session_id Session row id. Empty or NULL is rejected. + * @return 0 when a row was removed, non-zero otherwise. + */ +int dispatch_delete_session(const char *session_id); + #ifdef __cplusplus } #endif diff --git a/src/gateway/routes.c b/src/gateway/routes.c index 39c24a8..c95ea2c 100644 --- a/src/gateway/routes.c +++ b/src/gateway/routes.c @@ -17,6 +17,7 @@ #include "asap/log.h" #include "core/agent.h" #include "core/bootstrap.h" +#include "core/dispatch.h" #include "core/config.h" #include "core/config_patch.h" #include "core/memory.h" @@ -500,7 +501,7 @@ static void handle_sessions_list(char *buf, size_t size, int *status) static void handle_session_delete(const char *id, char *buf, size_t size, int *status) { - if (session_delete(id) != 0) { + if (dispatch_delete_session(id) != 0) { json_error(buf, size, status, 404, "Session not found"); return; } diff --git a/tests/test_dispatch.c b/tests/test_dispatch.c index 2478f42..4286bac 100644 --- a/tests/test_dispatch.c +++ b/tests/test_dispatch.c @@ -468,8 +468,32 @@ static int test_handle_message_holds_agent_mutex(void) return 0; } +static int test_api_session_delete_holds_agent_mutex(void) +{ + const char *db_path = "build/test_dispatch_api_delete.db"; + char history[128]; + + memory_cleanup(); + remove(db_path); + ASSERT(memory_init(db_path) == 0); + ASSERT(session_save("webchat:9", "[{\"role\":\"user\",\"content\":\"secret\"}]") == 0); + g_agent_mutex_held_during_reset = 0; + session_delete_set_hook_for_test(reset_lock_probe); + ASSERT(dispatch_delete_session("webchat:9") == 0); + session_delete_set_hook_for_test(NULL); + ASSERT(g_agent_mutex_held_during_reset == 1); + ASSERT(agent_mutex_is_locked_for_test() == 0); + ASSERT(session_load("webchat:9", history, sizeof(history)) != 0); + ASSERT(dispatch_delete_session("") != 0); + ASSERT(dispatch_delete_session(NULL) != 0); + memory_cleanup(); + remove(db_path); + return 0; +} + int main(void) { + RUN(test_api_session_delete_holds_agent_mutex()); RUN(test_reset_clears_session()); RUN(test_status_returns_version()); RUN(test_agent_failure_fallback_message());