From a4333342a915db54276f110a4d3dcea208c9a3e7 Mon Sep 17 00:00:00 2001 From: chrismin13 <17803045+chrismin13@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:23:58 +0300 Subject: [PATCH 1/2] perf(desktop): speed up packaged WSL startup --- .../backend/DesktopBackendConfiguration.ts | 17 ++--- .../src/backend/DesktopBackendManager.ts | 66 ++++++++++++++----- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 23 +++---- apps/server/src/persistence/Migrations.ts | 10 ++- apps/server/src/server.ts | 10 ++- apps/server/vite.config.test.ts | 26 ++++++++ apps/server/vite.config.ts | 39 ++++++++++- 7 files changed, 149 insertions(+), 42 deletions(-) create mode 100644 apps/server/vite.config.test.ts diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index bfb9d6900e5..dccd91aebf0 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -447,8 +447,8 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl noBrowser: true, port: input.port, // Omit t3Home so the Linux backend uses its own home dir instead of - // the Windows-side baseDir (which would be a /mnt/c path and share - // the SQLite file with the primary). + // the Windows-side baseDir, which would share the primary's SQLite file + // through the Windows filesystem mount. host: wslBindHost, desktopBootstrapToken: input.bootstrapToken, // PortSchema rejects 0, so when tailscale serve is disabled we still @@ -468,9 +468,10 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // archive FILE. The Windows primary reads its entry through // ELECTRON_RUN_AS_NODE (asar-aware), but the WSL backend launches plain // `wsl.exe -- node`, which can't read inside an asar. electron-builder unpacks - // the server bundle + node-pty (see asarUnpack in build-desktop-artifact.ts) - // to the app.asar.unpacked sibling, so point WSL there. In dev appRoot is - // already a real directory, so this is a no-op. + // the server bundle and intentionally external runtime dependencies (see + // asarUnpack in build-desktop-artifact.ts) to the app.asar.unpacked sibling, + // so point WSL there. In dev appRoot is already a real directory, so this is + // a no-op. const wslAppRoot = environment.isPackaged ? environment.path.join(environment.resourcesPath, "app.asar.unpacked") : environment.appRoot; @@ -522,9 +523,9 @@ const resolveWslStartConfig = Effect.fn("desktop.backendConfiguration.resolveWsl // Build an explicit copy of process.env minus T3CODE_HOME (dev-runner // exports the Windows-side base dir for the primary; if it leaks into - // the WSL backend the Linux side ends up sharing C:\Users\...\.t3 via - // /mnt/c, which means both backends read/write the same database and - // their env-ids collide). + // the WSL backend the Linux side reaches the same .t3 directory through + // the Windows filesystem mount, so both backends write the same database + // and their environment IDs collide). const parentEnvWithoutT3Home: Record = {}; for (const [key, value] of Object.entries(process.env)) { if (key === "T3CODE_HOME") continue; diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index b50c7a55ed7..add3d6da51b 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -25,6 +25,7 @@ import * as Brand from "effect/Brand"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; @@ -89,7 +90,7 @@ export interface DesktopBackendStartConfig extends BackendProcessContext { readonly env: Record; // When true the spawner merges the desktop process.env on top of `env`; // when false `env` is passed verbatim. WSL mode opts out so a leaking - // T3CODE_HOME can't pin the WSL backend to /mnt/c/...\.t3. + // T3CODE_HOME can't pin the WSL backend to a Windows-mounted .t3 directory. readonly extendEnv: boolean; readonly bootstrap: DesktopBackendBootstrapValue; readonly bootstrapDelivery: DesktopBackendBootstrapDelivery; @@ -636,8 +637,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const state = yield* Ref.make(initialState); const mutex = yield* Semaphore.make(1); - const { logWarning: logInstanceWarning, logError: logInstanceError } = - DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); + const { + logInfo: logInstanceInfo, + logWarning: logInstanceWarning, + logError: logInstanceError, + } = DesktopObservability.makeComponentLogger(`desktop-backend-instance:${spec.id}`); const updateActiveRun = (runId: number, f: (run: ActiveBackendRun) => ActiveBackendRun) => Ref.update(state, withActiveRun(runId, f)); @@ -886,6 +890,9 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ); }); + const runStartedAt = yield* Clock.currentTimeMillis; + const elapsedMs = Clock.currentTimeMillis.pipe(Effect.map((now) => now - runStartedAt)); + const program = runBackendProcess({ ...config.value, desktopTelemetryStream: desktopTelemetryPublisher.encoded, @@ -896,6 +903,15 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ...run, pid: Option.some(pid), })); + yield* logInstanceInfo("backend process spawned", { + pid, + port: config.value.bootstrap.port, + cwd: config.value.cwd, + entryPath: config.value.entryPath, + executablePath: config.value.executablePath, + bootstrapDelivery: config.value.bootstrapDelivery, + elapsedMs: yield* elapsedMs, + }); yield* backendOutputLog.beginSession({ details: `pid=${pid} port=${config.value.bootstrap.port} cwd=${config.value.cwd}`, }); @@ -906,31 +922,49 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( exitObserved: true, })), onReady: Effect.fn("desktop.backendInstance.onReady")(function* () { - const isCurrentRun = yield* Ref.modify(state, (latest) => { - const activeRun = Option.getOrUndefined(latest.active); - if (activeRun?.id !== runId) { - return [false, latest] as const; - } - - return [ - true, + const { isCurrentRun, pid } = yield* Ref.modify( + state, + ( + latest, + ): readonly [ { - ...latest, - restartAttempt: 0, - ready: true, + readonly isCurrentRun: boolean; + readonly pid: Option.Option; }, - ] as const; - }); + BackendManagerState, + ] => { + const activeRun = Option.getOrUndefined(latest.active); + if (activeRun?.id !== runId) { + return [{ isCurrentRun: false, pid: Option.none() }, latest] as const; + } + + return [ + { isCurrentRun: true, pid: activeRun.pid }, + { + ...latest, + restartAttempt: 0, + ready: true, + }, + ] as const; + }, + ); if (!isCurrentRun) { return; } + yield* logInstanceInfo("backend readiness succeeded", { + pid: Option.getOrUndefined(pid), + httpBaseUrl: config.value.httpBaseUrl.href, + elapsedMs: yield* elapsedMs, + }); yield* spec.onReady?.(config.value.httpBaseUrl) ?? Effect.void; }), onReadinessFailure: Effect.fn("desktop.backendInstance.onReadinessFailure")( function* (error) { yield* logInstanceWarning("backend readiness check failed during bootstrap", { error: error.message, + httpBaseUrl: config.value.httpBaseUrl.href, + elapsedMs: yield* elapsedMs, }); yield* backendOutputLog.persistFailureSnapshot({ details: error.message, diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index c6c274d8500..b185b56e35b 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -229,14 +229,12 @@ const NODE_PTY_PROBE_SCRIPT = ( printf 'nodeVersion:%s\\n' "$(node -p 'process.versions.node' 2>/dev/null)" printf 'resolvedPath:%s\\n' "$PATH" cd ${shellQuote(linuxServerDir)} && node <<'NODE' >/dev/null 2>&1 -// The server bundle externalizes its deps to node_modules, and the WSL Node -// can't read inside app.asar, so confirm those deps are unpacked on the real -// filesystem before reporting the backend healthy. "effect" is the framework -// every server module imports; resolving it validates the whole node_modules -// tree. Exit 3 marks this distinct from a node-pty problem so the caller can -// report it accurately instead of letting the server crash on -// ERR_MODULE_NOT_FOUND at launch (which, in wsl-only mode, would just fail to -// launch with no fallback). +// The pure-JS startup graph is bundled, but native and asset-owning runtime +// dependencies remain in node_modules. WSL Node can't read inside app.asar, so +// confirm the unpacked production dependency tree is reachable before reporting +// the backend healthy. "effect" is a stable direct dependency and inexpensive +// resolution sentinel; node-pty is loaded and validated below. Exit 3 marks a +// missing dependency tree distinctly from a node-pty compatibility problem. try { require.resolve("effect"); } catch (_e) { process.exit(3); } const fs = require("node:fs"); const path = require("node:path"); @@ -462,11 +460,10 @@ const ensureNodePtyImpl = ( } as const; } - // Server dependencies (e.g. "effect") couldn't be resolved on the WSL - // filesystem — a packaging regression, since the server bundle needs its - // node_modules unpacked from the asar. Fatal so wsl-only mode falls back to - // Windows and dual mode surfaces the reason inline, instead of the server - // crash-looping on ERR_MODULE_NOT_FOUND once it actually launches. + // The external production dependency tree couldn't be resolved on the WSL + // filesystem. This is a packaging regression: intentionally external native + // and asset-owning packages must be unpacked from the asar. Fatal so wsl-only + // mode falls back to Windows and dual mode surfaces the reason inline. if (probe.exitCode === 3) { return { ok: false, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbed..7c75fdc3a35 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -124,6 +124,8 @@ export const makeMigrationLoader = (throughId?: number) => */ const run = Migrator.make({}); +const processUptimeMs = () => Math.round(process.uptime() * 1000); + export interface RunMigrationsOptions { readonly toMigrationInclusive?: number | undefined; } @@ -143,9 +145,11 @@ export const runMigrations = Effect.fn("runMigrations")(function* ({ }: RunMigrationsOptions = {}) { const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) }); const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`); - yield* migrations.length === 0 - ? Effect.logDebug("Database schema is current") - : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); + const migrationLog = + migrations.length === 0 + ? Effect.logDebug("Database schema is current") + : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations })); + yield* migrationLog.pipe(Effect.annotateLogs({ processUptimeMs: processUptimeMs() })); return executedMigrations; }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 32bcaaa8b96..eea4046eda7 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -127,6 +127,8 @@ const ApplicationObservabilityLive = ObservabilityLive.pipe( Layer.provideMerge(ResourceAttributionLayerLive), ); +const processUptimeMs = () => Math.round(process.uptime() * 1000); + const PtyAdapterLive = Layer.unwrap( Effect.gen(function* () { if (typeof Bun !== "undefined") { @@ -482,7 +484,13 @@ export const makeServerLayer = Layer.unwrap( const httpListeningLayer = Layer.effectDiscard( Effect.gen(function* () { - yield* HttpServer.HttpServer; + const server = yield* HttpServer.HttpServer; + const address = server.address; + yield* Effect.logInfo("HTTP server listening", { + address: HttpServer.formatAddress(address), + port: address._tag === "TcpAddress" ? address.port : undefined, + processUptimeMs: processUptimeMs(), + }); const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; yield* startup.markHttpListening; }), diff --git a/apps/server/vite.config.test.ts b/apps/server/vite.config.test.ts new file mode 100644 index 00000000000..755721939ae --- /dev/null +++ b/apps/server/vite.config.test.ts @@ -0,0 +1,26 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { shouldBundleCliDependency } from "./vite.config.ts"; + +describe("shouldBundleCliDependency", () => { + it("bundles direct dependencies and their subpaths", () => { + assert.isTrue(shouldBundleCliDependency("effect")); + assert.isTrue(shouldBundleCliDependency("effect/Effect")); + assert.isTrue(shouldBundleCliDependency("@opencode-ai/sdk/client")); + }); + + it("bundles workspace dependencies", () => { + assert.isTrue(shouldBundleCliDependency("@t3tools/shared/httpReadiness")); + assert.isTrue(shouldBundleCliDependency("effect-acp")); + }); + + it("keeps packages that need runtime files external", () => { + assert.isFalse(shouldBundleCliDependency("node-pty")); + assert.isFalse(shouldBundleCliDependency("@anthropic-ai/claude-agent-sdk/cli.js")); + assert.isFalse(shouldBundleCliDependency("node-pty/lib/index.js")); + }); + + it("does not bundle unknown packages", () => { + assert.isFalse(shouldBundleCliDependency("not-a-server-dependency")); + }); +}); diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 521654f3279..70f532d101f 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -12,8 +12,45 @@ const bundledPackagePrefixes = [ "effect-codex-app-server", ]; +// WSL loads the packaged CLI through a Windows filesystem mount resolved by +// wslpath (commonly /mnt/c). Resolving a large dependency graph there is +// substantially slower than reading a few bundled chunks. Bundle direct runtime +// dependencies by default so adding a normal JS dependency cannot silently +// regress startup. Exceptions own native binaries, target another runtime, or +// resolve package assets at runtime and must stay in node_modules. +const externalRuntimePackageNames = new Set([ + // Resolves its CLI and other files from the installed package. + "@anthropic-ai/claude-agent-sdk", + + // Loaded only when the server runs under Bun. + "@effect/platform-bun", + "@effect/sql-sqlite-bun", + + // Load platform-specific native binaries from node_modules. + "@ff-labs/fff-node", + "node-pty", +]); +const runtimePackageNames = new Set(Object.keys(packageJson.dependencies)); + +function packageNameFromId(id: string): string { + if (id.startsWith("@")) { + const [scope, name] = id.split("/"); + return scope && name ? `${scope}/${name}` : id; + } + + return id.split("/")[0] ?? id; +} + export function shouldBundleCliDependency(id: string): boolean { - return bundledPackagePrefixes.some((prefix) => id.startsWith(prefix)); + const packageName = packageNameFromId(id); + if (externalRuntimePackageNames.has(packageName)) { + return false; + } + + return ( + runtimePackageNames.has(packageName) || + bundledPackagePrefixes.some((prefix) => id.startsWith(prefix)) + ); } const repoEnv = loadRepoEnv(); From bc7f7f17549a24c668264f5db212869af7b24c31 Mon Sep 17 00:00:00 2001 From: chrismin13 <17803045+chrismin13@users.noreply.github.com> Date: Wed, 12 Aug 2026 16:35:22 +0300 Subject: [PATCH 2/2] fix(desktop): use monotonic startup timing --- apps/desktop/src/backend/DesktopBackendManager.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index add3d6da51b..92f997d33a5 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -890,8 +890,10 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( ); }); - const runStartedAt = yield* Clock.currentTimeMillis; - const elapsedMs = Clock.currentTimeMillis.pipe(Effect.map((now) => now - runStartedAt)); + const runStartedAt = yield* Clock.currentTimeNanos; + const elapsedMs = Clock.currentTimeNanos.pipe( + Effect.map((now) => Duration.toMillis(Duration.nanos(now - runStartedAt))), + ); const program = runBackendProcess({ ...config.value,