Skip to content

Add desktop WSL backend mode#2353

Open
Jgratton24 wants to merge 13 commits intopingdotgg:mainfrom
Jgratton24:josh/desktop-wsl-backend
Open

Add desktop WSL backend mode#2353
Jgratton24 wants to merge 13 commits intopingdotgg:mainfrom
Jgratton24:josh/desktop-wsl-backend

Conversation

@Jgratton24
Copy link
Copy Markdown

@Jgratton24 Jgratton24 commented Apr 26, 2026

What Changed

Adds an opt-in Windows desktop mode that keeps the Electron UI native while launching the local T3 Code backend inside WSL. Scoped to the desktop backend lifecycle path — complements rather than replaces the broader WSL-hosted interop work in #170.

Concretely:

  • New apps/desktop/src/wsl.ts module: WSL availability detection, distro discovery, async wslpath conversion, persisted wsl-config.json with strict distro-name validation.
  • Backend launcher in main.ts spawns through wsl.exe -- node <linuxEntry> --bootstrap-fd 0 when WSL mode is enabled. Bootstrap envelope is sent on stdin (extra stdio fds do not reliably survive the wsl.exe bridge); t3Home is omitted so the Linux backend uses its own home directory.
  • New IPC channels (wsl-list-distros, wsl-get-config, wsl-set-config) wired through the preload bridge.
  • New WSL backend section in ConnectionsSettings.tsx with a toggle and distro selector.
  • Async wslpath conversion for the bundled backend entry and folder picker selections; failed conversions fail closed instead of returning Windows paths to the WSL backend.
  • Windows process environment is preserved when spawning wsl.exe; selected API keys are forwarded via WSLENV.
  • Backend lifecycle: WSL config update awaits the old backend's exit before relaunch, drains any in-flight startBackend awaiting wslpath, and rolls back the persisted config if the new backend fails to start.
  • Folder picker preserves caller initialPath (Linux, ~, UNC) and runs wslpath inside the picked path's distro when it differs from the configured one; default UNC path resolves the actual default distro instead of hardcoding Ubuntu.
  • Server bootstrap EACCES is now treated as a duplication error so the /proc/self/fd/<fd> fallback path applies under WSL.

Why

Closes #2346. Running the desktop app on Windows currently means launching the backend directly under Windows, which forces users with a WSL-based dev setup to either run the desktop app inside WSL (no native UX) or fall back to the web UI. This change keeps the Electron UI native on Windows while letting the backend run alongside the user's existing Linux toolchain.

The PR is size:XL, but ~75% of the diff is self-contained: the new wsl.ts helper (224 LOC) plus its tests (251 LOC) plus a localized UI section (130 LOC). The surgery in main.ts is the irreducible core — async path conversion, env forwarding, and the WSL-aware lifecycle gates. There is no straightforward way to split this without either shipping a permanently-disabled feature flag or merging the UI before the backend works behind it.

UI Changes

Adds a "WSL backend" section to the Connections settings panel: a design-system <Select> for the distro (populated from wsl.exe --list --verbose) plus a toggle for enabling WSL mode. Flipping the toggle opens an AlertDialog confirming the swap, which surfaces phased loading text ("Restarting backend…" → "Re-establishing session…") while the backend restarts and the renderer re-bootstraps. Toasts surface success and error states.

WSL backend off

WSL backend off

WSL backend on with Ubuntu selected

WSL backend on

Confirmation dialog before a swap

Enable WSL backend confirmation dialog

The dialog sets the cold-start time expectation and notes that each backend keeps its own threads — flipping back returns the original list rather than wiping it.

Phased loading during a swap

Restarting backend loading state

Verification

  • bun test apps/desktop/src/wsl.test.ts apps/desktop/src/backendReadiness.test.ts
  • bun run typecheck
  • bun run build:desktop
  • bun run lint clean on apps/desktop/src

I also exercised the WSL launch path locally on Windows / Ubuntu. The backend process accepted the stdin bootstrap and created Linux-side state/log files under /tmp/t3code-wsl-smoke-final. A separate headless loopback probe did not observe HTTP readiness before my command timeout in this environment, so the runtime smoke is partial rather than a full end-to-end UI pass.

Updates since first review

Follow-up commits on top of f8f22994:

  • Reconcile renderer state on backend swap. Each backend has its own environment-id, so toggling WSL ⇄ Windows used to leave the previous backend's threads/projects sitting in the store. The renderer now drops the previous env's slice from environmentStateById, disposes the prior EnvironmentConnection, and navigates off any /<oldEnvId>/<threadId> route the user was sitting on. startServerStateSync is keyed to the live primary id so it follows the swap.
  • Re-authenticate the desktop session after a swap. Each backend signs sessions with its own key, so the renderer's cookie 401s the new backend — including the WS upgrade, which on desktop primary authenticates via cookie (no wsToken query param). Added reauthenticatePrimaryEnvironment() that re-runs the desktop bootstrap exchange against the new backend before any reconnect, and bumped BOOTSTRAP_RETRY_TIMEOUT_MS from 15s to 60s to cover cold WSL launches. WS connection-status events are suppressed during the deliberate swap window so the UI doesn't flash "disconnected".
  • Backend-lifecycle hardening. Concurrent WSL toggle requests are serialized via a wslConfigUpdateInFlight chain in main.ts. The wsl-config.json rollback fires on synchronous throws inside startBackend (not just async failures) and now also stops a backend that started but failed its readiness wait, then relaunches under the previous config so runtime and persisted state stay aligned. The node-pty rebuild step is gated on !app.isPackaged. The wslpath timeout is bumped from 3s to 10s so cold-VM starts don't surface as conversion failures.
  • Confirmation dialog and phased loading. Flipping the WSL toggle now opens an AlertDialog (see screenshots above) with the cold-start time expectation and a note that each backend keeps its own threads. The dialog button transitions through Restarting backend…Re-establishing session… so the longer cold-launch path has visible progress instead of a single static spinner.
  • UX polish in the Connections settings panel. The distro dropdown stays editable when WSL is off (selection stages locally, no backend churn). The native <select> was swapped for the design-system <Select> so the chevron lines up with the rest of the panel.
  • Misc fixes from latest bot review. scheduleBackendRestart now .catch()es synchronous rejections from the timer-fired startBackend() so they can't surface as unhandled rejections. runWslShell kills its wsl.exe child on stdin error/write failure (the timeout already did, but the error paths didn't). suppressWsConnectionLifecycle only resets the connection state on the outermost depth transition so a future nested caller can't wipe state mid-flight.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes
  • I included a video for animation/interaction changes

Note

Add WSL backend mode to the desktop app

  • Adds a new wsl.ts module with utilities for detecting WSL availability, listing distros, converting Windows paths via wslpath, executing bash scripts inside WSL, and building/staging a Linux node-pty binary when missing.
  • Extends main.ts to launch the backend process inside WSL when enabled, forwarding select env vars via WSLENV and writing the bootstrap envelope via stdin instead of fd 3.
  • Adds three IPC channels (wslListDistros, wslGetConfig, wslSetConfig) wired through preload and exposed on DesktopBridge in ipc.ts.
  • Adds a 'WSL backend' section in ConnectionsSettings.tsx where users can enable WSL mode and select a distro; changes go through a confirmation dialog, restart the backend, refresh the environment descriptor, and reauthenticate.
  • Adds websocket lifecycle suppression (suppressWsConnectionLifecycle) so in-progress reconnect logic is paused during backend restarts triggered by WSL config changes.
  • Risk: switching WSL mode stops and restarts the backend; if the restart fails the config is rolled back, but there is a window where the app may be temporarily disconnected.

Macroscope summarized 443ec84.


Note

Medium Risk
Medium risk: changes backend launch/restart flow (including spawning via wsl.exe, config persistence/rollback, and bootstrap pipe handling) and adjusts renderer session/WS lifecycle to handle backend identity swaps, which could impact startup reliability and connectivity on Windows.

Overview
Adds an opt-in Windows desktop WSL backend mode that can launch the local backend inside a selected WSL distribution, including path conversion (wslpath), environment forwarding, and a one-time node-pty Linux build/staging helper.

The desktop main process now exposes WSL management over IPC (wsl-list-distros, wsl-get-config, wsl-set-config), persists wsl-config.json, serializes/rolls back config changes on failure, and makes folder picking WSL-aware (UNC default paths + Windows→WSL conversion).

On the web UI, Connections settings gains a WSL toggle + distro selector that restarts the backend and then refreshes the primary environment descriptor and reauthenticates to obtain a new session cookie; websocket lifecycle reporting is suppressible during the swap, and the app now disconnects/clears old primary-environment state when the backend’s environment id changes. Also includes small robustness fixes (server bootstrap error handling under WSL, longer bootstrap retry timeout, and minor readiness/favicon/test updates).

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

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 26, 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: 39e79857-d030-4bbb-a5c5-0d086dd45438

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:L 100-499 changed lines (additions + deletions). labels Apr 26, 2026
Comment thread apps/desktop/src/main.ts Outdated
Comment thread apps/desktop/src/main.ts
Comment thread apps/desktop/src/main.ts
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a574cbb5d0

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

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

macroscopeapp Bot commented Apr 26, 2026

Approvability

Verdict: Needs human review

This PR adds a complete new feature enabling WSL backend mode on Windows, including UI controls, process spawning logic, and session reauthentication. The scope and complexity of this new capability warrants careful human review.

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

Comment thread apps/desktop/src/backendReadiness.ts Outdated
Comment thread apps/desktop/src/main.ts Outdated
Comment thread apps/desktop/src/main.ts
Comment thread apps/desktop/src/wsl.ts
Comment thread apps/desktop/src/wsl.ts
Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx Outdated
Comment thread apps/desktop/src/main.ts Outdated
Comment thread apps/desktop/src/main.ts Outdated
Comment thread apps/desktop/src/main.ts
@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 Apr 26, 2026
@adenafil
Copy link
Copy Markdown

wow this is great man

Comment thread apps/desktop/src/backendReadiness.ts Outdated
Comment thread apps/desktop/src/main.ts
Comment thread apps/desktop/src/main.ts
Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
@juliusmarminge
Copy link
Copy Markdown
Member

Wow great work! Will find some time to test and review next week!

@juliusmarminge juliusmarminge self-requested a review April 27, 2026 02:34
Comment thread apps/desktop/src/main.ts Outdated
@github-actions github-actions Bot added size:XXL 1,000+ changed lines (additions + deletions). and removed size:XL 500-999 changed lines (additions + deletions). labels Apr 27, 2026
Comment thread apps/desktop/src/wsl.ts
Comment thread apps/desktop/src/main.ts
- Drain in-flight startBackend before stopBackendAndWaitForExit so a WSL
  config update no longer races past a restart that is awaiting wslpath
  and throws a misleading error while the in-flight call spawns with
  stale config.
- Run wslpath inside the distro the user actually picked when they
  select a path under \wsl.localhost\<distro>\... rather than
  always using wslConfig.distro, so multi-distro setups convert
  correctly.
- Revert DEFAULT_TIMEOUT_MS in backendReadiness back to 30s; the only
  caller passes 60s explicitly, so the 5-minute default served no
  purpose and risked silently slowing future callers.
- Cover extractDistroFromUncPath in wsl.test.ts.
Capture the previous on-disk WSL config before persisting the new one,
and restore it if startBackend fails. Otherwise the renderer error toast
leaves the UI showing the old toggle/distro while the disk has already
moved to the never-confirmed configuration, which the next app launch
silently honors.
- Bootstrap reader: attach an error listener to the readline Interface so
  the EAGAIN that the Linux pty bridge surfaces after stdin closes does not
  bubble up as an unhandled error and exit the backend with code 1 right
  after parsing the envelope. Guard the resume path so a late error fired
  before cleanup runs is a no-op.

- node-pty Linux prebuild: node-pty's npm tarball ships prebuilds for
  darwin/win32 only, so a Windows-side install has no pty.node the inner
  Linux process can load. On WSL backend startup, probe whether node-pty
  loads inside the distro and, if not, run \`node-gyp rebuild\` once via
  wsl.exe and stage the result in prebuilds/linux-x64/. Pipe the script
  through stdin to avoid wsl.exe's quote re-escaping.
…installs

- Queue concurrent wsl-set-config IPC calls behind a single in-flight
  promise (wslConfigUpdateInFlight). Two rapid toggles previously raced
  through stopBackend / startBackend in interleaved order, leaving the
  backend pinned to whichever spawn won the race and the on-disk config
  ahead of reality.

- Always roll back the persisted wsl-config.json when the swap fails,
  not just when startBackend resolves to false. Wrap stopBackend /
  startBackend / waitForBackendWindowReady in a single try block so
  synchronous spawn failures, missing distros, or HTTP-readiness timeouts
  all unwind the on-disk config to the previous value before the IPC
  call rejects. Without this, an unhandled spawn throw left the renderer
  with a generic IPC error and the next launch silently honored a
  never-confirmed config.

- Surface a Windows-side rollback message too, so disabling WSL with a
  failing Windows backend produces the same toast/log behavior as the
  WSL-enable failure case.

- Split ensureWslNodePty's behavior with an allowBuild flag: dev builds
  still rebuild on demand, packaged builds only reuse a verified staged
  binary. Add prepareWslNodePty.ts entry point so packagers can stage
  the prebuild in CI via 'bun run prepare:wsl' instead of relying on a
  cold-path build inside the desktop process.
When the desktop swaps backends (Windows / WSL), each backend has a
separate environment-id, separate sqlite, and separate session-signing
key. Without explicit handling, the renderer kept the previous backend's
threads/projects in the store and sent the previous backend's session
cookie, which the new backend rejected with 401 — leaving the UI showing
stale data and the WS connection unable to upgrade.

- store: add a clearPrimaryEnvironmentState action that drops the
  previous env's slice from environmentStateById on identity change.
  Unset activeEnvironmentId if it pointed at the cleared env. Aggregate
  selectors iterate the remaining entries directly.

- __root.tsx: track the last-seen primary env id in a ref and reconcile
  on every welcome / config event. On identity change: dispose the prior
  EnvironmentConnection, clear its store slice, and navigate the user
  off any /<oldEnvId>/<threadId> route they were sitting on. Bind
  startServerStateSync to the live primary id so it follows the swap.

- auth: add reauthenticatePrimaryEnvironment() that re-runs the desktop
  bootstrap exchange against the new backend so the renderer holds a
  cookie signed by the live key. Without this, every subsequent HTTP
  request and the WS upgrade itself 401, since /ws on desktop primary
  authenticates via session cookie (no wsToken query param). Bump
  BOOTSTRAP_RETRY_TIMEOUT_MS from 15s to 60s to cover cold WSL launches.

- runtime/{connection,service}: thread a reportLifecycleEvents option
  through dispose() so suppressed swaps do not churn the connection-status
  UI; add disconnectPrimaryEnvironment for the renderer to call when it
  reconciles env identity.

- rpc/{wsConnectionState,protocol,wsTransport,wsRpcClient}: introduce
  suppressWsConnectionLifecycle so the settings handler can stop
  reporting connect/disconnect events to the connection-status atom
  during the deliberate swap window. The lifecycle gate consults a
  shouldReportLifecycleEvent hook composed with the existing handlers.

- ProjectFavicon: render the placeholder folder icon when the env's
  HTTP base URL is briefly unavailable (e.g. during a swap) instead of
  building an invalid src URL.
- Replace the native <select> for distro selection with the design
  system's <Select> component (built on @base-ui/react/select), used
  elsewhere in settings. Fixes the slightly off-axis chevron and matches
  the dropdown styling of the rest of the panel.

- Stop disabling the distro dropdown when WSL is off. The selection now
  stages locally (stagedDesktopWslDistro) without hitting the backend;
  flipping the toggle on uses the staged value as the new distro, while
  changes made while WSL is already enabled still go through the existing
  confirmation dialog. A useEffect mirrors the saved distro into the
  staged value on first load and whenever WSL is enabled.

- Wire reauthenticatePrimaryEnvironment() into handleDesktopWslChange
  so the renderer re-exchanges the desktop bootstrap token for a session
  cookie signed by the new backend before any WS reconnect attempts. The
  call sits inside suppressWsConnectionLifecycle alongside the descriptor
  refresh so its 401-then-200 doesn't briefly surface as a connection
  hiccup in the UI.

- Track a desktopWslChangeStage ('restarting-backend' / 'reauthenticating')
  so the dialog button reads "Restarting backend…" then "Re-establishing
  session…" instead of a single static "Restarting…" — gives the user
  feedback during the longer cold-launch path.

- Expand the confirmation copy: set the time expectation ("can take up
  to 30 seconds the first time") and clarify that each backend keeps its
  own threads, so the previous list isn't gone — it returns when you
  switch back.
Two follow-ups from the latest review pass:

- main.ts WSL_SET_CONFIG_CHANNEL: when startBackend() succeeded but the
  readiness wait then failed (e.g. a slow first WSL boot exceeding the
  60s timeout), the spawned child kept running under the new mode while
  the on-disk config and renderer state silently reverted, leaving
  runtime and persisted state inconsistent until the next backend exit.
  Stop the new-mode child explicitly, save the previous config, and
  relaunch under it; if the rollback start also fails, fall back to the
  exponential-backoff restart so the loop keeps trying.

- wsl.ts runWslShellScript: the stdin "error" handler and the catch
  around stdin.write/end called settle() without first killing the
  spawned wsl.exe child, so a stdin failure after spawn left the child
  running with no termination path (the timeout that would have killed
  it is cleared by settle()). Kill the child before settle() in those
  paths to match the timeout-handler shape.
@Jgratton24 Jgratton24 force-pushed the josh/desktop-wsl-backend branch from f241be6 to 017f1d6 Compare April 27, 2026 17:52
Comment thread apps/desktop/src/wsl.ts
Comment thread apps/desktop/src/main.ts
scheduleBackendRestart fires startBackend() from a setTimeout callback
and discards the result with `void`. startBackend is async, so any
synchronous throw inside it — a bad spawn argv, a rejection bubbling
out of windowsToWslPathAsync or ensureWslNodePty — would otherwise
surface as an unhandled promise rejection at runtime, which Electron
flags as a noisy warning. Log the failure and let the next backend
lifecycle event drive the retry loop instead.
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 3 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 815bdee. Configure here.

Comment thread apps/web/src/components/settings/ConnectionsSettings.tsx
Comment thread apps/web/src/rpc/wsConnectionState.ts
Comment thread apps/desktop/src/wsl.ts
- wsl.ts windowsToWslPathAsync: bump the wslpath child timeout from 3s
  to 10s. The first wsl.exe invocation against a cold distro routinely
  spends a few seconds spinning up the LX VM before wslpath even runs,
  and 3s was tight enough to occasionally surface "wslpath conversion
  failed" errors in the folder picker (which throws on null) and
  exponential-backoff restart noise in startBackend.

- wsConnectionState.ts suppressWsConnectionLifecycle: only call
  resetWsConnectionState on the outermost depth transition (0 → 1 and
  1 → 0). The previous unconditional reset would wipe connection state
  on every inner finally, leaving the atom stuck at the initial value
  if a nested caller exited while an outer suppression window was
  still open. No nested caller exists today; this is defensive.
@Jgratton24 Jgratton24 force-pushed the josh/desktop-wsl-backend branch from 28281c2 to 443ec84 Compare April 27, 2026 19:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XXL 1,000+ changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Desktop WSL backend mode for Windows app

3 participants