Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to ShellClaw are documented here. Format follows [Keep a Cha
## [Unreleased]

### Fixed
- WebChat writes are moved off the agent thread: frames are queued and the lws service thread is woken (`lws_cancel_service`), marking writable only from `EVENT_WAIT_CANCELLED`, which stops the poll-set race against a closing socket.
- Sandboxed shell commands no longer inherit the agent environment: the command process keeps only `PATH`, locale (`LANG`, `LC_ALL`, `LC_CTYPE`), `TERM`, `TZ`, and `HOME` (set to the workspace), so a prompt-injected `printenv` cannot read provider API keys or bot tokens. `LD_*`, `CUDA_*`, and proxy variables are dropped before `exec`; see the environment model in `docs/SECURITY.md` (#109).
- The shell command timeout is enforced against a monotonic wall clock on both the sandboxed and the unsandboxed paths, so a command that keeps stdout trickling can no longer outlive `shell_timeout_sec` and hold the agent lock until the output buffer fills (#116).
- Telegram `getUpdates` requests a single update per poll and skips a message without a numeric sender id, so a backlog larger than the 256 KiB read buffer (or a channel/anonymous post) no longer pins the poll offset and stalls the inbox.
Expand Down
3 changes: 3 additions & 0 deletions src/gateway/http.c
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,9 @@ void http_stop(void)
g_ctx->thread = 0;
}
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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

lws_context_destroy(g_ctx->lws_ctx);
g_ctx->lws_ctx = NULL;
}
Expand Down
4 changes: 4 additions & 0 deletions src/gateway/http_lws.c
Original file line number Diff line number Diff line change
Expand Up @@ -488,6 +488,10 @@ int ws_callback(struct lws *wsi, enum lws_callback_reasons reason, void *user,
if (conn_id > 0) ws_unregister_conn(conn_id);
break;
}
case LWS_CALLBACK_EVENT_WAIT_CANCELLED:
/* wsi here is not a client socket. Mark real conns from our table. */
ws_on_service_cancel();
break;
default:
break;
}
Expand Down
83 changes: 72 additions & 11 deletions src/gateway/ws.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,31 @@
#ifdef SHELLCLAW_WS_TEST
struct lws;
struct lws_context;
static void lws_callback_on_writable(struct lws *wsi) { (void)wsi; }
static int g_ws_test_writable_count;
static int g_ws_test_cancel_count;
static void lws_callback_on_writable(struct lws *wsi)
{
(void)wsi;
g_ws_test_writable_count++;
}
static void lws_cancel_service(struct lws_context *ctx)
{
(void)ctx;
g_ws_test_cancel_count++;
}
int ws_test_writable_count(void)
{
return g_ws_test_writable_count;
}
int ws_test_cancel_count(void)
{
return g_ws_test_cancel_count;
}
void ws_test_reset_wake_counts(void)
{
g_ws_test_writable_count = 0;
g_ws_test_cancel_count = 0;
}
#else
#include "gateway/lws_compat.h"
#include <libwebsockets.h>
Expand Down Expand Up @@ -59,7 +83,48 @@ static struct lws_context *g_lws_ctx;

void ws_set_context(void *ctx)
{
pthread_mutex_lock(&g_mutex);
g_lws_ctx = (struct lws_context *)ctx;
pthread_mutex_unlock(&g_mutex);
}

/* Cross-thread wake. lws_cancel_service is the call that is safe off the
* service thread; the writable mark happens in ws_on_service_cancel. */
static void ws_wake_service(void)
{
struct lws_context *ctx;

pthread_mutex_lock(&g_mutex);
ctx = g_lws_ctx;
pthread_mutex_unlock(&g_mutex);
if (ctx)
lws_cancel_service(ctx);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

}

void ws_on_service_cancel(void)
{
struct lws *wake[MAX_CONNECTIONS];
int n = 0;
int i;

pthread_mutex_lock(&g_mutex);
for (i = 0; i < MAX_CONNECTIONS; i++) {
ws_msg_t *msg;
int conn_id;

if (!g_conns[i].wsi)
continue;
conn_id = g_conns[i].conn_id;
for (msg = g_outgoing_head; msg; msg = msg->next) {
if (msg->conn_id != conn_id)
continue;
wake[n++] = (struct lws *)g_conns[i].wsi;
break;
}
}
pthread_mutex_unlock(&g_mutex);
for (i = 0; i < n; i++)
lws_callback_on_writable(wake[i]);
}

int ws_next_conn_id(void)
Expand Down Expand Up @@ -321,15 +386,14 @@ int ws_send_to(const char *session_id, const char *text)
}
ws_enqueue_outgoing_locked(m);
pthread_mutex_unlock(&g_mutex);
lws_callback_on_writable((struct lws *)wsi);
ws_wake_service();
return 0;
}

void ws_broadcast_text(const char *text)
{
int i;
int wake_count = 0;
ws_conn_t wake_list[MAX_CONNECTIONS];
int queued = 0;
if (!ws_text_len_ok(text)) return;
pthread_mutex_lock(&g_mutex);
for (i = 0; i < MAX_CONNECTIONS; i++) {
Expand All @@ -341,15 +405,12 @@ void ws_broadcast_text(const char *text)
m = ws_msg_new(g_conns[i].conn_id, text);
if (!m)
break;
ws_enqueue_outgoing_locked(m);
if (wake_count < MAX_CONNECTIONS)
wake_list[wake_count++] = g_conns[i].wsi;
ws_enqueue_outgoing_locked(m);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: lost its leading tab in the rebase.

queued = 1;
}
pthread_mutex_unlock(&g_mutex);
for (i = 0; i < wake_count; i++) {
if (wake_list[i])
lws_callback_on_writable((struct lws *)wake_list[i]);
}
if (queued)
ws_wake_service();
}

void ws_shutdown_signal(void)
Expand Down
11 changes: 11 additions & 0 deletions src/gateway/ws.h
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,17 @@ int ws_dequeue_outgoing(int conn_id, char *buf, size_t buf_size, size_t *len_out
*/
int ws_has_pending_outgoing(int conn_id);

/**
* Ask the service thread to flush queued WebChat frames.
* Call only from the libwebsockets thread, in
* LWS_CALLBACK_EVENT_WAIT_CANCELLED. lws_callback_on_writable() is not
* safe from the agent thread: libwebsockets 4.3 updates the poll set
* without the per-thread lock.
*
* Example: `case LWS_CALLBACK_EVENT_WAIT_CANCELLED: ws_on_service_cancel(); break;`
*/
void ws_on_service_cancel(void);

/**
* Get next connection ID for new connections.
*
Expand Down
67 changes: 66 additions & 1 deletion tests/test_ws.c
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,68 @@ static int test_pop_incoming_invalid_args(void)
return 0;
}

/* Counters live in the SHELLCLAW_WS_TEST build of ws.c. */
extern int ws_test_writable_count(void);
extern int ws_test_cancel_count(void);
extern void ws_test_reset_wake_counts(void);

/**
* Agent replies run on the main thread while lws_service runs on the gateway
* thread. The wake must be lws_cancel_service, and only the service-thread
* hook may call lws_callback_on_writable.
*/
static int test_send_wakes_service_thread(void)
{
char buf[32];
size_t len;

ws_cleanup();
ws_test_reset_wake_counts();
ws_set_context((void *)(intptr_t)1);
ASSERT(ws_register_conn(11, (ws_conn_t)(intptr_t)11) == 0);
ASSERT(ws_send_to("webchat:11", "hello") == 0);
ASSERT(ws_test_cancel_count() == 1);
ASSERT(ws_test_writable_count() == 0);
ws_on_service_cancel();
ASSERT(ws_test_writable_count() == 1);
ASSERT(ws_dequeue_outgoing(11, buf, sizeof(buf), &len) == 1);
ASSERT(strcmp(buf, "hello") == 0);
ws_on_service_cancel();
ASSERT(ws_test_writable_count() == 1);
ws_set_context(NULL);
ws_cleanup();
return 0;
}

static int test_broadcast_wakes_once(void)
{
ws_cleanup();
ws_test_reset_wake_counts();
ws_set_context((void *)(intptr_t)1);
ASSERT(ws_register_conn(12, (ws_conn_t)(intptr_t)12) == 0);
ASSERT(ws_register_conn(13, (ws_conn_t)(intptr_t)13) == 0);
ws_broadcast_text("status");
ASSERT(ws_test_cancel_count() == 1);
ASSERT(ws_test_writable_count() == 0);
ws_on_service_cancel();
ASSERT(ws_test_writable_count() == 2);
ws_set_context(NULL);
ws_cleanup();
return 0;
}

static int test_send_without_context_does_not_touch_lws(void)
{
ws_cleanup();
ws_test_reset_wake_counts();
ASSERT(ws_register_conn(14, (ws_conn_t)(intptr_t)14) == 0);
ASSERT(ws_send_to("webchat:14", "queued") == 0);
ASSERT(ws_test_cancel_count() == 0);
ASSERT(ws_test_writable_count() == 0);
ws_cleanup();
return 0;
}

static int test_shutdown_stops_pop(void)
{
char session[32];
Expand Down Expand Up @@ -349,8 +411,11 @@ int main(void)
RUN(test_push_incoming_rejects_empty());
RUN(test_pop_incoming_invalid_args());
RUN(test_shutdown_stops_pop());
RUN(test_unregister_drops_pending_outgoing());
RUN(test_unregister_drops_pending_outgoing());
RUN(test_send_to_absent_conn_does_not_fill_queue());
RUN(test_send_wakes_service_thread());
RUN(test_broadcast_wakes_once());
RUN(test_send_without_context_does_not_touch_lws());
printf("test_ws: all tests passed\n");
return 0;
}
Loading