Skip to content

Commit f07b646

Browse files
TylerBarnesMastra Code (openai/gpt-5.5)
andauthored
fix(core): prevent unix pubsub subscriber stalls (#17302)
This fixes the UnixSocketPubSub path where one paused Mastra Code subscriber could block the broker and every other subscriber after a large stream part/tool result. Before, broker publish waited for every remote socket write to drain: ```ts await writeFrame(client.socket, frame); await this.flush(); ``` After, broker delivery is local-first and remote writes are queued per client with a bounded buffer: ```ts this.#deliverLocal(topic, brokerEvent); this.#enqueueBrokerClientWrite(client, frame); ``` A stuck remote client can now buffer briefly and catch up if it resumes, but if its per-client queue exceeds the cap we drop only that client. The active broker and healthy subscribers keep streaming. I verified this with the focused core tests and a manual 3-instance Mastra Code repro where one subscriber was paused with `kill -STOP`; the broker and healthy subscriber kept streaming, then the paused process resumed cleanly. --------- Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent e9d54b2 commit f07b646

3 files changed

Lines changed: 258 additions & 25 deletions

File tree

.changeset/clean-geese-scream.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed UnixSocketPubSub streaming so a slow or stuck subscriber no longer blocks active local streams or other subscribers.

packages/core/src/events/unix-socket-pubsub.test.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,117 @@ describe('UnixSocketPubSub', () => {
6969
expect(secondCb.mock.calls[0]![0].type).toBe('hello');
7070
});
7171

72+
it('allows a temporarily backpressured remote client to catch up below the queue cap', async () => {
73+
const path = await socketPath();
74+
const broker = new UnixSocketPubSub(path, { maxRemoteClientQueuedBytes: 1024 * 1024 });
75+
pubsubs.push(broker);
76+
77+
const brokerCb = vi.fn();
78+
await broker.subscribe('topic-a', brokerCb);
79+
80+
const frames: any[] = [];
81+
const rawClient = net.createConnection(path);
82+
rawClient.setEncoding('utf8');
83+
let buffer = '';
84+
rawClient.on('data', (chunk: string) => {
85+
buffer += chunk;
86+
while (true) {
87+
const newlineIndex = buffer.indexOf('\n');
88+
if (newlineIndex === -1) break;
89+
const line = buffer.slice(0, newlineIndex);
90+
buffer = buffer.slice(newlineIndex + 1);
91+
if (line.trim()) frames.push(JSON.parse(line));
92+
}
93+
});
94+
95+
const waitForRawFrame = async (predicate: (frame: any) => boolean) => {
96+
await waitFor(() => {
97+
expect(frames.some(predicate)).toBe(true);
98+
});
99+
};
100+
101+
try {
102+
await new Promise<void>((resolve, reject) => {
103+
rawClient.once('connect', resolve);
104+
rawClient.once('error', reject);
105+
});
106+
await new Promise<void>((resolve, reject) => {
107+
rawClient.write(`${JSON.stringify({ type: 'subscribe', topic: 'topic-a' })}\n`, (error?: Error | null) => {
108+
if (error) reject(error);
109+
else resolve();
110+
});
111+
});
112+
await waitForRawFrame(frame => frame.type === 'subscribed' && frame.topic === 'topic-a');
113+
rawClient.pause();
114+
115+
const payload = 'x'.repeat(16 * 1024);
116+
for (let i = 0; i < 4; i++) {
117+
await broker.publish('topic-a', makeEvent({ type: `recover-${i}`, data: { payload } }));
118+
}
119+
120+
expect(broker.remoteClientCount).toBe(1);
121+
expect(brokerCb).toHaveBeenCalledTimes(4);
122+
rawClient.resume();
123+
124+
await waitForRawFrame(frame => frame.type === 'event' && frame.event?.type === 'recover-3');
125+
expect(broker.remoteClientCount).toBe(1);
126+
} finally {
127+
rawClient.destroy();
128+
}
129+
});
130+
131+
it('does not let a backpressured remote client block local or healthy subscribers', async () => {
132+
const path = await socketPath();
133+
const broker = new UnixSocketPubSub(path, { maxRemoteClientQueuedBytes: 256 * 1024 });
134+
const healthy = new UnixSocketPubSub(path, { maxRemoteClientQueuedBytes: 256 * 1024 });
135+
pubsubs.push(broker, healthy);
136+
137+
const brokerCb = vi.fn();
138+
const healthyCb = vi.fn();
139+
await broker.subscribe('topic-a', brokerCb);
140+
await healthy.subscribe('topic-a', healthyCb);
141+
142+
const stuck = net.createConnection(path);
143+
try {
144+
await new Promise<void>((resolve, reject) => {
145+
stuck.once('connect', resolve);
146+
stuck.once('error', reject);
147+
});
148+
await new Promise<void>((resolve, reject) => {
149+
stuck.write(`${JSON.stringify({ type: 'subscribe', topic: 'topic-a' })}\n`, (error?: Error | null) => {
150+
if (error) reject(error);
151+
else resolve();
152+
});
153+
});
154+
stuck.pause();
155+
156+
await waitFor(() => {
157+
expect(broker.remoteClientCount).toBe(2);
158+
});
159+
await new Promise(resolve => setTimeout(resolve, 0));
160+
161+
const payload = 'x'.repeat(32 * 1024);
162+
for (let i = 0; i < 20; i++) {
163+
const result = await Promise.race([
164+
broker.publish('topic-a', makeEvent({ type: `large-${i}`, data: { payload } })).then(() => 'published'),
165+
new Promise(resolve => setTimeout(() => resolve('timeout'), 500)),
166+
]);
167+
expect(result).toBe('published');
168+
await new Promise(resolve => setTimeout(resolve, 0));
169+
}
170+
171+
expect(brokerCb).toHaveBeenCalledTimes(20);
172+
await waitFor(() => {
173+
expect(healthyCb.mock.calls.some(call => call[0].type === 'large-19')).toBe(true);
174+
});
175+
await waitFor(() => {
176+
expect(broker.remoteClientCount).toBe(1);
177+
});
178+
} finally {
179+
stuck.destroy();
180+
}
181+
});
182+
72183
it('isolates local subscriber failures from other subscribers', async () => {
73184
const path = await socketPath();
74185
const pubsub = new UnixSocketPubSub(path);

packages/core/src/events/unix-socket-pubsub.ts

Lines changed: 142 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -17,40 +17,88 @@ type ClientFrame =
1717

1818
type ServerFrame = { type: 'event'; topic: string; event: Event } | { type: 'subscribed'; topic: string };
1919

20+
type UnixSocketPubSubOptions = {
21+
maxRemoteClientQueuedBytes?: number;
22+
};
23+
2024
type BrokerClient = {
2125
socket: net.Socket;
2226
subscriptions: Set<string>;
27+
writeChain: Promise<void>;
28+
queuedBytes: number;
2329
};
2430

2531
type SubscribeWaiter = {
2632
resolve: () => void;
2733
reject: (error: Error) => void;
2834
};
2935

30-
function writeFrame(socket: net.Socket, frame: ClientFrame | ServerFrame): Promise<void> {
36+
const DEFAULT_MAX_REMOTE_CLIENT_QUEUED_BYTES = 64 * 1024 * 1024;
37+
38+
function serializeFrame(frame: ClientFrame | ServerFrame): string {
39+
return `${JSON.stringify(frame)}\n`;
40+
}
41+
42+
function writeSerializedFrame(socket: net.Socket, serializedFrame: string): Promise<void> {
3143
return new Promise((resolve, reject) => {
32-
const onError = (error: Error) => {
44+
let writeCompleted = false;
45+
let drainCompleted = true;
46+
let settled = false;
47+
48+
const cleanup = () => {
49+
socket.off('error', onError);
50+
socket.off('close', onClose);
3351
socket.off('drain', onDrain);
34-
reject(error);
3552
};
36-
const onDrain = () => {
37-
socket.off('error', onError);
53+
const settle = (error?: Error) => {
54+
if (settled) return;
55+
settled = true;
56+
cleanup();
57+
if (error) {
58+
reject(error);
59+
return;
60+
}
3861
resolve();
3962
};
63+
const maybeResolve = () => {
64+
if (writeCompleted && drainCompleted) {
65+
settle();
66+
}
67+
};
68+
const onError = (error: Error) => settle(error);
69+
const onClose = () => settle(new Error('UnixSocketPubSub socket closed before write completed'));
70+
const onDrain = () => {
71+
drainCompleted = true;
72+
maybeResolve();
73+
};
4074

4175
socket.once('error', onError);
42-
const drained = socket.write(`${JSON.stringify(frame)}\n`, () => {
43-
if (drained) {
44-
socket.off('error', onError);
45-
resolve();
46-
}
47-
});
76+
socket.once('close', onClose);
77+
let drained: boolean;
78+
try {
79+
drained = socket.write(serializedFrame, error => {
80+
if (error) {
81+
settle(error);
82+
return;
83+
}
84+
writeCompleted = true;
85+
maybeResolve();
86+
});
87+
} catch (error) {
88+
settle(error as Error);
89+
return;
90+
}
4891
if (!drained) {
92+
drainCompleted = false;
4993
socket.once('drain', onDrain);
5094
}
5195
});
5296
}
5397

98+
function writeFrame(socket: net.Socket, frame: ClientFrame | ServerFrame): Promise<void> {
99+
return writeSerializedFrame(socket, serializeFrame(frame));
100+
}
101+
54102
function nextTick(): Promise<void> {
55103
return new Promise(resolve => setImmediate(resolve));
56104
}
@@ -85,12 +133,14 @@ export class UnixSocketPubSub extends PubSub {
85133
#callbacks = new Map<string, Set<EventCallback>>();
86134
#subscribeWaiters = new Map<string, SubscribeWaiter[]>();
87135
#brokerClients = new Map<net.Socket, BrokerClient>();
88-
#pendingWrites: Promise<void>[] = [];
136+
#pendingWrites = new Set<Promise<void>>();
89137
#recovering?: Promise<void>;
138+
#maxRemoteClientQueuedBytes: number;
90139

91-
constructor(socketPath: string) {
140+
constructor(socketPath: string, options: UnixSocketPubSubOptions = {}) {
92141
super();
93142
this.socketPath = socketPath;
143+
this.#maxRemoteClientQueuedBytes = options.maxRemoteClientQueuedBytes ?? DEFAULT_MAX_REMOTE_CLIENT_QUEUED_BYTES;
94144
}
95145

96146
override get supportedModes(): ReadonlyArray<PubSubDeliveryMode> {
@@ -160,8 +210,7 @@ export class UnixSocketPubSub extends PubSub {
160210
}
161211

162212
async flush(): Promise<void> {
163-
await Promise.allSettled(this.#pendingWrites);
164-
this.#pendingWrites = [];
213+
await Promise.allSettled([...this.#pendingWrites]);
165214
}
166215

167216
async close(): Promise<void> {
@@ -172,10 +221,9 @@ export class UnixSocketPubSub extends PubSub {
172221
this.#clientSocket = undefined;
173222
this.#rejectSubscribeWaiters(new Error('UnixSocketPubSub is closed'));
174223

175-
for (const client of this.#brokerClients.values()) {
176-
client.socket.destroy();
224+
for (const client of [...this.#brokerClients.values()]) {
225+
this.#removeBrokerClient(client);
177226
}
178-
this.#brokerClients.clear();
179227

180228
if (this.#server) {
181229
await new Promise<void>(resolve => this.#server?.close(() => resolve()));
@@ -436,21 +484,67 @@ export class UnixSocketPubSub extends PubSub {
436484
}
437485

438486
#handleBrokerClient(socket: net.Socket) {
439-
const client: BrokerClient = { socket, subscriptions: new Set() };
487+
const client: BrokerClient = {
488+
socket,
489+
subscriptions: new Set(),
490+
writeChain: Promise.resolve(),
491+
queuedBytes: 0,
492+
};
440493
this.#brokerClients.set(socket, client);
441494
readFrames(socket, frame => {
442495
const clientFrame = frame as ClientFrame;
443496
if (clientFrame.type === 'subscribe') {
444497
client.subscriptions.add(clientFrame.topic);
445-
void writeFrame(socket, { type: 'subscribed', topic: clientFrame.topic }).catch(() => {});
498+
this.#enqueueBrokerClientWrite(client, { type: 'subscribed', topic: clientFrame.topic });
446499
} else if (clientFrame.type === 'unsubscribe') {
447500
client.subscriptions.delete(clientFrame.topic);
448501
} else if (clientFrame.type === 'publish') {
449502
void this.#publishFromBroker(clientFrame.topic, clientFrame.event);
450503
}
451504
});
452-
socket.on('close', () => this.#brokerClients.delete(socket));
453-
socket.on('error', () => this.#brokerClients.delete(socket));
505+
socket.on('close', () => this.#removeBrokerClient(client));
506+
socket.on('error', () => this.#removeBrokerClient(client));
507+
}
508+
509+
#enqueueBrokerClientWrite(client: BrokerClient, frame: ServerFrame) {
510+
if (this.#brokerClients.get(client.socket) !== client || client.socket.destroyed) return;
511+
512+
const serializedFrame = serializeFrame(frame);
513+
const queuedBytes = Buffer.byteLength(serializedFrame);
514+
if (client.queuedBytes + queuedBytes > this.#maxRemoteClientQueuedBytes) {
515+
this.#removeBrokerClient(client);
516+
return;
517+
}
518+
519+
client.queuedBytes += queuedBytes;
520+
521+
const write = client.writeChain
522+
.catch(() => {})
523+
.then(async () => {
524+
if (this.#brokerClients.get(client.socket) !== client || client.socket.destroyed) return;
525+
await writeSerializedFrame(client.socket, serializedFrame);
526+
})
527+
.catch(() => {
528+
this.#removeBrokerClient(client);
529+
})
530+
.finally(() => {
531+
client.queuedBytes = Math.max(0, client.queuedBytes - queuedBytes);
532+
});
533+
534+
client.writeChain = write;
535+
this.#pendingWrites.add(write);
536+
void write.finally(() => this.#pendingWrites.delete(write));
537+
}
538+
539+
#removeBrokerClient(client: BrokerClient) {
540+
if (this.#brokerClients.get(client.socket) !== client) return;
541+
this.#brokerClients.delete(client.socket);
542+
client.subscriptions.clear();
543+
client.queuedBytes = 0;
544+
client.writeChain = Promise.resolve();
545+
if (!client.socket.destroyed) {
546+
client.socket.destroy();
547+
}
454548
}
455549

456550
#handleServerFrame(frame: ServerFrame) {
@@ -484,10 +578,8 @@ export class UnixSocketPubSub extends PubSub {
484578
if (!client.subscriptions.has(topic) || client.socket.destroyed) continue;
485579
// Lazily build the frame only when we know at least one client needs it.
486580
frame ??= { type: 'event', topic, event: brokerEvent };
487-
const write = writeFrame(client.socket, frame).catch(() => {});
488-
this.#pendingWrites.push(write);
581+
this.#enqueueBrokerClientWrite(client, frame);
489582
}
490-
if (frame) await this.flush();
491583
}
492584

493585
#deliverLocal(topic: string, event: Event) {
@@ -510,14 +602,39 @@ export class UnixSocketPubSub extends PubSub {
510602
}
511603

512604
async #sendToBroker(frame: ClientFrame) {
605+
try {
606+
await this.#sendToActiveBroker(frame);
607+
} catch (error) {
608+
if (this.#closed) throw error;
609+
const failedSocket = this.#clientSocket;
610+
this.#clientSocket = undefined;
611+
failedSocket?.destroy();
612+
await this.#ensureStarted(true);
613+
await this.#sendToActiveBroker(frame);
614+
}
615+
}
616+
617+
async #sendToActiveBroker(frame: ClientFrame) {
513618
const socket = this.#clientSocket;
514619
if (!socket || socket.destroyed) {
515620
await this.#ensureStarted(true);
516621
}
622+
if (this.#isBroker) {
623+
await this.#handlePromotedBrokerFrame(frame);
624+
return;
625+
}
517626
const activeSocket = this.#clientSocket;
518627
if (!activeSocket || activeSocket.destroyed) {
519628
throw new Error('UnixSocketPubSub is not connected to a broker');
520629
}
521630
await writeFrame(activeSocket, frame);
522631
}
632+
633+
async #handlePromotedBrokerFrame(frame: ClientFrame) {
634+
if (frame.type === 'subscribe') {
635+
this.#settleSubscribeWaiters(frame.topic);
636+
} else if (frame.type === 'publish') {
637+
await this.#publishFromBroker(frame.topic, frame.event);
638+
}
639+
}
523640
}

0 commit comments

Comments
 (0)