The PianoRhythm initialization system is a sophisticated dependency-based state machine designed to eliminate race conditions and ensure reliable application startup across different environments and network conditions.
🚀 Quick Reference: See Initialization Quick Reference for common development tasks.
The original initialization process suffered from several critical race conditions:
Synth Engine vs Client Socket ID: The synth engine was created before the client socket ID was available, causing the error: "Synth engine created but client socket ID not set, yet."
Async Operation Coordination: Multiple async operations (WebSocket connection, audio initialization, core WASM loading) ran in parallel without proper coordination
Service Dependencies: Services were initialized without considering their dependencies on other services
Error Recovery: Limited retry mechanisms and timeout handling for transient failures
These race conditions led to:
src/services/initialization.service.ts
)The central coordinator that manages the entire initialization process:
interface InitializationService {
executeStep(step: InitializationStep, executor: StepExecutor): Promise<void>
waitForStep(step: InitializationStep, timeout?: number): Promise<void>
getStepStatus(step: InitializationStep): InitializationStatus
isStepCompleted(step: InitializationStep): boolean
reset(): void
getNextReadyStep(): InitializationStep | null
}
Key Features:
src/types/initialization.types.ts
)Comprehensive type definitions for the initialization system:
enum InitializationStep {
UserGesture = "user-gesture",
CoreWasm = "core-wasm",
AppState = "app-state",
WebsocketIdentity = "websocket-identity",
WebsocketConnection = "websocket-connection",
WelcomeEvent = "welcome-event",
ClientLoaded = "client-loaded",
AudioService = "audio-service",
SynthEngine = "synth-engine",
ClientSocketId = "client-socket-id",
ClientAddedToSynth = "client-added-to-synth",
Soundfont = "soundfont",
AppSettings = "app-settings",
UsersService = "users-service",
ChatService = "chat-service",
RoomsService = "rooms-service",
MonitorService = "monitor-service",
Complete = "complete"
}
Each initialization step implements the StepExecutor
interface:
interface StepExecutor {
execute: () => Promise<void>
validate?: () => Promise<boolean>
cleanup?: () => Promise<void>
}
The initialization follows a strict dependency graph (refactored for proper audio/soundfont order):
graph TD
A[UserGesture] --> B[CoreWasm]
B --> C[AppState]
C --> D[WebsocketIdentity]
D --> E[WebsocketConnection]
E --> F[WelcomeEvent]
F --> G[ClientLoaded]
G --> H[AudioService]
H --> I[SynthEngine]
I --> J[ClientSocketId]
J --> K[ClientAddedToSynth]
K --> L[Soundfont]
L --> M[AppSettings]
M --> N[UsersService]
N --> O[ChatService]
O --> P[RoomsService]
P --> Q[MonitorService]
Q --> R[Complete]
style K fill:#ff9999
style H fill:#99ff99
style L fill:#99ff99
Key Refactoring: The audio service now initializes completely before soundfont loading begins:
// Step 8: Audio Service (initializes synth engine and audio context)
await initializationService().executeStep(InitializationStep.AudioService, {
execute: async () => {
if (!audioService().initialized()) {
await audioService().initialize();
await raceTimeout(until(audioService().initialized), DEFAULT_SERVICE_TIMEOUT, true, "Audio service never initialized.");
}
}
});
// Step 10: Soundfont (loads after audio service is ready)
await initializationService().executeStep(InitializationStep.Soundfont, {
execute: async () => {
let soundfontLoaded = await onLoadClientSoundfont();
// ... soundfont loading logic with fallback to default
},
validate: async () => {
return !!audioService().loadedSoundfontName();
}
});
Benefits:
This is the most critical step that resolves the original race condition:
await initializationService().executeStep(InitializationStep.ClientAddedToSynth, {
execute: async () => {
// Wait for ALL required conditions
await raceTimeout(until(() => {
return appService().clientLoaded() &&
appService().getSocketID() &&
audioService().clientAdded();
}), DEFAULT_SERVICE_TIMEOUT, true, "Client never properly added to synth.");
},
validate: async () => {
return appService().clientLoaded() &&
!!appService().getSocketID() &&
audioService().clientAdded();
}
});
This step ensures:
The audio service was updated to handle proper sequencing:
// Wait for both client to be loaded AND socket ID to be available
await until(() => {
const clientLoaded = appService().clientLoaded();
const socketId = appService().getSocketID();
const workletReady = !appSettingsService().getSetting("AUDIO_USE_WORKLET") ||
!canCreateSharedArrayBuffer() ||
audioWorkletNode();
return clientLoaded && socketId && workletReady;
});
const socketId = appService().getSocketID();
if (!socketId) {
logError("[AudioService] Client loaded but socket ID is not available");
return;
}
// Now safely add client to synth
appService().coreService()?.send_app_action(AppStateActions.create({
action: AppStateActions_Action.SynthAction,
audioSynthAction: AudioSynthActions.create({
action: AudioSynthActions_Action.AddClient,
socketId: socketId,
})
}));
The Rust core middleware was enhanced with better error handling:
AudioSynthActions_Action::AddClient if synth_action.has_socketId() => {
let socket_id_str = synth_action.get_socketId();
log::info!("Adding client to synth with socket ID: {}", socket_id_str);
if let Some(socket_id) = add_synth_user(socket_id_str, true) {
pianorhythm_synth::set_client_socket_id(socket_id);
log::info!("Successfully added client to synth and set client socket ID: {}", socket_id);
} else {
log::warn!("Failed to add client to synth for socket ID: {}", socket_id_str);
}
}
The initialization service supports comprehensive configuration:
interface InitializationConfig {
defaultTimeout: number; // 30 seconds default
maxRetries: number; // 3 retries default
retryDelay: number; // 1 second delay
enableLogging: boolean; // Debug logging
}
Comprehensive test coverage includes:
describe('InitializationService', () => {
it('should execute steps in dependency order')
it('should prevent execution of steps with unmet dependencies')
it('should retry failed steps up to max retries')
it('should fail after max retries exceeded')
it('should validate steps when validator is provided')
it('should handle timeouts correctly')
it('should calculate progress correctly')
it('should reset state correctly')
it('should identify next ready step correctly')
});
Test Results: âś… All 10 tests passing
The refactored app loading process should be tested with:
When adding new initialization steps:
InitializationStep
enumstepDependencies
mapping// 1. Add to enum
enum InitializationStep {
// ... existing steps
NewFeature = "new-feature"
}
// 2. Define dependencies
const stepDependencies = {
// ... existing dependencies
[InitializationStep.NewFeature]: [InitializationStep.AudioService]
}
// 3. Execute the step
await initializationService().executeStep(InitializationStep.NewFeature, {
execute: async () => {
// Implementation
},
validate: async () => {
// Optional validation
return true;
}
});
Consider adding:
The initialization architecture refactoring successfully eliminates race conditions while providing a robust, maintainable foundation for application startup. The dependency-based approach ensures reliable operation across different environments and network conditions, significantly improving the user experience and developer productivity.
The comprehensive testing strategy and clear documentation make this system maintainable and extensible for future development needs.