Skip to content

Commit faa8fca

Browse files
committed
🤖 Fix init events race condition by awaiting hook start
Root cause: workspace.create returned before init hook started, causing race between event emission and frontend subscription. Early events were lost (emitted before IPC listener registered). Solution: Refactor runWorkspaceInitHook → startWorkspaceInitHook (async) and await it in workspace.create. Now: 1. Create workspace metadata 2. Call startInit() to create in-memory state 3. Return from workspace.create (frontend can now subscribe) 4. Init hook process runs async (emits events to subscribed frontend) This guarantees: - In-memory state exists before workspace.create returns - replayInit() always finds state (no empty replay) - All init events have active subscription (none lost) - Fast: only waits for hook to START (~instant), not complete Live streaming and replay now produce identical UI states. Net change: ~10 LoC (refactor fire-and-forget to async/await)
1 parent e46c091 commit faa8fca

File tree

1 file changed

+61
-50
lines changed

1 file changed

+61
-50
lines changed

src/services/ipcMain.ts

Lines changed: 61 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -60,62 +60,72 @@ export class IpcMain {
6060
private mainWindow: BrowserWindow | null = null;
6161

6262
// Run optional .cmux/init hook for a newly created workspace and stream its output
63-
private async runWorkspaceInitHook(params: {
63+
private async startWorkspaceInitHook(params: {
6464
projectPath: string;
6565
worktreePath: string;
6666
workspaceId: string;
6767
}): Promise<void> {
68-
// Non-blocking fire-and-forget; errors are reported via init state manager
69-
try {
70-
const hookPath = path.join(params.projectPath, ".cmux", "init");
68+
const { projectPath, worktreePath, workspaceId } = params;
69+
const hookPath = path.join(projectPath, ".cmux", "init");
70+
71+
// Check if hook exists and is executable
72+
const exists = await fsPromises
73+
.access(hookPath, fs.constants.X_OK)
74+
.then(() => true)
75+
.catch(() => false);
76+
77+
if (!exists) {
78+
log.debug(`No init hook found at ${hookPath}`);
79+
return; // Nothing to do
80+
}
7181

72-
log.debug(`Checking for init hook at ${hookPath}`);
82+
log.info(`Starting init hook for workspace ${workspaceId}: ${hookPath}`);
7383

74-
// Check if hook exists and is executable
75-
const exists = await fsPromises
76-
.access(hookPath, fs.constants.X_OK)
77-
.then(() => true)
78-
.catch(() => false);
84+
// Start init hook tracking (creates in-memory state + emits init-start event)
85+
// This MUST complete before we return so replayInit() finds state
86+
this.initStateManager.startInit(workspaceId, hookPath);
7987

80-
if (!exists) {
81-
log.debug(`No init hook found at ${hookPath}`);
82-
return; // Nothing to do
83-
}
84-
85-
log.info(`Running init hook for workspace ${params.workspaceId}: ${hookPath}`);
86-
87-
// Start init hook tracking (automatically emits init-start event)
88-
this.initStateManager.startInit(params.workspaceId, hookPath);
89-
90-
// Execute init hook through centralized bash service
91-
this.bashService.executeStreaming(
92-
hookPath,
93-
{
94-
cwd: params.worktreePath,
95-
detached: false, // Don't need process group for simple script execution
96-
},
97-
{
98-
onStdout: (line) => {
99-
this.initStateManager.appendOutput(params.workspaceId, line, false);
100-
},
101-
onStderr: (line) => {
102-
this.initStateManager.appendOutput(params.workspaceId, line, true);
103-
},
104-
onExit: (exitCode) => {
105-
void this.initStateManager.endInit(params.workspaceId, exitCode);
88+
// Launch the hook process (async, don't await completion)
89+
void (async () => {
90+
try {
91+
const startTime = Date.now();
92+
93+
// Execute init hook through centralized bash service
94+
this.bashService.executeStreaming(
95+
hookPath,
96+
{
97+
cwd: worktreePath,
98+
detached: false, // Don't need process group for simple script execution
10699
},
107-
}
108-
);
109-
} catch (error) {
110-
log.error(`Failed to run init hook for workspace ${params.workspaceId}:`, error);
111-
// Report error through init state manager
112-
this.initStateManager.appendOutput(
113-
params.workspaceId,
114-
error instanceof Error ? error.message : String(error),
115-
true
116-
);
117-
void this.initStateManager.endInit(params.workspaceId, -1);
118-
}
100+
{
101+
onStdout: (line) => {
102+
this.initStateManager.appendOutput(workspaceId, line, false);
103+
},
104+
onStderr: (line) => {
105+
this.initStateManager.appendOutput(workspaceId, line, true);
106+
},
107+
onExit: (exitCode) => {
108+
const duration = Date.now() - startTime;
109+
const status = exitCode === 0 ? "success" : "error";
110+
log.info(
111+
`Init hook ${status} for workspace ${workspaceId} (exit code ${exitCode}, duration ${duration}ms)`
112+
);
113+
// Finalize init state (automatically emits init-end event and persists to disk)
114+
void this.initStateManager.endInit(workspaceId, exitCode);
115+
},
116+
}
117+
);
118+
} catch (error) {
119+
log.error(`Failed to run init hook for workspace ${workspaceId}:`, error);
120+
// Report error through init state manager
121+
this.initStateManager.appendOutput(
122+
workspaceId,
123+
error instanceof Error ? error.message : String(error),
124+
true
125+
);
126+
void this.initStateManager.endInit(workspaceId, -1);
127+
}
128+
})();
119129
}
120130
private registered = false;
121131

@@ -314,8 +324,9 @@ export class IpcMain {
314324
const session = this.getOrCreateSession(workspaceId);
315325
session.emitMetadata(completeMetadata);
316326

317-
// Fire-and-forget: run optional .cmux/init hook and stream output to renderer
318-
void this.runWorkspaceInitHook({
327+
// Start optional .cmux/init hook (waits for state creation, then returns)
328+
// This ensures replayInit() will find state when frontend subscribes
329+
await this.startWorkspaceInitHook({
319330
projectPath,
320331
worktreePath: result.path,
321332
workspaceId,

0 commit comments

Comments
 (0)