fix(desktop): race existing connections against bootstrap for faster startup
The Electron startup blocked on a ~2.6s synchronous CLI status check before showing the app. Now the host runtime controllers (which probe persisted connections) race against the bootstrap path — if the daemon is already running, the UI comes online in milliseconds. Also converts the desktop CLI spawn calls from spawnSync to async spawn so the Electron main thread is no longer blocked during daemon status checks.
This commit is contained in:
@@ -230,6 +230,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let cancelAnyOnline: (() => void) | null = null;
|
||||||
const shouldManageDesktop = shouldUseDesktopDaemon();
|
const shouldManageDesktop = shouldUseDesktopDaemon();
|
||||||
const store = getHostRuntimeStore();
|
const store = getHostRuntimeStore();
|
||||||
|
|
||||||
@@ -240,28 +241,53 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
if (isDesktopManaged) {
|
if (isDesktopManaged) {
|
||||||
setPhase("starting-daemon");
|
setPhase("starting-daemon");
|
||||||
setError(null);
|
setError(null);
|
||||||
const bootstrapResult = await store.bootstrapDesktop();
|
|
||||||
if (!bootstrapResult.ok) {
|
let raceSettled = false;
|
||||||
if (!cancelled) {
|
|
||||||
setPhase("error");
|
const anyOnline = store.waitForAnyConnectionOnline();
|
||||||
setError(bootstrapResult.error);
|
cancelAnyOnline = anyOnline.cancel;
|
||||||
|
|
||||||
|
const bootstrapPromise = (async (): Promise<
|
||||||
|
{ type: "online" } | { type: "error"; error: string }
|
||||||
|
> => {
|
||||||
|
try {
|
||||||
|
const bootstrapResult = await store.bootstrapDesktop();
|
||||||
|
if (!bootstrapResult.ok) {
|
||||||
|
return { type: "error", error: bootstrapResult.error };
|
||||||
|
}
|
||||||
|
if (!cancelled && !raceSettled) {
|
||||||
|
setPhase("connecting");
|
||||||
|
}
|
||||||
|
await store.addConnectionFromListenAndWaitForOnline({
|
||||||
|
listenAddress: bootstrapResult.listenAddress,
|
||||||
|
serverId: bootstrapResult.serverId,
|
||||||
|
hostname: bootstrapResult.hostname,
|
||||||
|
});
|
||||||
|
return { type: "online" };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
type: "error",
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return;
|
})();
|
||||||
}
|
|
||||||
|
|
||||||
if (cancelled) {
|
const result = await Promise.race([
|
||||||
return;
|
anyOnline.promise.then((): { type: "online" } => ({ type: "online" })),
|
||||||
}
|
bootstrapPromise,
|
||||||
|
]);
|
||||||
|
|
||||||
|
raceSettled = true;
|
||||||
|
anyOnline.cancel();
|
||||||
|
|
||||||
setPhase("connecting");
|
|
||||||
await store.addConnectionFromListenAndWaitForOnline({
|
|
||||||
listenAddress: bootstrapResult.listenAddress,
|
|
||||||
serverId: bootstrapResult.serverId,
|
|
||||||
hostname: bootstrapResult.hostname,
|
|
||||||
});
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setPhase("online");
|
if (result.type === "online") {
|
||||||
setError(null);
|
setPhase("online");
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setPhase("error");
|
||||||
|
setError(result.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
void store.bootstrap({ manageBuiltInDaemon: settings.manageBuiltInDaemon });
|
||||||
@@ -288,6 +314,7 @@ function HostRuntimeBootstrapProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
cancelAnyOnline?.();
|
||||||
};
|
};
|
||||||
}, [retryToken]);
|
}, [retryToken]);
|
||||||
|
|
||||||
|
|||||||
@@ -1681,6 +1681,41 @@ export class HostRuntimeStore {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
waitForAnyConnectionOnline(): { promise: Promise<void>; cancel: () => void } {
|
||||||
|
let unsubscribe: (() => void) | null = null;
|
||||||
|
|
||||||
|
const isAnyOnline = (): boolean => {
|
||||||
|
for (const host of this.hosts) {
|
||||||
|
const snapshot = this.getSnapshot(host.serverId);
|
||||||
|
if (snapshot?.connectionStatus === "online") return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const promise = new Promise<void>((resolve) => {
|
||||||
|
if (isAnyOnline()) {
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
unsubscribe = this.subscribeAll(() => {
|
||||||
|
if (isAnyOnline()) {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
promise,
|
||||||
|
cancel: () => {
|
||||||
|
unsubscribe?.();
|
||||||
|
unsubscribe = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
ensureConnectedAll(): void {
|
ensureConnectedAll(): void {
|
||||||
for (const controller of this.controllers.values()) {
|
for (const controller of this.controllers.values()) {
|
||||||
controller.ensureConnected();
|
controller.ensureConnected();
|
||||||
|
|||||||
@@ -186,11 +186,14 @@ function resolveDesktopAppVersion(): string {
|
|||||||
// Daemon lifecycle
|
// Daemon lifecycle
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
function resolveStatus(): DesktopDaemonStatus {
|
async function resolveStatus(): Promise<DesktopDaemonStatus> {
|
||||||
const home = getPaseoHome();
|
const home = getPaseoHome();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = runCliJsonCommand(["daemon", "status", "--json"]) as Record<string, unknown>;
|
const payload = (await runCliJsonCommand(["daemon", "status", "--json"])) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>;
|
||||||
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
const localDaemon = typeof payload.localDaemon === "string" ? payload.localDaemon : "stopped";
|
||||||
const running = localDaemon === "running";
|
const running = localDaemon === "running";
|
||||||
|
|
||||||
@@ -227,7 +230,7 @@ function normalizeVersion(version: string | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function startDaemon(): Promise<DesktopDaemonStatus> {
|
async function startDaemon(): Promise<DesktopDaemonStatus> {
|
||||||
const current = resolveStatus();
|
const current = await resolveStatus();
|
||||||
if (current.status === "running") {
|
if (current.status === "running") {
|
||||||
const appVersion = normalizeVersion(resolveDesktopAppVersion());
|
const appVersion = normalizeVersion(resolveDesktopAppVersion());
|
||||||
const daemonVersion = normalizeVersion(current.version);
|
const daemonVersion = normalizeVersion(current.version);
|
||||||
@@ -320,7 +323,7 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
|
|
||||||
// Poll for PID file with server ID
|
// Poll for PID file with server ID
|
||||||
for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) {
|
for (let attempt = 0; attempt < STARTUP_POLL_MAX_ATTEMPTS; attempt++) {
|
||||||
const status = resolveStatus();
|
const status = await resolveStatus();
|
||||||
if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) {
|
if (attempt === 0 || attempt === STARTUP_POLL_MAX_ATTEMPTS - 1 || attempt % 10 === 9) {
|
||||||
logDesktopDaemonLifecycle("polling daemon status after detached start", {
|
logDesktopDaemonLifecycle("polling daemon status after detached start", {
|
||||||
attempt: attempt + 1,
|
attempt: attempt + 1,
|
||||||
@@ -334,11 +337,11 @@ async function startDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
await sleep(STARTUP_POLL_INTERVAL_MS);
|
await sleep(STARTUP_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveStatus();
|
return await resolveStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stopDaemon(): Promise<DesktopDaemonStatus> {
|
async function stopDaemon(): Promise<DesktopDaemonStatus> {
|
||||||
const status = resolveStatus();
|
const status = await resolveStatus();
|
||||||
if (status.status !== "running" || !status.pid) return status;
|
if (status.status !== "running" || !status.pid) return status;
|
||||||
|
|
||||||
const pid = status.pid;
|
const pid = status.pid;
|
||||||
@@ -354,7 +357,7 @@ async function stopDaemon(): Promise<DesktopDaemonStatus> {
|
|||||||
throw new Error(`Timed out waiting for daemon PID ${pid} to stop`);
|
throw new Error(`Timed out waiting for daemon PID ${pid} to stop`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveStatus();
|
return await resolveStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function restartDaemon(): Promise<DesktopDaemonStatus> {
|
async function restartDaemon(): Promise<DesktopDaemonStatus> {
|
||||||
@@ -370,12 +373,12 @@ function getDaemonLogs(): DesktopDaemonLogs {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCliDaemonStatus(): string {
|
async function getCliDaemonStatus(): Promise<string> {
|
||||||
return runCliTextCommand(["daemon", "status"]);
|
return await runCliTextCommand(["daemon", "status"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
|
async function getDaemonPairing(): Promise<DesktopPairingOffer> {
|
||||||
const status = resolveStatus();
|
const status = await resolveStatus();
|
||||||
if (status.status !== "running") {
|
if (status.status !== "running") {
|
||||||
return {
|
return {
|
||||||
relayEnabled: false,
|
relayEnabled: false,
|
||||||
@@ -385,7 +388,7 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const payload = runCliJsonCommand(["daemon", "pair", "--json"]);
|
const payload = await runCliJsonCommand(["daemon", "pair", "--json"]);
|
||||||
if (!isRecord(payload)) {
|
if (!isRecord(payload)) {
|
||||||
throw new Error("Daemon pairing response was not an object.");
|
throw new Error("Daemon pairing response was not an object.");
|
||||||
}
|
}
|
||||||
@@ -404,8 +407,8 @@ async function getDaemonPairing(): Promise<DesktopPairingOffer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLocalDaemonVersion(): { version: string | null; error: string | null } {
|
async function getLocalDaemonVersion(): Promise<{ version: string | null; error: string | null }> {
|
||||||
const status = resolveStatus();
|
const status = await resolveStatus();
|
||||||
if (status.status !== "running") {
|
if (status.status !== "running") {
|
||||||
return { version: null, error: "Daemon is not running." };
|
return { version: null, error: "Daemon is not running." };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { app } from "electron";
|
import { app } from "electron";
|
||||||
@@ -246,44 +246,60 @@ export function runCliPassthroughCommand(args: string[]): number {
|
|||||||
return result.signal ? 1 : 0;
|
return result.signal ? 1 : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runCliTextCommand(args: string[]): string {
|
function spawnAsync(
|
||||||
const invocation = createCliInvocation(args);
|
command: string,
|
||||||
const result = spawnSync(invocation.command, invocation.args, {
|
args: string[],
|
||||||
env: invocation.env,
|
options: { env: NodeJS.ProcessEnv },
|
||||||
encoding: "utf-8",
|
): Promise<{ stdout: string; stderr: string; exitCode: number | null }> {
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
env: options.env,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
|
||||||
|
child.stdout.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
child.stderr.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("error", reject);
|
||||||
|
child.on("close", (exitCode) => {
|
||||||
|
resolve({ stdout, stderr, exitCode });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.status !== 0) {
|
|
||||||
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
|
||||||
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return typeof result.stdout === "string" ? result.stdout.trimEnd() : "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function runCliJsonCommand(args: string[]): unknown {
|
export async function runCliTextCommand(args: string[]): Promise<string> {
|
||||||
const invocation = createCliInvocation(args);
|
const invocation = createCliInvocation(args);
|
||||||
const result = spawnSync(invocation.command, invocation.args, {
|
const result = await spawnAsync(invocation.command, invocation.args, {
|
||||||
env: invocation.env,
|
env: invocation.env,
|
||||||
encoding: "utf-8",
|
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.exitCode !== 0) {
|
||||||
throw result.error;
|
const stderr = result.stderr.trim();
|
||||||
|
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (result.status !== 0) {
|
return result.stdout.trimEnd();
|
||||||
const stderr = typeof result.stderr === "string" ? result.stderr.trim() : "";
|
}
|
||||||
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.status}`);
|
|
||||||
|
export async function runCliJsonCommand(args: string[]): Promise<unknown> {
|
||||||
|
const invocation = createCliInvocation(args);
|
||||||
|
const result = await spawnAsync(invocation.command, invocation.args, {
|
||||||
|
env: invocation.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.exitCode !== 0) {
|
||||||
|
const stderr = result.stderr.trim();
|
||||||
|
throw new Error(stderr.length > 0 ? stderr : `CLI command failed with exit code ${result.exitCode}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stdout = typeof result.stdout === "string" ? result.stdout.trim() : "";
|
const stdout = result.stdout.trim();
|
||||||
if (stdout.length === 0) {
|
if (stdout.length === 0) {
|
||||||
throw new Error("CLI command did not produce JSON output.");
|
throw new Error("CLI command did not produce JSON output.");
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user