Add desktop WSL backend mode#2353
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. 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. Comment |
There was a problem hiding this comment.
💡 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".
ApprovabilityVerdict: Needs human review This PR introduces a significant new feature (WSL backend mode) with new user-facing controls, 500+ lines of new WSL management logic, complex backend swap coordination including session re-authentication, and cross-cutting changes to auth/runtime layers. New features of this scope require human review. You can customize Macroscope's approvability policy. Learn more. |
|
wow this is great man |
|
Wow great work! Will find some time to test and review next week! |
- 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.
WS open event swallowed during suppression (Medium):
The protocol layer's lifecycle composer gated onOpen on the global
suppression flag, but the WS event listener uses { once: true }, so a
swallowed open event is gone for that socket. If the swap finished
fast enough that the new socket opened during the suppression window,
the atom stayed at INITIAL and the UI was stuck on "connecting" until
a heartbeat timeout forced a reconnect. Fix: drop the global-suppression
gate from onOpen (per-session validity still applies) and remove the
symmetric exit-side reset in suppressWsConnectionLifecycle so the
recorded "open" state is preserved when suppression ends.
Missing timeout.unref() in listWslDistrosAsync (Low):
Match the pattern in windowsToWslPathAsync and preWarmWslAsync so the
8s probe timer doesn't keep the event loop alive during shutdown.
When ensureWslNodePty falls through to the dev-mode rebuild, probe for
make/g++/python3 first and return a targeted message ("install
build-essential + python3") if any are absent. Without this, fresh WSL
distros surface only the trailing `gyp ERR! not ok` lines, which never
include the actual `command not found` line, leaving users with no clue
which package to install.
|
@juliusmarminge Just reproduced this. It's a missing toolchain in your WSL distro, not a desktop bug. node-pty is a native module and the desktop rebuilds it on first launch via node-gyp inside the distro, which needs Quick fix: then re-toggle WSL mode in settings. The "exit 1 / gyp ERR! not ok" tail is unhelpful because I was truncating the build output and cutting off the actual |
|
do we also detect a potentially missing |
The early return on `!src` already guarantees the variable is truthy for
the rest of the render, so the `|| !src` and `{src ? ... : null}` checks
were dead code.
A missing node binary in WSL would previously fail at line 3 of the node-pty build script with `node: command not found` buried in the truncated tail. Add node to the pre-flight probe and split the remediation message so node and the build toolchain can each point at their own install path.
|
Good catch, we weren't. Just pushed 8fd37b4 to add node to the pre-flight probe, with its own install hint (nvm instead of apt). #2504 was useful context btw. Our WSL path uses bash -l -s so login shell PATH pulls in nvm/volta/etc already, so no need for the discovery routine you wrote for sh -s. engine range check is worth porting separately though |
- ConnectionsSettings: re-authenticate after WSL toggle failures. Main rolls back to the previous config on failure, but the rolled-back backend is a fresh process with a fresh session-signing key, so the renderer's existing cookie was rejected with 401 by every reconnect attempt until the app was restarted. - main.ts WSL stdin handler: stop scheduling a 500ms restart from the async stdin error listener. The listener fired after the exit handler was registered, so marking the child expected and racing the restart timer against SIGTERM could leave the app with no backend (timer fires → startBackend bails on still-set backendProcess → exit handler later returns early on wasExpected). The async path now only kills; the exit handler observes the unexpected exit and schedules the restart. The synchronous catch path (no listeners registered yet) keeps owning the kill + restart cycle directly.
|
Yea we require quite modern node version so should be called out and verified otherwise we get bug reports for weird shit |
Pre-flight now reads `apps/server`'s `engines.node` and rejects WSL distros whose Node version is too old, naming the actual version and the required range. Without this we'd let an outdated Node fall through to node-gyp/runtime errors that don't mention version at all. The semver-range check is ported from the JS used by the SSH launcher's remote node check in PR pingdotgg#2504. When that PR merges (or this one does), whichever lands second extracts the helper into `packages/shared` so both call sites can drop their copy.
|
Done, just pushed 24130e4. Probe now reads |
The prologue's `isQuitting` / `backendProcess` / `backendStartInFlight` short-circuits each have an owner for the retry elsewhere (the in-flight start's own `scheduleBackendRestart` paths, or its child's exit handler). Add a diagnostic log so a future regression that returns false without scheduling a follow-up is visible in logs instead of silently stalling. No behavior change.
A synchronous throw inside startBackend (most plausibly a ChildProcess.spawn failure before any exit handler is registered) used to log and stop, since there was no child whose exit event could drive the next retry. Schedule another attempt from the catch so the existing backoff/restartTimer guard owns the retry instead of stalling.
…backoff - listWslDistrosAsync: add the `settled` guard pattern used by sibling helpers. Node fires 'close' after 'error', so the close handler was running Buffer.concat + parseWslDistroList on partial post-error data even though the second resolve was a no-op. - WSL config rollback: reset `restartAttempt` to 0 before scheduling the rollback restart. The counter only resets on a successful child spawn, so a rollback after multiple prior crashes was inheriting backoff up to the WSL-active cap (20s) instead of the intended 500ms base delay for a deliberate restart.
Move `setDesktopWslConfig` and `setPendingDesktopWslConfig` to fire immediately after `wslSetConfig` returns true, instead of after the trailing `refreshPrimaryEnvironmentDescriptor` / `reauthenticatePrimary Environment` calls. If either of those threw, the backend was already running the new config but the toggle UI stayed on the old position, and re-toggling triggered an unnecessary backend restart with an already-active config. Also rewrote the catch-block comment to cover both failure modes (rollback in main, or in-suppress reauth that failed) since the previous text only described the rollback case.
…gn abort - Move the `restartAttempt = 0` reset off the spawn event (which fires for `wsl.exe` immediately and always when WSL is installed) and onto the listening-detector resolve. The "Listening on http://" log line is the actual signal that the inner backend is healthy. Previously a persistent WSL backend failure tight-looped every 500ms because spawn reset the counter on every doomed retry. - Bootstrap dev path: only `return` when readiness aborts during quit. Otherwise (concurrent stopBackend during startup, e.g. user toggling WSL config in the first second) fall through and create the window so the app isn't left windowless.
- Pre-warm WSL VM before spawning backend so the readiness probe starts against a hot relay - Renderer holds the modal through restart -> reauth -> welcome, with explicit thread cleanup once the new env id is known - Cap rollback path to 5s so a failed swap cannot strand the modal - Drop the prior "first time" copy since cold-start cost recurs per swap
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d8363f5. Configure here.
- Memoize the in-flight fetch in refreshPrimaryEnvironmentDescriptor so a concurrent resolveInitialPrimaryEnvironmentDescriptor caller shares the same network request during a backend swap - Tighten DISTRO_NAME_PATTERN to require the trailing char be \w so hand-edited config like "Ubuntu " / "Ubuntu-" / "Ubuntu." rejects before producing broken UNC paths
|
@juliusmarminge wanted to ask before I start the port. The recent main.ts refactor (DesktopApp, DesktopBackendManager, etc) replaces basically all the code I'm touching, so getting the WSL backend into the new layer structure is a multi-hour effort Still on track to land if I do that work, or would you rather take the design from here and integrate it yourself? Either way's fine, just want to know before redoing it. |
|
I can do it if you don't have time. Should be "easier" to do now though with a bit more structured code 🫣 |



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:
apps/desktop/src/wsl.tsmodule: WSL availability detection, distro discovery, asyncwslpathconversion, persistedwsl-config.jsonwith strict distro-name validation.main.tsspawns throughwsl.exe -- node <linuxEntry> --bootstrap-fd 0when WSL mode is enabled. Bootstrap envelope is sent on stdin (extra stdio fds do not reliably survive thewsl.exebridge);t3Homeis omitted so the Linux backend uses its own home directory.wsl-list-distros,wsl-get-config,wsl-set-config) wired through the preload bridge.ConnectionsSettings.tsxwith a toggle and distro selector.wslpathconversion for the bundled backend entry and folder picker selections; failed conversions fail closed instead of returning Windows paths to the WSL backend.wsl.exe; selected API keys are forwarded viaWSLENV.startBackendawaitingwslpath, and rolls back the persisted config if the new backend fails to start.initialPath(Linux,~, UNC) and runswslpathinside the picked path's distro when it differs from the configured one; default UNC path resolves the actual default distro instead of hardcodingUbuntu.EACCESis 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 newwsl.tshelper (224 LOC) plus its tests (251 LOC) plus a localized UI section (130 LOC). The surgery inmain.tsis 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 fromwsl.exe --list --verbose) plus a toggle for enabling WSL mode. Flipping the toggle opens anAlertDialogconfirming 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 on with Ubuntu selected
Confirmation dialog before a swap
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
Verification
bun test apps/desktop/src/wsl.test.ts apps/desktop/src/backendReadiness.test.tsbun run typecheckbun run build:desktopbun run lintclean onapps/desktop/srcI 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:environmentStateById, disposes the priorEnvironmentConnection, and navigates off any/<oldEnvId>/<threadId>route the user was sitting on.startServerStateSyncis keyed to the live primary id so it follows the swap.wsTokenquery param). AddedreauthenticatePrimaryEnvironment()that re-runs the desktop bootstrap exchange against the new backend before any reconnect, and bumpedBOOTSTRAP_RETRY_TIMEOUT_MSfrom 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".wslConfigUpdateInFlightchain inmain.ts. Thewsl-config.jsonrollback fires on synchronous throws insidestartBackend(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. Thenode-ptyrebuild step is gated on!app.isPackaged. Thewslpathtimeout is bumped from 3s to 10s so cold-VM starts don't surface as conversion failures.AlertDialog(see screenshots above) with the cold-start time expectation and a note that each backend keeps its own threads. The dialog button transitions throughRestarting backend…→Re-establishing session…so the longer cold-launch path has visible progress instead of a single static spinner.<select>was swapped for the design-system<Select>so the chevron lines up with the rest of the panel.scheduleBackendRestartnow.catch()es synchronous rejections from the timer-firedstartBackend()so they can't surface as unhandled rejections.runWslShellkills itswsl.exechild on stdin error/write failure (the timeout already did, but the error paths didn't).suppressWsConnectionLifecycleonly resets the connection state on the outermost depth transition so a future nested caller can't wipe state mid-flight.Checklist
Note
Add WSL backend mode to run the local server inside a Windows Subsystem for Linux distro
wsl.tswith utilities to list WSL distros, parsewsl.exeoutput, convert Windows paths to Linux paths, build and stage a WSL-compatiblenode-ptybinary, and load/save WSL config.main.tsto spawn the backend inside WSL when enabled, passing a JSON bootstrap envelope over stdin; includes pre-warm at startup, IPC handlers for distro listing and config get/set, and rollback on failed config change.ConnectionsSettings.tsxwith a distro selector and enable/disable toggle; confirming restarts the backend, reauthenticates, and waits for the new backend's welcome event before closing the dialog.suppressWsConnectionLifecycle) so connection status is not updated during backend restarts triggered by WSL config changes.nodeEngineRange.tsfor semver-likeengines.noderange checking used during node-pty toolchain preflight.Macroscope summarized ea1348b.
Note
High Risk
High risk because it changes desktop backend startup/restart semantics, adds a new WSL-based execution path (process spawning, env/stdio forwarding, path conversion), and introduces renderer reauthentication/state-reset logic during backend swaps that can disrupt connectivity if wrong.
Overview
Adds an opt-in WSL-backed local backend mode for the Windows desktop app: the main process can now spawn the server via
wsl.exe(stdin bootstrap envelope, path conversion viawslpath, selective env forwarding, node-pty Linux prebuild staging) and persists/validates awsl-config.json.Introduces new Desktop IPC surface (
wslListDistros,wslGetConfig,wslSetConfig) and a Connections settings UI to toggle WSL mode and choose a distro; applying changes serializes updates, restarts the backend with rollback on failure, and adjusts folder picking defaults/returned paths for WSL.Hardens lifecycle/UX around swaps: backend restart backoff is WSL-aware, the window opens even after readiness timeouts, the renderer clears old environment state and disconnects the previous primary connection, re-authenticates after a backend swap (longer transient bootstrap retry window), and suppresses WS lifecycle status churn during the deliberate restart.
Reviewed by Cursor Bugbot for commit ea1348b. Bugbot is set up for automated code reviews on this repo. Configure here.