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
4 changes: 3 additions & 1 deletion assistant/src/runtime/assistant-event-hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,9 @@ export class AssistantEventHub {
for (const entry of snapshot) {
if (!entry.active) continue;
if (entry.filter.assistantId !== event.assistantId) continue;
if (entry.filter.sessionId != null && entry.filter.sessionId !== event.sessionId) continue;
// System events (no sessionId) match all subscribers; scoped events
// must match the subscriber's sessionId filter when present.
if (event.sessionId != null && entry.filter.sessionId != null && entry.filter.sessionId !== event.sessionId) continue;
try {
await entry.callback(event);
} catch (err) {
Expand Down
13 changes: 12 additions & 1 deletion assistant/src/runtime/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ import { handleSubscribeAssistantEvents } from './routes/events-routes.js';
import { consumeCallback, consumeCallbackError } from '../security/oauth-callback-registry.js';
import { PairingStore } from '../daemon/pairing-store.js';
import type { ServerMessage } from '../daemon/ipc-contract.js';
import { assistantEventHub } from './assistant-event-hub.js';
import { buildAssistantEvent } from './assistant-event.js';

// Middleware
import {
Expand Down Expand Up @@ -183,10 +185,19 @@ export class RuntimeHttpServer {
}

private get pairingContext(): PairingHandlerContext {
const ipcBroadcast = this.pairingBroadcast;
return {
pairingStore: this.pairingStore,
bearerToken: this.bearerToken,
pairingBroadcast: this.pairingBroadcast,
pairingBroadcast: ipcBroadcast
? (msg) => {
// Broadcast to IPC socket clients (local Unix socket)
ipcBroadcast(msg);
// Also publish to the event hub so HTTP/SSE clients (e.g. macOS
// app with localHttpEnabled) receive pairing approval requests.
void assistantEventHub.publish(buildAssistantEvent('self', msg));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Unhandled promise rejection from void assistantEventHub.publish(...) can crash the process

The pairing event hub publish at http-server.ts:198 uses void to fire-and-forget the async publish() call. If any SSE subscriber callback throws during fanout, AssistantEventHub.publish() rejects with an AggregateError (assistant-event-hub.ts:136-138). Because the returned promise is discarded via void, this rejection is unhandled and will crash the Bun process.

Root cause and comparison with IPC path

The IPC path in assistant/src/daemon/ipc-handler.ts:72-76 handles this correctly by chaining .catch():

this._hubChain = this._hubChain
  .then(() => assistantEventHub.publish(event))
  .catch((err) => {
    log.warn({ err }, 'assistant-events hub subscriber threw during IPC send');
  });

The new code at http-server.ts:198 does:

void assistantEventHub.publish(buildAssistantEvent('self', msg));

The void operator evaluates the expression and returns undefined, but does not attach a rejection handler to the promise. If any SSE subscriber's callback throws (e.g., controller.enqueue() fails because the stream is in an error state), publish() collects those errors and rejects with AggregateError. With no .catch(), this becomes an unhandled promise rejection.

Impact: A single misbehaving SSE subscriber can crash the entire daemon process when a pairing approval request is broadcast.

Suggested change
void assistantEventHub.publish(buildAssistantEvent('self', msg));
void assistantEventHub.publish(buildAssistantEvent('self', msg)).catch(() => {});
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

}
: undefined,
};
}

Expand Down