Skip to content

Port desktop backend readiness checks to Effect#2546

Open
juliusmarminge wants to merge 4 commits intomainfrom
t3code/a1238be3
Open

Port desktop backend readiness checks to Effect#2546
juliusmarminge wants to merge 4 commits intomainfrom
t3code/a1238be3

Conversation

@juliusmarminge
Copy link
Copy Markdown
Member

@juliusmarminge juliusmarminge commented May 6, 2026

Summary

  • Reworked desktop backend readiness probing around Effect-based services and effects, with temporary promise shims preserved for existing call sites.
  • Added Effect/Vitest coverage for readiness polling, request timeouts, abort handling, and backend startup race behavior.
  • Updated the desktop main process to use typed URL handling for backend endpoints and the new readiness error type.

Testing

  • bun fmt
  • bun lint
  • bun typecheck
  • bun run test for apps/desktop/src/backendReadiness.test.ts and apps/desktop/src/backendStartupReadiness.test.ts

Note

Medium Risk
Touches desktop startup/process lifecycle and server config bootstrap parsing; regressions could prevent the backend from starting or the window from loading, though changes are scoped and covered by new Effect-based tests.

Overview
Replaces the desktop app’s Promise/Node-based backend readiness + child-process management with Effect-based implementations: waitForHttpReadyEffect (Effect HttpClient + Effect clock timeouts/retries) and a new runBackendProcess that spawns the server with a typed bootstrap payload over fd3 and reports readiness via the new probe.

Updates Electron main.ts to manage the backend via an Effect Fiber, switch backend URL handling to Option<URL>, and simplify window creation to occur on HTTP readiness (removing the log-based listening detector/startup race helpers). Adds a shared DesktopBackendBootstrap schema in @t3tools/contracts and updates the server CLI + tests to decode that schema from --bootstrap-fd.

Reviewed by Cursor Bugbot for commit b5c8ea4. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Port desktop backend readiness checks and process lifecycle to Effect

  • Replaces promise-based backend process management in apps/desktop with Effect fibers and layers; startBackend, stopBackend, and stopBackendAndWaitForExit are all rewritten to use Effect primitives.
  • Replaces waitForHttpReady (promise + AbortSignal) with waitForHttpReadyEffect using HttpClient, per-request timeouts, Schedule.spaced retry, and a tagged BackendTimeoutError.
  • Adds runBackendProcess in backendProcess.ts to orchestrate spawning, fd3 bootstrap JSON encoding, readiness probing, and structured exit reporting.
  • Moves the DesktopBackendBootstrap schema to a shared desktopBootstrap.ts in @t3tools/contracts; the server's resolveServerConfig now uses this schema instead of its own inline one.
  • Behavioral Change: devUrl, autoBootstrapProjectFromCwd, and logWebSocketEvents are no longer read from the bootstrap envelope by the server; the new readiness API no longer accepts an external AbortSignal or custom isReady predicate; the main window is now created only after backend readiness is signaled.

Macroscope summarized b5c8ea4.

- Replace promise-based readiness polling with Effect layers and services
- Update desktop startup flow to use URL-backed readiness state
- Add Effect-based tests for HTTP and startup readiness
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: d0086e69-da14-4a88-82d3-b55d3bf30b8e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch t3code/a1238be3

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size:L 100-499 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list. labels May 6, 2026
Comment thread apps/desktop/src/backendReadiness.ts
Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Autofix Details

Bugbot Autofix prepared a fix for the issue found in the latest run.

  • ✅ Fixed: Timeout silently succeeds instead of failing with error
    • Added Option.match after Effect.timeoutOption to convert Option.None (timeout) into a BackendTimeoutError failure instead of silently succeeding, matching the established pattern used in all other timeoutOption call sites in the codebase.

Create PR

Or push these changes by commenting:

@cursor push 27f2b65c9b
Preview (27f2b65c9b)
diff --git a/apps/desktop/src/backendReadiness.ts b/apps/desktop/src/backendReadiness.ts
--- a/apps/desktop/src/backendReadiness.ts
+++ b/apps/desktop/src/backendReadiness.ts
@@ -3,6 +3,7 @@
 import * as Data from "effect/Data";
 import * as Duration from "effect/Duration";
 import * as Effect from "effect/Effect";
+import * as Option from "effect/Option";
 import * as Predicate from "effect/Predicate";
 import * as Schedule from "effect/Schedule";
 import { HttpClient } from "effect/unstable/http";
@@ -60,9 +61,13 @@
   yield* client.get(requestUrl).pipe(
     Effect.asVoid,
     Effect.timeoutOption(timeout),
+    Effect.flatMap(
+      Option.match({
+        onNone: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
+        onSome: () => Effect.void,
+      }),
+    ),
     Effect.catchTags({
-      TimeoutError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
-      // Maybe map this to different error kind?
       HttpClientError: () => Effect.fail(new BackendTimeoutError({ url: baseUrl })),
     }),
   );

You can send follow-ups to the cloud agent here.

Comment thread apps/desktop/src/backendReadiness.ts
@macroscopeapp
Copy link
Copy Markdown
Contributor

macroscopeapp Bot commented May 6, 2026

Approvability

Verdict: Needs human review

1 blocking correctness issue found. Major refactor replacing Promise-based process management with Effect patterns, with unresolved review comments identifying a race condition bug in backend state management and concerns about timeout handling behavior.

You can customize Macroscope's approvability policy. Learn more.

@juliusmarminge
Copy link
Copy Markdown
Member Author

@cursor push 27f2b65

Effect.timeoutOption returns Option.None on timeout (on the success channel),
not a TimeoutError on the error channel. The previous code discarded the Option
result, so a timeout was treated as success. Now we explicitly match the Option:
None maps to BackendTimeoutError, Some maps to Effect.void. The now-unreachable
TimeoutError catchTag is removed.

Applied via @cursor push command
macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 6, 2026
@macroscopeapp macroscopeapp Bot dismissed their stale review May 6, 2026 07:20

Dismissing prior approval to re-evaluate d8f0d72

macroscopeapp[bot]
macroscopeapp Bot previously approved these changes May 6, 2026
- Spawn the backend through a shared Effect runner with fd3 bootstrap
- Replace ad hoc readiness/listening plumbing with HTTP readiness hooks
- Add contract support for desktop bootstrap payloads
@macroscopeapp macroscopeapp Bot dismissed their stale review May 6, 2026 07:59

Dismissing prior approval to re-evaluate b5c8ea4

@github-actions github-actions Bot added size:XL 500-999 changed lines (additions + deletions). and removed size:L 100-499 changed lines (additions + deletions). labels May 6, 2026
Comment thread apps/desktop/src/main.ts
Comment on lines +1443 to +1448
const clearStartedBackendState = (): void => {
if (backendProcessFiber === startedFiber) {
backendProcessFiber = null;
}
backendReady = false;
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High src/main.ts:1443

clearStartedBackendState unconditionally sets backendReady = false even when the fiber being finalized is not the current one. If stopBackend() is called and startBackend() is called again before the old fiber's finalizer runs, the old fiber's Effect.ensuring handler will incorrectly clear backendReady after the new backend has already signaled readiness via handleBackendReady(), causing the application to think the backend is not ready when it actually is.

  const clearStartedBackendState = (): void => {
     if (backendProcessFiber === startedFiber) {
       backendProcessFiber = null;
+      backendReady = false;
     }
-    backendReady = false;
   };
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file apps/desktop/src/main.ts around lines 1443-1448:

`clearStartedBackendState` unconditionally sets `backendReady = false` even when the fiber being finalized is not the current one. If `stopBackend()` is called and `startBackend()` is called again before the old fiber's finalizer runs, the old fiber's `Effect.ensuring` handler will incorrectly clear `backendReady` after the new backend has already signaled readiness via `handleBackendReady()`, causing the application to think the backend is not ready when it actually is.

Evidence trail:
apps/desktop/src/main.ts lines 1443-1447: `clearStartedBackendState` definition showing `backendReady = false` is outside the `if (backendProcessFiber === startedFiber)` guard.
apps/desktop/src/main.ts lines 1524-1527: `Effect.ensuring` handler that calls `finalizeBackendSession` which calls `clearStartedBackendState`.
apps/desktop/src/main.ts lines 1536-1544: `stopBackend()` sets `backendProcessFiber = null` and forks interrupt asynchronously.
apps/desktop/src/main.ts lines 1420-1421: `startBackend()` guard only checks `backendProcessFiber !== null`, allowing re-entry after `stopBackend()`.
apps/desktop/src/main.ts lines 516-517: `handleBackendReady()` sets `backendReady = true`.

Copy link
Copy Markdown
Contributor

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

Bugbot Autofix prepared fixes for both issues found in the latest run.

  • ✅ Fixed: Port fields lose range validation in bootstrap schema
    • Replaced Schema.Number with a shared PortSchema (Schema.Int with isBetween 1-65535) for both port and tailscaleServePort fields in DesktopBackendBootstrap, restoring the validation that was present in the old BootstrapEnvelopeSchema.
  • ✅ Fixed: Readiness timeout fires but no window ever created
    • Added fallback window creation in the onReadinessFailure handler so users get a window (showing reconnection state) instead of being stuck with no UI when the readiness probe times out.

Create PR

Or push these changes by commenting:

@cursor push 1291f41e07
Preview (1291f41e07)
diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts
--- a/apps/desktop/src/main.ts
+++ b/apps/desktop/src/main.ts
@@ -1496,6 +1496,12 @@
             `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
           );
           console.warn("[desktop] backend readiness check failed during bootstrap", error);
+
+          if (isDevelopment) return;
+          const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
+          if (existingWindow !== null) return;
+          mainWindow = createWindow();
+          writeDesktopLogHeader("bootstrap main window created (readiness timeout fallback)");
         }),
       onOutput: (streamName, chunk) =>
         Effect.sync(() => {

diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts
--- a/apps/server/src/cli.ts
+++ b/apps/server/src/cli.ts
@@ -5,6 +5,7 @@
   CommandId,
   DesktopBackendBootstrap,
   OrchestrationReadModel,
+  PortSchema,
   ProjectId,
   type ClientOrchestrationCommand,
 } from "@t3tools/contracts";
@@ -67,8 +68,6 @@
 import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
 import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
 
-const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
-
 const modeFlag = Flag.choice("mode", RuntimeMode.literals).pipe(
   Flag.withDescription("Runtime mode. `desktop` keeps loopback defaults unless overridden."),
   Flag.optional,

diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts
--- a/packages/contracts/src/baseSchemas.ts
+++ b/packages/contracts/src/baseSchemas.ts
@@ -5,6 +5,7 @@
 
 export const NonNegativeInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
 export const PositiveInt = Schema.Int.check(Schema.isGreaterThanOrEqualTo(1));
+export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
 
 export const IsoDateTime = Schema.String;
 export type IsoDateTime = typeof IsoDateTime.Type;

diff --git a/packages/contracts/src/desktopBootstrap.ts b/packages/contracts/src/desktopBootstrap.ts
--- a/packages/contracts/src/desktopBootstrap.ts
+++ b/packages/contracts/src/desktopBootstrap.ts
@@ -1,14 +1,16 @@
 import { Schema } from "effect";
 
+import { PortSchema } from "./baseSchemas.ts";
+
 export const DesktopBackendBootstrap = Schema.Struct({
   mode: Schema.Literal("desktop"),
   noBrowser: Schema.Boolean,
-  port: Schema.Number,
+  port: PortSchema,
   t3Home: Schema.String,
   host: Schema.String,
   desktopBootstrapToken: Schema.String,
   tailscaleServeEnabled: Schema.Boolean,
-  tailscaleServePort: Schema.Number,
+  tailscaleServePort: PortSchema,
   otlpTracesUrl: Schema.optional(Schema.String),
   otlpMetricsUrl: Schema.optional(Schema.String),
 });

You can send follow-ups to the cloud agent here.

Reviewed by Cursor Bugbot for commit b5c8ea4. Configure here.

host: Schema.String,
desktopBootstrapToken: Schema.String,
tailscaleServeEnabled: Schema.Boolean,
tailscaleServePort: Schema.Number,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Port fields lose range validation in bootstrap schema

Low Severity

The port and tailscaleServePort fields in DesktopBackendBootstrap use Schema.Number instead of the PortSchema (Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))) that was previously used in the old BootstrapEnvelopeSchema inside cli.ts. This means the server-side bootstrap decoder no longer validates that these values are integers within the valid port range, accepting fractional numbers, zero, negative values, or ports above 65535.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b5c8ea4. Configure here.

Effect.tap(() => options.onReady?.() ?? Effect.void),
Effect.catch((error) => options.onReadinessFailure?.(error) ?? Effect.void),
Effect.forkScoped,
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Readiness timeout fires but no window ever created

Medium Severity

When the readiness probe times out (after 1 minute), onReadinessFailure logs a warning but the process keeps running. Meanwhile backendReady stays false, so handleBackendReady never fires and no window is created. The process continues indefinitely without scheduling a restart or creating a window. In the old code, the timeout would log and the ensureInitialBackendWindowOpen promise chain would end—but the process also kept running, so this is comparable. However, the new code's Effect.match onSuccess handler only fires when the process exits, meaning a backend that starts serving but takes longer than the timeout window will leave the user staring at nothing with no recovery path until the process happens to exit.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit b5c8ea4. Configure here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:trusted PR author is trusted by repo permissions or the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants