Skip to content
Merged
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
33 changes: 32 additions & 1 deletion apps/desktop/src/backendReadiness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
} from "./backendReadiness";

describe("waitForHttpReady", () => {
it("returns once the backend reports a successful session endpoint", async () => {
it("returns once the backend serves the requested readiness path", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(null, { status: 503 }))
Expand All @@ -20,6 +20,11 @@ describe("waitForHttpReady", () => {
});

expect(fetchImpl).toHaveBeenCalledTimes(2);
expect(fetchImpl).toHaveBeenNthCalledWith(
1,
"http://127.0.0.1:3773/",
expect.objectContaining({ redirect: "manual" }),
);
});

it("retries after a readiness request stalls past the per-request timeout", async () => {
Expand Down Expand Up @@ -80,4 +85,30 @@ describe("waitForHttpReady", () => {
expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true);
expect(isBackendReadinessAborted(new Error("nope"))).toBe(false);
});

it("supports custom readiness predicates", async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(new Response(null, { status: 200 }))
.mockResolvedValueOnce(new Response(null, { status: 204 }));

await waitForHttpReady("http://127.0.0.1:3773", {
fetchImpl,
timeoutMs: 1_000,
intervalMs: 0,
path: "/api/healthz",
isReady: (response) => response.status === 204,
});

expect(fetchImpl).toHaveBeenNthCalledWith(
1,
"http://127.0.0.1:3773/api/healthz",
expect.objectContaining({ redirect: "manual" }),
);
expect(fetchImpl).toHaveBeenNthCalledWith(
2,
"http://127.0.0.1:3773/api/healthz",
expect.objectContaining({ redirect: "manual" }),
);
});
});
8 changes: 6 additions & 2 deletions apps/desktop/src/backendReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ export interface WaitForHttpReadyOptions {
readonly requestTimeoutMs?: number;
readonly fetchImpl?: typeof fetch;
readonly signal?: AbortSignal;
readonly path?: string;
readonly isReady?: (response: Response) => boolean;
}

const DEFAULT_TIMEOUT_MS = 30_000;
Expand Down Expand Up @@ -57,6 +59,8 @@ export async function waitForHttpReady(
const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const readinessPath = options?.path ?? "/";
const isReady = options?.isReady ?? ((response: Response) => response.ok);
const deadline = Date.now() + timeoutMs;

for (;;) {
Expand All @@ -74,11 +78,11 @@ export async function waitForHttpReady(
signal?.addEventListener("abort", abortRequest, { once: true });

try {
const response = await fetchImpl(`${baseUrl}/api/auth/session`, {
const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), {
redirect: "manual",
signal: requestController.signal,
});
if (response.ok) {
if (isReady(response)) {
return;
}
} catch (error) {
Expand Down
168 changes: 136 additions & 32 deletions apps/desktop/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import { showDesktopConfirmDialog } from "./confirmDialog";
import { resolveDesktopServerExposure } from "./serverExposure";
import { syncShellEnvironment } from "./syncShellEnvironment";
import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";
import { ServerListeningDetector } from "./serverListeningDetector";
import {
createInitialDesktopUpdateState,
reduceDesktopUpdateStateOnCheckFailure,
Expand Down Expand Up @@ -144,6 +145,8 @@ let backendWsUrl = "";
let backendEndpointUrl: string | null = null;
let backendAdvertisedHost: string | null = null;
let backendReadinessAbortController: AbortController | null = null;
let backendInitialWindowOpenInFlight: Promise<void> | null = null;
let backendListeningDetector: ServerListeningDetector | null = null;
let restartAttempt = 0;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
let isQuitting = false;
Expand Down Expand Up @@ -362,13 +365,17 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null {
return null;
}

async function waitForBackendHttpReady(baseUrl: string): Promise<void> {
async function waitForBackendHttpReady(
baseUrl: string,
options?: Parameters<typeof waitForHttpReady>[1],
): Promise<void> {
cancelBackendReadinessWait();
const controller = new AbortController();
backendReadinessAbortController = controller;

try {
await waitForHttpReady(baseUrl, {
...options,
Comment thread
cursor[bot] marked this conversation as resolved.
signal: controller.signal,
});
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
} finally {
Expand All @@ -383,6 +390,88 @@ function cancelBackendReadinessWait(): void {
backendReadinessAbortController = null;
}

async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {
const httpReadyPromise = waitForBackendHttpReady(baseUrl, {
timeoutMs: 60_000,
});
const listeningPromise = backendListeningDetector?.promise;

if (!listeningPromise) {
await httpReadyPromise;
return "http";
}

return await new Promise<"listening" | "http">((resolve, reject) => {
let settled = false;

const settleResolve = (source: "listening" | "http") => {
if (settled) {
return;
}
settled = true;
if (source === "listening") {
cancelBackendReadinessWait();
}
resolve(source);
};

const settleReject = (error: unknown) => {
if (settled) {
return;
}
settled = true;
reject(error);
};

listeningPromise.then(
() => settleResolve("listening"),
(error) => settleReject(error),
);
httpReadyPromise.then(
() => settleResolve("http"),
(error) => {
if (settled && isBackendReadinessAborted(error)) {
return;
}
settleReject(error);
},
);
});
}

function ensureInitialBackendWindowOpen(): void {
const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) {
return;
}

const nextOpen = waitForBackendWindowReady(backendHttpUrl)
.then((source) => {
writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
if (mainWindow ?? BrowserWindow.getAllWindows()[0]) {
return;
}
mainWindow = createWindow();
writeDesktopLogHeader("bootstrap main window created");
})
.catch((error) => {
if (isBackendReadinessAborted(error)) {
return;
}
writeDesktopLogHeader(
`bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
);
console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error);
})
Comment thread
cursor[bot] marked this conversation as resolved.
.finally(() => {
if (backendInitialWindowOpenInFlight === nextOpen) {
backendInitialWindowOpenInFlight = null;
}
});

backendInitialWindowOpenInFlight = nextOpen;
}

function writeDesktopStreamChunk(
streamName: "stdout" | "stderr",
chunk: unknown,
Expand Down Expand Up @@ -460,14 +549,16 @@ function initializePackagedLogging(): void {
}

function captureBackendOutput(child: ChildProcess.ChildProcess): void {
if (!app.isPackaged || backendLogSink === null) return;
const writeChunk = (chunk: unknown): void => {
if (!backendLogSink) return;
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
backendLogSink.write(buffer);
const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => {
stream?.on("data", (chunk: unknown) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
backendLogSink?.write(buffer);
backendListeningDetector?.push(buffer);
});
};
child.stdout?.on("data", writeChunk);
child.stderr?.on("data", writeChunk);

attachStream(child.stdout);
attachStream(child.stderr);
}

initializePackagedLogging();
Expand Down Expand Up @@ -1222,7 +1313,7 @@ function startBackend(): void {
return;
}

const captureBackendLogs = app.isPackaged && backendLogSink !== null;
const captureBackendLogs = !isDevelopment;
const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
cwd: resolveBackendCwd(),
// In Electron main, process.execPath points to the Electron binary.
Expand Down Expand Up @@ -1259,6 +1350,8 @@ function startBackend(): void {
scheduleBackendRestart("missing desktop bootstrap pipe");
return;
}
const listeningDetector = new ServerListeningDetector();
backendListeningDetector = listeningDetector;
backendProcess = child;
let backendSessionClosed = false;
const closeBackendSession = (details: string) => {
Expand All @@ -1277,6 +1370,10 @@ function startBackend(): void {
});

child.on("error", (error) => {
if (backendListeningDetector === listeningDetector) {
listeningDetector.fail(error);
backendListeningDetector = null;
}
const wasExpected = expectedBackendExitChildren.has(child);
if (backendProcess === child) {
backendProcess = null;
Expand All @@ -1289,6 +1386,14 @@ function startBackend(): void {
});

child.on("exit", (code, signal) => {
if (backendListeningDetector === listeningDetector) {
listeningDetector.fail(
new Error(
`backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`,
),
);
backendListeningDetector = null;
}
const wasExpected = expectedBackendExitChildren.has(child);
if (backendProcess === child) {
backendProcess = null;
Expand All @@ -1300,10 +1405,13 @@ function startBackend(): void {
const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
scheduleBackendRestart(reason);
});

ensureInitialBackendWindowOpen();
}

function stopBackend(): void {
cancelBackendReadinessWait();
backendListeningDetector = null;
if (restartTimer) {
clearTimeout(restartTimer);
restartTimer = null;
Expand Down Expand Up @@ -1705,7 +1813,7 @@ function createWindow(): BrowserWindow {
height: 780,
minWidth: 840,
minHeight: 620,
show: isDevelopment,
show: false,
autoHideMenuBar: true,
backgroundColor: getInitialWindowBackgroundColor(),
...getIconOption(),
Expand Down Expand Up @@ -1779,20 +1887,23 @@ function createWindow(): BrowserWindow {
window.setTitle(APP_DISPLAY_NAME);
emitUpdateState();
});
if (!isDevelopment) {
window.once("ready-to-show", () => {
revealWindow(window);
});
}

let initialRevealScheduled = false;
const revealInitialWindow = () => {
if (initialRevealScheduled) {
return;
}
initialRevealScheduled = true;
revealWindow(window);
};

window.once("ready-to-show", revealInitialWindow);

if (isDevelopment) {
void window.loadURL(resolveDesktopDevServerUrl());
window.webContents.openDevTools({ mode: "detach" });
setImmediate(() => {
revealWindow(window);
});
} else {
void window.loadURL(resolveDesktopWindowUrl());
void window.loadURL(backendHttpUrl);
}

window.on("closed", () => {
Expand All @@ -1804,14 +1915,6 @@ function createWindow(): BrowserWindow {
return window;
}

function resolveDesktopWindowUrl(): string {
if (backendHttpUrl) {
return backendHttpUrl;
}

return `${DESKTOP_SCHEME}://app`;
}

// Override Electron's userData path before the `ready` event so that
// Chromium session data uses a filesystem-friendly directory name.
// Must be called synchronously at the top level — before `app.whenReady()`.
Expand Down Expand Up @@ -1885,10 +1988,7 @@ async function bootstrap(): Promise<void> {
return;
}

await waitForBackendHttpReady(backendHttpUrl);
writeDesktopLogHeader("bootstrap backend ready");
mainWindow = createWindow();
writeDesktopLogHeader("bootstrap main window created");
ensureInitialBackendWindowOpen();
}

app.on("before-quit", () => {
Expand Down Expand Up @@ -1922,7 +2022,11 @@ app
revealWindow(existingWindow);
return;
}
mainWindow = createWindow();
if (isDevelopment) {
mainWindow = createWindow();
return;
}
ensureInitialBackendWindowOpen();
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.
});
})
.catch((error) => {
Expand Down
Loading
Loading