Skip to content

Commit 71a820b

Browse files
TylerBarnesMastra Code (openai/gpt-5.5)
andauthored
fix(mastracode): coordinate signals over Unix socket PubSub (#16669)
This adds a Unix socket PubSub implementation and wires Mastra Code to use it for local cross-process signal/stream coordination on macOS/Linux so that you never see the "thread is locked" message when starting multiple mc instances in the same dir. Used MC to test this but I need unix pubsub to make it easier to test drucker agents. Mastra Code now defaults to a local socket PubSub per project resource id, skips file thread locks when that cross-process PubSub is active, and closes the socket PubSub during CLI cleanup. Windows keeps the existing in-process PubSub/thread-lock behavior for now. After: ```ts const signalsPubSub = configuredPubSub ?? (process.platform === 'win32' ? undefined : createSignalsPubSub(project.resourceId)); ``` I tested this with core signal/runtime tests, Unix socket PubSub tests, and the Mastra Code build/typecheck. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ELI5 This PR makes Mastra Code on macOS/Linux use a per-project local Unix-domain socket so multiple local mastracode processes can share signals and live-stream updates without getting blocked by file-based thread locks; Windows behavior is unchanged. It also ensures the socket PubSub is closed cleanly on shutdown and preserves configured pubsub behavior unless cross-process mode is enabled. --- Overview Adds a Unix-domain socket PubSub transport and wires Mastra Code to use a deterministic per-resource Unix socket (derived from project.resourceId) for local cross-process signal and stream coordination on macOS/Linux. The Mastra Code TUI enables the Unix-socket PubSub by default when not on Windows (createMastraCode still honors an explicit configured pubsub), cross-process mode disables file thread-locks only when active, and shutdown paths close the socket PubSub. The UnixSocketPubSub implementation handles broker/client roles, reconnection, stale-socket reclamation, resubscription on reconnect, rejecting pending subscribe waiters on broker failure, and robust close/unlink behavior. --- Key changes - New IPC transport - packages/core/src/events/unix-socket-pubsub.ts - Implements UnixSocketPubSub: newline-delimited JSON over Unix domain sockets with broker/client semantics, write batching/flush, resubscribe-on-reconnect, subscribe waiters, and safe close/unlink. - Public API: socketPath, supportedModes, isBroker, publish, subscribe, unsubscribe, flush, close. - Mastra Code integration - mastracode/src/index.ts - MastraCodeConfig.unixSocketPubSub?: boolean added. - createMastraCode derives signalsPubSub from config.pubsub or (non-Windows) a per-resource Unix-socket PubSub when enabled; crossProcessPubSub defaults to the Unix-socket choice only if no configured pubsub is provided. threadLock remains conditional on crossProcessPubSub. - mastracode/src/utils/signals-pubsub.ts - getSignalsPubSubSocketPath(resourceId): deterministic SHA-256–derived short socket path under app data signals dir. - createSignalsPubSub(resourceId) factory that returns a UnixSocketPubSub bound to that path. - mastracode/src/main.ts and mastracode/src/headless.ts - TUI entry initializes createMastraCode({ unixSocketPubSub: true }), stores returned signalsPubSub, and includes signalsPubSub.close() in async cleanup; headless cleanup also closes optional signalsPubSub. - mastracode/src/onboarding/settings.ts - Introduces SignalSettings and adds GlobalSettings.signals.unixSocketPubSub (default false) with migration/load handling. - Tests - packages/core/src/events/unix-socket-pubsub.test.ts - Expanded Vitest suite: fan-out, subscriber exception isolation, broker promotion after close, stale-socket reclamation, and subscribe rejection when broker disconnects. - packages/core/src/agent/__tests__/agent-signals.test.ts - Non-Windows integration test: two AgentThreadStreamRuntime instances using UnixSocketPubSub on the same socket both receive streamed text deltas. - mastracode/src/__tests__/index.test.ts - Tests cover createMastraCode behavior for provided pubsub vs crossProcessPubSub and expected threadLock settings. - Unix-socket tests are skipped on Windows. - Exports and release notes - packages/core/src/events/index.ts re-exports unix-socket-pubsub. - .changeset/unix-socket-signals-pubsub.md added (patch for `@mastra/core` and mastracode). --- Platform behavior - macOS/Linux: TUI enables per-resource Unix socket PubSub by default (unless overridden) to allow cross-process coordination and avoid file-based thread locks. - Windows: unchanged — continues using in-process PubSub and file-based thread-lock behavior; Unix-socket tests are skipped. --- Testing performed - Unit tests for UnixSocketPubSub covering normal and edge cases (broker disconnect, stale socket, broker promotion, subscriber failures). - Integration test validating cross-runtime signal/stream delivery between runtimes using the same socket. - Mastra Code build/typecheck and core signal/runtime tests run successfully with the changeset. <!-- review_stack_entry_start --> [![Review Change Stack](https://storage.googleapis.com/coderabbit_public_assets/review-stack-in-coderabbit-ui.svg)](https://app.coderabbit.ai/change-stack/mastra-ai/mastra/pull/16669?utm_source=github_walkthrough&utm_medium=github&utm_campaign=change_stack) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Mastra Code (openai/gpt-5.5) <noreply@mastra.ai>
1 parent 3ff518e commit 71a820b

11 files changed

Lines changed: 796 additions & 6 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@mastra/core': patch
3+
'mastracode': patch
4+
---
5+
6+
Added a Unix socket PubSub transport and wired the Mastra Code TUI through a per-resource socket so local sessions can coordinate thread streams across processes. Programmatic `createMastraCode` usage remains opt-in:
7+
8+
```ts
9+
await createMastraCode({ unixSocketPubSub: true });
10+
```

mastracode/src/__tests__/index.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,32 @@ describe('createMastraCode', () => {
324324
);
325325
});
326326

327+
it('keeps thread locks enabled for configured PubSub unless cross-process mode is explicit', async () => {
328+
const pubsub = {} as any;
329+
const { createMastraCode } = await import('../index.js');
330+
331+
await createMastraCode({ pubsub, unixSocketPubSub: true });
332+
333+
const harnessConfig = harnessConstructorMock.mock.calls.at(-1)?.[0] as
334+
| { pubsub?: unknown; threadLock?: unknown }
335+
| undefined;
336+
expect(harnessConfig?.pubsub).toBe(pubsub);
337+
expect(harnessConfig?.threadLock).toBeDefined();
338+
});
339+
340+
it('skips thread locks for configured PubSub when cross-process mode is explicit', async () => {
341+
const pubsub = {} as any;
342+
const { createMastraCode } = await import('../index.js');
343+
344+
await createMastraCode({ pubsub, crossProcessPubSub: true });
345+
346+
const harnessConfig = harnessConstructorMock.mock.calls.at(-1)?.[0] as
347+
| { pubsub?: unknown; threadLock?: unknown }
348+
| undefined;
349+
expect(harnessConfig?.pubsub).toBe(pubsub);
350+
expect(harnessConfig?.threadLock).toBeUndefined();
351+
});
352+
327353
it('restores the current thread caveman observation setting at startup', async () => {
328354
harnessGetCurrentThreadIdMock.mockReturnValue('thread-1');
329355
harnessListThreadsMock.mockResolvedValue([{ id: 'thread-1', metadata: { cavemanObservations: true } }]);

mastracode/src/headless.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -628,7 +628,8 @@ export async function headlessMain(predrainedInput?: string | null): Promise<nev
628628

629629
// Cleanup
630630
releaseAllThreadLocks();
631-
await Promise.allSettled([mcpManager?.disconnect(), harness?.stopHeartbeats()]);
631+
const closeSignalsPubSub = (result.signalsPubSub as { close?: () => Promise<void> | void } | undefined)?.close;
632+
await Promise.allSettled([mcpManager?.disconnect(), harness?.stopHeartbeats(), closeSignalsPubSub?.()]);
632633

633634
process.exit(exitCode);
634635
}

mastracode/src/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ import {
7373
getResourceIdOverride,
7474
} from './utils/project.js';
7575
import type { StorageConfig } from './utils/project.js';
76+
import { createSignalsPubSub } from './utils/signals-pubsub.js';
7677
import { createStorage, createVectorStore } from './utils/storage-factory.js';
7778
import { acquireThreadLock, releaseThreadLock } from './utils/thread-lock.js';
7879

@@ -135,6 +136,8 @@ export interface MastraCodeConfig {
135136
browser?: HarnessConfig['browser'];
136137
/** PubSub for signal routing. When crossProcessPubSub is true, thread locks are disabled. */
137138
pubsub?: PubSub;
139+
/** Use Mastra Code's built-in Unix socket PubSub for local cross-process signal routing. */
140+
unixSocketPubSub?: boolean;
138141
/** Marks the configured PubSub as cross-process-safe, allowing Mastra Code to skip file thread locks. */
139142
crossProcessPubSub?: boolean;
140143
}
@@ -232,8 +235,11 @@ export async function createMastraCode(config?: MastraCodeConfig) {
232235
project.resourceIdOverride = true;
233236
}
234237

235-
const signalsPubSub = config?.pubsub;
236-
const crossProcessPubSub = config?.crossProcessPubSub ?? false;
238+
const configuredPubSub = config?.pubsub;
239+
const useUnixSocketPubSub =
240+
(config?.unixSocketPubSub ?? globalSettings.signals?.unixSocketPubSub ?? false) && process.platform !== 'win32';
241+
const signalsPubSub = configuredPubSub ?? (useUnixSocketPubSub ? createSignalsPubSub(project.resourceId) : undefined);
242+
const crossProcessPubSub = config?.crossProcessPubSub ?? (!configuredPubSub && useUnixSocketPubSub);
237243
if (crossProcessPubSub && !signalsPubSub) {
238244
throw new Error('crossProcessPubSub requires a pubsub instance');
239245
}
@@ -675,11 +681,11 @@ export async function createMastraCode(config?: MastraCodeConfig) {
675681
harness,
676682
mcpManager,
677683
hookManager,
684+
signalsPubSub,
678685
authStorage,
679686
resolveModel,
680687
storageWarning,
681688
observabilityWarning,
682-
signalsPubSub,
683689
builtinPacks,
684690
builtinOmPacks,
685691
effectiveDefaults,

mastracode/src/main.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ let harness: Awaited<ReturnType<typeof createMastraCode>>['harness'];
2121
let mcpManager: Awaited<ReturnType<typeof createMastraCode>>['mcpManager'];
2222
let hookManager: Awaited<ReturnType<typeof createMastraCode>>['hookManager'];
2323
let authStorage: Awaited<ReturnType<typeof createMastraCode>>['authStorage'];
24+
let signalsPubSub: Awaited<ReturnType<typeof createMastraCode>>['signalsPubSub'];
2425
let analytics: ReturnType<typeof createMastraCodeAnalytics> | undefined;
2526

2627
// Global safety nets — catch any uncaught errors from storage init, etc.
@@ -43,11 +44,12 @@ async function tuiMain(pipedInput?: string | null) {
4344
return browserPromise;
4445
};
4546

46-
const result = await createMastraCode();
47+
const result = await createMastraCode({ unixSocketPubSub: true });
4748
harness = result.harness;
4849
mcpManager = result.mcpManager;
4950
hookManager = result.hookManager;
5051
authStorage = result.authStorage;
52+
signalsPubSub = result.signalsPubSub;
5153

5254
if (result.storageWarning) {
5355
console.info(`⚠ ${result.storageWarning}`);
@@ -119,7 +121,13 @@ async function tuiMain(pipedInput?: string | null) {
119121

120122
const asyncCleanup = async () => {
121123
releaseAllThreadLocks();
122-
await Promise.allSettled([mcpManager?.disconnect(), harness?.stopHeartbeats(), analytics?.shutdown()]);
124+
const closeSignalsPubSub = (signalsPubSub as { close?: () => Promise<void> | void } | undefined)?.close;
125+
await Promise.allSettled([
126+
mcpManager?.disconnect(),
127+
harness?.stopHeartbeats(),
128+
closeSignalsPubSub?.(),
129+
analytics?.shutdown(),
130+
]);
123131
};
124132

125133
process.on('beforeExit', () => {

mastracode/src/onboarding/settings.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,10 +207,17 @@ export interface GlobalSettings {
207207
lsp?: LSPConfig;
208208
// Browser automation configuration
209209
browser: BrowserSettings;
210+
// Signal routing configuration
211+
signals: SignalSettings;
210212
// Cloud observability configuration (per-resource project IDs; tokens stored in auth.json)
211213
observability: ObservabilitySettings;
212214
}
213215

216+
export interface SignalSettings {
217+
/** Opt into local Unix socket PubSub for cross-process signal routing. */
218+
unixSocketPubSub: boolean;
219+
}
220+
214221
export interface ObservabilityResourceConfig {
215222
/** Cloud project ID for this resource */
216223
projectId: string;
@@ -279,6 +286,7 @@ const DEFAULTS: GlobalSettings = {
279286
viewport: { width: 1280, height: 720 },
280287
stagehand: { env: 'LOCAL' },
281288
},
289+
signals: { unixSocketPubSub: false },
282290
observability: { resources: {}, localTracing: false },
283291
};
284292

@@ -513,6 +521,12 @@ function migrateFromAuth(settingsPath: string): boolean {
513521
memoryGateway: raw.memoryGateway && typeof raw.memoryGateway === 'object' ? raw.memoryGateway : {},
514522
lsp: raw.lsp && typeof raw.lsp === 'object' ? (raw.lsp as LSPConfig) : undefined,
515523
browser: parseBrowserSettings(raw.browser),
524+
signals: {
525+
unixSocketPubSub:
526+
raw.signals && typeof raw.signals === 'object' && typeof raw.signals.unixSocketPubSub === 'boolean'
527+
? raw.signals.unixSocketPubSub
528+
: DEFAULTS.signals.unixSocketPubSub,
529+
},
516530
observability: parseObservabilitySettings(raw.observability),
517531
};
518532
applyQuietModePreferenceRollout(settings, raw.onboarding);
@@ -633,6 +647,12 @@ export function loadSettings(filePath: string = getSettingsPath()): GlobalSettin
633647
memoryGateway: raw.memoryGateway && typeof raw.memoryGateway === 'object' ? raw.memoryGateway : {},
634648
lsp: raw.lsp && typeof raw.lsp === 'object' ? (raw.lsp as LSPConfig) : undefined,
635649
browser: parseBrowserSettings(raw.browser),
650+
signals: {
651+
unixSocketPubSub:
652+
raw.signals && typeof raw.signals === 'object' && typeof raw.signals.unixSocketPubSub === 'boolean'
653+
? raw.signals.unixSocketPubSub
654+
: DEFAULTS.signals.unixSocketPubSub,
655+
},
636656
observability: parseObservabilitySettings(raw.observability),
637657
};
638658

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { createHash } from 'node:crypto';
2+
import * as fs from 'node:fs';
3+
import * as path from 'node:path';
4+
5+
import { UnixSocketPubSub } from '@mastra/core/events';
6+
7+
import { getAppDataDir } from './project.js';
8+
9+
function getSignalsDir(): string {
10+
const dir = path.join(getAppDataDir(), 'signals');
11+
if (!fs.existsSync(dir)) {
12+
fs.mkdirSync(dir, { recursive: true });
13+
}
14+
return dir;
15+
}
16+
17+
function shortHash(value: string): string {
18+
return createHash('sha256').update(value).digest('hex').slice(0, 16);
19+
}
20+
21+
export function getSignalsPubSubSocketPath(resourceId: string): string {
22+
return path.join(getSignalsDir(), `${shortHash(resourceId)}.sock`);
23+
}
24+
25+
export function createSignalsPubSub(resourceId: string): UnixSocketPubSub {
26+
return new UnixSocketPubSub(getSignalsPubSubSocketPath(resourceId));
27+
}

packages/core/src/agent/__tests__/agent-signals.test.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import { mkdtemp, rm } from 'node:fs/promises';
2+
import { tmpdir } from 'node:os';
3+
import { join } from 'node:path';
4+
15
import { MockLanguageModelV2, convertArrayToReadableStream } from '@internal/ai-sdk-v5/test';
26
import { beforeEach, describe, expect, it, vi } from 'vitest';
37

48
import { EventEmitterPubSub } from '../../events/event-emitter';
9+
import { UnixSocketPubSub } from '../../events/unix-socket-pubsub';
510
import { Mastra } from '../../mastra';
611
import { MockMemory } from '../../memory/mock';
712
import { Agent } from '../agent';
@@ -884,6 +889,70 @@ describe('Agent signals', () => {
884889
senderSubscription.unsubscribe();
885890
});
886891

892+
it.runIf(process.platform !== 'win32')(
893+
'broadcasts subscribed thread stream parts across UnixSocketPubSub runtime instances',
894+
async () => {
895+
const tempDir = await mkdtemp(join(tmpdir(), 'mastra-agent-signals-'));
896+
const ownerPubSub = new UnixSocketPubSub(join(tempDir, 'signals.sock'));
897+
const followerPubSub = new UnixSocketPubSub(join(tempDir, 'signals.sock'));
898+
const ownerRuntime = new AgentThreadStreamRuntime();
899+
const followerRuntime = new AgentThreadStreamRuntime();
900+
const owner = new Agent({
901+
id: 'unix-stream-agent',
902+
name: 'Unix Stream Owner Agent',
903+
instructions: 'Test',
904+
model: createTextStreamModel('owner response'),
905+
});
906+
const follower = new Agent({
907+
id: 'unix-stream-agent',
908+
name: 'Unix Stream Follower Agent',
909+
instructions: 'Test',
910+
model: createTextStreamModel('follower response'),
911+
});
912+
let finishRun!: () => void;
913+
const output = {
914+
runId: 'unix-run-1',
915+
status: 'running',
916+
fullStream: (async function* () {
917+
yield { type: 'text-delta', runId: 'unix-run-1', payload: { text: 'hello over uds' } };
918+
yield { type: 'finish', runId: 'unix-run-1', payload: {} };
919+
})(),
920+
_waitUntilFinished: () => new Promise<void>(resolve => (finishRun = resolve)),
921+
} as any;
922+
923+
try {
924+
const ownerSubscription = await ownerRuntime.subscribeToThread(
925+
owner,
926+
{ resourceId: 'unix-resource', threadId: 'unix-thread' },
927+
ownerPubSub,
928+
);
929+
const followerSubscription = await followerRuntime.subscribeToThread(
930+
follower,
931+
{ resourceId: 'unix-resource', threadId: 'unix-thread' },
932+
followerPubSub,
933+
);
934+
const ownerRun = readNextRunWithParts(ownerSubscription.stream[Symbol.asyncIterator]());
935+
const followerRun = readNextRunWithParts(followerSubscription.stream[Symbol.asyncIterator]());
936+
937+
ownerRuntime.registerRun(
938+
owner,
939+
output,
940+
{ runId: 'unix-run-1', memory: { resource: 'unix-resource', thread: 'unix-thread' } } as any,
941+
ownerPubSub,
942+
);
943+
944+
await expect(ownerRun).resolves.toMatchObject({ value: { text: 'hello over uds' }, done: false });
945+
await expect(followerRun).resolves.toMatchObject({ value: { text: 'hello over uds' }, done: false });
946+
finishRun();
947+
ownerSubscription.unsubscribe();
948+
followerSubscription.unsubscribe();
949+
} finally {
950+
await Promise.allSettled([ownerPubSub.close(), followerPubSub.close()]);
951+
await rm(tempDir, { recursive: true, force: true });
952+
}
953+
},
954+
);
955+
887956
it('supports cross-instance thread subscriptions through an injected PubSub without Mastra', async () => {
888957
const pubsub = new EventEmitterPubSub();
889958
const runner = new Agent({

packages/core/src/events/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * from './types';
22
export * from './pubsub';
33
export * from './event-emitter';
44
export { CachingPubSub, withCaching, type CachingPubSubOptions } from './caching-pubsub';
5+
export * from './unix-socket-pubsub';

0 commit comments

Comments
 (0)