Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions apps/agent/entrypoints/background/scheduledJobRuns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,8 @@ export const scheduledJobRuns = async () => {
type: 'normal',
})

// FIXME: Race condition - the controller-ext extension sends a window_created
// WebSocket message to register window ownership, but our HTTP request may arrive
// at the server before that registration completes. This delay is a temporary fix.
// Proper solution: ControllerBridge should wait/poll for window ownership registration.
await new Promise((resolve) => setTimeout(resolve, 1000))
// Note: Window ownership race condition is now handled by ControllerBridge.sendRequest()
// which polls for up to 500ms waiting for window registration before falling back.

const backgroundTab = backgroundWindow?.tabs?.[0]

Expand Down
45 changes: 38 additions & 7 deletions apps/server/src/browser/extension/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,34 @@ export class ControllerBridge {
return this.primaryClientId !== null
}

/**
* Waits for window ownership registration with polling.
* This resolves the race condition where HTTP requests arrive before
* the window_created WebSocket message is processed.
*
* @param windowId - The window ID to wait for
* @param timeoutMs - Maximum time to wait (default 500ms)
* @param pollIntervalMs - Polling interval (default 50ms)
* @returns The owner clientId if found, null if timeout
*/
private async waitForWindowOwnership(
windowId: number,
timeoutMs: number = 500,
pollIntervalMs: number = 50,
): Promise<string | null> {
const startTime = Date.now()

while (Date.now() - startTime < timeoutMs) {
const ownerClientId = this.windowOwnership.get(windowId)
if (ownerClientId && this.clients.has(ownerClientId)) {
return ownerClientId
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs))
}

return null
}

async sendRequest(
action: string,
payload: unknown,
Expand All @@ -140,22 +168,25 @@ export class ControllerBridge {
const payloadObj = payload as Record<string, unknown> | null
const windowId = payloadObj?.windowId as number | undefined

// FIXME: Race condition - when a new window is created, the window_created
// WebSocket message may not be processed before requests arrive for that window.
// This causes fallback to primaryClientId. For single-profile setups this works,
// but breaks multi-profile routing. Proper fix: poll/wait for window ownership
// registration here (e.g., retry for up to 500ms before falling back).
let targetClientId = this.primaryClientId
if (windowId !== undefined) {
const ownerClientId = this.windowOwnership.get(windowId)
// First check if already registered
let ownerClientId = this.windowOwnership.get(windowId)

// If not registered, wait for registration (fixes race condition)
if (!ownerClientId || !this.clients.has(ownerClientId)) {
this.logger.debug('Window not yet registered, waiting...', { windowId })
ownerClientId = await this.waitForWindowOwnership(windowId)
}

if (ownerClientId && this.clients.has(ownerClientId)) {
targetClientId = ownerClientId
this.logger.debug('Routing request by windowId', {
windowId,
targetClientId,
})
} else {
this.logger.warn('No owner found for windowId, using primary', {
this.logger.warn('Window ownership timeout, using primary', {
windowId,
primaryClientId: this.primaryClientId,
})
Expand Down
Loading