Skip to content

Commit 5111e24

Browse files
authored
🤖 fix: prevent init state persist race condition (#1056)
Fixed flaky test `should persist init state to disk for replay across page reloads`. ## Root Cause Race condition in `InitStateManager.endInit()`: 1. In-memory state was updated (`exitCode`) synchronously 2. Then `persist()` was awaited to write to disk 3. But `logComplete()` called `endInit()` with `void` (fire-and-forget) If replay happened while `persist()` was still running, it would see `exitCode !== null` but the file wouldn't exist yet. ## Fix Persist state BEFORE updating in-memory `exitCode`, ensuring the invariant: **if `init-end` is visible (live or replay), the file MUST exist**. ## Validation - Test passes consistently (5 consecutive runs) - All init tests pass - Full static-check passes _Generated with `mux`_
1 parent e9da918 commit 5111e24

File tree

2 files changed

+20
-4
lines changed

2 files changed

+20
-4
lines changed

docs/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ Avoid mock-heavy tests that verify implementation details rather than behavior.
114114
- Use `using` declarations (or equivalent disposables) for processes, file handles, etc., to ensure cleanup even on errors.
115115
- Centralize magic constants under `src/constants/`; share them instead of duplicating values across layers.
116116
- Never repeat constant values (like keybinds) in comments—they become stale when the constant changes.
117+
- **Avoid `void asyncFn()`** - fire-and-forget async calls hide race conditions. When state is observable by other code (in-memory cache, event emitters), ensure visibility order matches invariants. If memory and disk must stay in sync, persist before updating memory so observers see consistent state.
117118

118119
## Component State & Storage
119120

src/node/services/initStateManager.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ export class InitStateManager extends EventEmitter {
197197
/**
198198
* Finalize init hook execution.
199199
* Updates state, persists to disk, emits init-end event, and resolves completion promise.
200+
*
201+
* IMPORTANT: We persist BEFORE updating in-memory exitCode to prevent a race condition
202+
* where replay() sees exitCode !== null but the file doesn't exist yet. This ensures
203+
* the invariant: if init-end is visible (live or replay), the file MUST exist.
200204
*/
201205
async endInit(workspaceId: string, exitCode: number): Promise<void> {
202206
const state = this.store.getState(workspaceId);
@@ -207,13 +211,24 @@ export class InitStateManager extends EventEmitter {
207211
}
208212

209213
const endTime = Date.now();
210-
state.status = exitCode === 0 ? "success" : "error";
214+
const finalStatus = exitCode === 0 ? "success" : "error";
215+
216+
// Create complete state for persistence (don't mutate in-memory state yet)
217+
const stateToPerist: InitHookState = {
218+
...state,
219+
status: finalStatus,
220+
exitCode,
221+
endTime,
222+
};
223+
224+
// Persist FIRST - ensures file exists before in-memory state shows completion
225+
await this.store.persist(workspaceId, stateToPerist);
226+
227+
// NOW update in-memory state (replay will now see file exists)
228+
state.status = finalStatus;
211229
state.exitCode = exitCode;
212230
state.endTime = endTime;
213231

214-
// Persist to disk (fire-and-forget, errors logged internally by EventStore)
215-
await this.store.persist(workspaceId, state);
216-
217232
log.info(
218233
`Init hook ${state.status} for workspace ${workspaceId} (exit code ${exitCode}, duration ${endTime - state.startTime}ms)`
219234
);

0 commit comments

Comments
 (0)