Repository navigation
fix(gateway): wake WebChat writes on the lws service thread - #128
Conversation
lws_callback_on_writable updates the poll set without the per-thread lock. Replies and provider-status broadcasts called it from the agent thread while lws_service ran on the gateway thread, racing pollfd updates and a closing wsi. Queue the frame, cancel the service wait, and mark writable only from EVENT_WAIT_CANCELLED. Supersedes #127.
There was a problem hiding this comment.
Summary
This moves WebChat wakeups off the agent thread: ws_send_to and ws_broadcast_text enqueue, call lws_cancel_service, and lws_callback_on_writable runs only from LWS_CALLBACK_EVENT_WAIT_CANCELLED on the service thread. The approach is correct for libwebsockets (lws_cancel_service is the thread-safe wake, and the cancel event reaches the single ws protocol once per cancel). The #119 drop-on-close behavior is preserved. I ran make test_ws LDLIBS=-lm locally and all tests pass (the stock target needs -lcurl, which this image lacks). No blockers, but the shutdown guard does not fully close the race it claims to.
Must Fix
None.
Should Fix
src/gateway/ws.cws_wake_service(lines 93-102) readsg_lws_ctxunder the mutex, unlocks, then callslws_cancel_service(ctx). The comment inhttp.csays thews_set_context(NULL)before destroy stops a late send from cancelling a freed context, but a sender that copied the pointer just beforehttp_stopclears it still callslws_cancel_serviceon a destroyed context.lws_cancel_serviceonly writes to the wake fd and never takesg_mutex, so calling it while holdingg_mutexis safe and makes the NULL-out an actual barrier (ws_on_service_canceltakes the mutex on the service thread, not insidelws_cancel_service, so there is no inversion). In practice senders are the main thread andhttp_stopruns from cleanup, so the window is narrow, but the code claims to close it.src/gateway/http.clines 143-146:ws_set_context(ctx->lws_ctx)runs beforepthread_create. Ifpthread_createfails, the context is destroyed withoutws_set_context(NULL), leaving a danglingg_lws_ctx. Clear it on that failure path, or movews_set_contextafter a successfulpthread_create.- Test gap: all new tests use a stub
lws_cancel_service/lws_callback_on_writableinSHELLCLAW_WS_TEST, so the claim that the realEVENT_WAIT_CANCELLEDpath flushes frames is not exercised.test_gateway_httphas no WebSocket send round trip. A GATEWAY=1 test that connects a WS client, callsws_send_to/ws_broadcast_textfrom the main thread and reads the frame would lock the actual fix. Also missing: a test that a conn with no pending frames gets no writable call when another conn does (the secondws_on_service_cancelintest_send_wakes_service_threadonly covers the drained case), and a test that a dropped send (no livewsi) does not cancel.
Nice to Have
src/gateway/ws.cline 408 (ws_enqueue_outgoing_locked(m);) andtests/test_ws.cline 414 (RUN(test_unregister_drops_pending_outgoing());) lost their indentation in the rebase. Restore the tab so formatting andgit blamestay clean.ws_on_service_cancelis O(conns x queue) under the mutex (16 x 64 max), fine today. IfMAX_QUEUEgrows, a per-conn pending flag set at enqueue would avoid the nested scan.ws.hws_send_todocs could state that delivery is asynchronous via the service thread, in addition to the existing note about return 0 on a dropped send.- The PR targets
development;AGENTS.mdsaysmainis the active line. Fine if intentional, just confirm.
Positive Highlights
- The fix addresses the real cause (poll-set update without the per-thread lock) instead of adding locking around
lws_callback_on_writable, and thews.hcomment explains why the call is thread-restricted. - Broadcast now wakes once instead of once per connection, and
ws_on_service_cancelcopieswsipointers out under the lock and calls lws after unlocking, so no lws call happens withg_mutexheld. Unregister runs on the same service thread, so those pointers cannot go stale. CHANGELOG.mdis updated and the tests assert both counters (cancel on the sender side, writable only after the hook), which pins the thread contract.
Sent by Cursor Automation: Code review
| ctx = g_lws_ctx; | ||
| pthread_mutex_unlock(&g_mutex); | ||
| if (ctx) | ||
| lws_cancel_service(ctx); |
There was a problem hiding this comment.
Should Fix: ctx is copied under the mutex but used after unlock. http_stop can clear it and lws_context_destroy between the copy and this call, so the NULL-out in http.c is not a barrier. Call lws_cancel_service(ctx) while still holding g_mutex (it only writes the wake fd, no re-entry into g_mutex).
| ws_enqueue_outgoing_locked(m); | ||
| if (wake_count < MAX_CONNECTIONS) | ||
| wake_list[wake_count++] = g_conns[i].wsi; | ||
| ws_enqueue_outgoing_locked(m); |
There was a problem hiding this comment.
Nit: lost its leading tab in the rebase.
| if (g_ctx->lws_ctx) { | ||
| /* Drop the pointer before destroy so a late send cannot | ||
| * lws_cancel_service a freed context. */ | ||
| ws_set_context(NULL); |
There was a problem hiding this comment.
Should Fix: the pthread_create failure path in http_start (around line 146) destroys the lws context without ws_set_context(NULL), leaving g_lws_ctx dangling. Clear it there too.
# Conflicts: # CHANGELOG.md


Summary
Consolidates the cursor critical-bug fix originally in
cursor/critical-bug-identification-2a2f(PR #127) as an operator-authored commit.lws_callback_on_writableupdates the poll set without the per-thread lock; replies and provider-status broadcasts called it from the agent thread whilelws_serviceran on the gateway thread, racing pollfd updates against a closingwsi. Frames are now queued, the service wait is cancelled, and writable is marked only fromEVENT_WAIT_CANCELLED.Rebased on top of #119 (merged): the shared chunk of
ws_send_to/ws_broadcast_textwas fused manually so the closed-socket drops from #119 and the service-thread wake both hold;test_wsruns all regression sets from both fixes.Testing
make test_ws— all tests passed (drops + wake tests together)CI=true make test— all 45 test binaries passedmake static— passedSupersedes #127.