authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-10-14 16:20:01-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-14 16:20:01-07:00
log40a7908df54d5069c93835032c9e955b56b55f1d
treebf90e4ced94ba193e93cb68a75becc0849120db4
parent5a4e9980533705774dc3f45b35cacf76c3d78dbc
signature Signed by PGP key B5690EEEBB952194

fix(server): Reveal in File Explorer and browser launches work on Windows again (#19656)

Windows PowerShell started with detached: true exits 0 without running its command. Native Windows PowerShell launches (reveal and browser) now start attached, unref'd, with ignored stdio. Takes over #10387. Fixes #11780. Co-authored-by: doubletwisted <118005273+doubletwisted@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

2 files changed, 164 insertions(+), 75 deletions(-)

apps/server/src/process/externalLauncher.test.ts+134-58
......@@ -1,5 +1,4 @@
1// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs.
2import * as NodeChildProcess from "node:child_process";
1// @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches.
32import * as NodeFS from "node:fs";
43import * as NodeOS from "node:os";
54import * as NodePath from "node:path";
......@@ -124,6 +123,50 @@ it.effect("launches the default browser through the platform command", () => {
124123 );
125124});
126125
126it.effect.each(["win32", "linux"] as const)(
127 "launches the browser through PowerShell on %s",
128 (platform) => {
129 let spawned: ChildProcess.StandardCommand | undefined;
130 return Effect.gen(function* () {
131 const launcher = yield* ExternalLauncher.ExternalLauncher;
132
133 yield* launcher.launchBrowser("https://example.com/some path");
134
135 assert.ok(spawned);
136 assert.equal(
137 spawned.command,
138 platform === "win32"
139 ? "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
140 : "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
141 );
142 assert.deepEqual(spawned.args.slice(0, -1), [
143 "-NoProfile",
144 "-NonInteractive",
145 "-ExecutionPolicy",
146 "Bypass",
147 "-EncodedCommand",
148 ]);
149 const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";
150 const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
151 assert.equal(
152 decodedCommand,
153 "$ProgressPreference = 'SilentlyContinue'; Start 'https://example.com/some path'",
154 );
155 assert.equal(spawned.options.detached, platform !== "win32");
156 }).pipe(
157 Effect.provide(
158 layerTest({
159 platform,
160 env: { SYSTEMROOT: "C:\\Windows", WSL_DISTRO_NAME: "Ubuntu" },
161 onSpawn: (command) => {
162 spawned = command;
163 },
164 }),
165 ),
166 );
167 },
168);
169
127170it.effect("launches an installed editor with platform-safe arguments", () =>
128171 Effect.gen(function* () {
129172 const fileSystem = yield* FileSystem.FileSystem;
......@@ -163,6 +206,7 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
163206 );
164207 assert.deepEqual(spawned.args, []);
165208 assert.equal(spawned.options.shell, true);
209 assert.equal(spawned.options.detached, true);
166210 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
167211);
168212
......@@ -291,6 +335,7 @@ it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS",
291335 assert.ok(spawned);
292336 assert.equal(spawned.command, "open");
293337 assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]);
338 assert.equal(spawned.options.detached, true);
294339 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
295340);
296341
......@@ -349,65 +394,95 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () =>
349394 "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')",
350395 );
351396 assert.equal(spawned.options.shell, false);
397 assert.equal(spawned.options.detached, false);
352398 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
353399);
354400
355// Real-chain smoke check for the Explorer selection contract: runs the exact
356// PowerShell source the reveal launch encodes, against a stub that records
357// the raw argument tail it receives, and asserts a spaced path arrives as the
358// single `/select,"<path>"` switch. Mock argv assertions cannot prove this —
359// only Windows' own PowerShell -> CreateProcess quoting chain can, so the
360// test runs only where that chain exists.
361// oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime.
362it.skipIf(process.platform !== "win32")(
363 "delivers the raw /select switch for spaced paths through real PowerShell",
364 { timeout: 60_000 },
365 async () => {
366 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-"));
367 try {
368 const recorderPath = NodePath.join(tempDir, "recorder.cmd");
369 const outputPath = NodePath.join(tempDir, "argv.txt");
370 NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`);
371
372 const target = "C:/workspace with spaces/media/author's clip.mp4";
373 const explorerTarget = target.replaceAll("/", "\\");
374 const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(
375 recorderPath,
376 explorerTarget,
377 );
378 const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;
379 NodeChildProcess.execFileSync(
380 powerShellPath,
381 [
382 "-NoProfile",
383 "-NonInteractive",
384 "-ExecutionPolicy",
385 "Bypass",
386 "-EncodedCommand",
387 Buffer.from(source, "utf16le").toString("base64"),
388 ],
389 { timeout: 30_000 },
390 );
391
392 // Start-Process returns before the recorder runs; wait for its output.
393 // The waits run outside the Effect runtime on purpose: the test
394 // exercises the real Windows process chain in real time.
395 // @effect-diagnostics-next-line globalTimers:off
396 const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis));
397 // @effect-diagnostics-next-line globalDate:off
398 const deadline = Date.now() + 20_000;
399 // @effect-diagnostics-next-line globalDate:off
400 while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) {
401 await sleep(100);
402 }
403 await sleep(200);
404 const recorded = NodeFS.readFileSync(outputPath, "utf8").trim();
405 assert.equal(recorded, `/select,"${explorerTarget}"`);
406 } finally {
407 NodeFS.rmSync(tempDir, { recursive: true, force: true });
408 }
409 },
410);
401// Exercise real spawning, ignored stdio, unref, and scope cleanup. Only the
402// destination application is replaced, so tests never open Explorer or a browser.
403for (const kind of ["browser", "reveal"] as const) {
404 it.live.skipIf(!windowsHost)(
405 `completes a ${kind} launch through real PowerShell after the launcher scope closes`,
406 () =>
407 Effect.gen(function* () {
408 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-"));
409 const outputPath = NodePath.join(tempDir, "argv.txt");
410 const watcher = NodeFS.watch(tempDir, { signal: AbortSignal.timeout(20_000) });
411 try {
412 const recorderPath = NodePath.join(tempDir, "recorder.cmd");
413 const stagingPath = NodePath.join(tempDir, "argv.tmp");
414 NodeFS.writeFileSync(
415 recorderPath,
416 `@echo off\r\n>"${stagingPath}" echo(%*\r\nmove /y "${stagingPath}" "${outputPath}" >nul\r\n`,
417 );
418 // The rename publishes a complete result; no sleeps or file polling.
419 const recorded = new Promise<string>((resolve, reject) => {
420 watcher.on("change", () => {
421 if (NodeFS.existsSync(outputPath)) {
422 resolve(NodeFS.readFileSync(outputPath, "utf8").trim());
423 }
424 });
425 watcher.on("error", reject);
426 watcher.on("close", () =>
427 reject(new Error("PowerShell did not run the recording stub")),
428 );
429 });
430
431 const target = "C:/workspace with spaces/media/author's clip.mp4";
432 yield* Effect.all(
433 [
434 Effect.gen(function* () {
435 const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
436 const recordingSpawner = ChildProcessSpawner.make((command) => {
437 assert.ok(ChildProcess.isStandardCommand(command));
438 const source = Buffer.from(command.args.at(-1) ?? "", "base64")
439 .toString("utf16le")
440 .replace("'explorer.exe'", `'${recorderPath.replaceAll("'", "''")}'`);
441 return realSpawner.spawn(
442 ChildProcess.make(
443 command.command,
444 [
445 ...command.args.slice(0, -1),
446 Buffer.from(source, "utf16le").toString("base64"),
447 ],
448 command.options,
449 ),
450 );
451 });
452 const launcher = yield* ExternalLauncher.make.pipe(
453 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner),
454 );
455 if (kind === "browser") {
456 yield* launcher.launchBrowser(recorderPath);
457 } else {
458 yield* launcher.launchEditor({
459 editor: "file-manager",
460 cwd: target,
461 reveal: true,
462 });
463 }
464 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
465 Effect.promise(() =>
466 recorded.then((value) => {
467 assert.equal(
468 value,
469 kind === "browser" ? "" : `/select,"${target.replaceAll("/", "\\")}"`,
470 );
471 }),
472 ),
473 ],
474 { concurrency: "unbounded" },
475 );
476 } finally {
477 watcher.close();
478 // The recording stub's cmd.exe can still hold the directory for a
479 // moment after it publishes its output, which Windows reports as EPERM.
480 NodeFS.rmSync(tempDir, { recursive: true, force: true, maxRetries: 20, retryDelay: 100 });
481 }
482 }),
483 { timeout: 30_000 },
484 );
485}
411486
412487it.effect("does not advertise reveal on Windows when PowerShell is missing", () =>
413488 Effect.gen(function* () {
......@@ -488,6 +563,7 @@ it.effect.skipIf(windowsHost)(
488563 // The reveal routes through interop PowerShell so Explorer receives its
489564 // raw `/select,"<path>"` switch even for spaced paths.
490565 assert.equal(spawned.command, "powershell.exe");
566 assert.equal(spawned.options.detached, true);
491567 const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";
492568 const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
493569 assert.equal(
apps/server/src/process/externalLauncher.ts+30-17
......@@ -62,6 +62,8 @@ interface EditorLaunch {
6262 readonly target: string;
6363 readonly command: string;
6464 readonly args: ReadonlyArray<string>;
65 /** Defaults to true. See WINDOWS_POWERSHELL_DETACHED. */
66 readonly detached?: boolean;
6567}
6668
6769interface ProcessLaunch {
......@@ -93,6 +95,12 @@ const POWERSHELL_ARGUMENTS_PREFIX = [
9395 "-EncodedCommand",
9496] as const;
9597
98// Windows PowerShell started with DETACHED_PROCESS exits 0 without running its
99// command, so native Windows launches through it stay attached. The child is
100// still unref'd with ignored stdio, so nothing waits on it. WSL interop
101// PowerShell starts from a Linux process and keeps the detached launch.
102const WINDOWS_POWERSHELL_DETACHED = false;
103
96104const DETACHED_IGNORE_STDIO_OPTIONS = {
97105 detached: true,
98106 stdin: "ignore",
......@@ -219,7 +227,11 @@ function shouldUseWindowsHostFromWsl(
219227 );
220228}
221229
222function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLaunch {
230function resolveWindowsBrowserLaunch(
231 target: string,
232 command: string,
233 detached: boolean,
234): ProcessLaunch {
223235 const encodedCommand = encodeUtf16LeBase64(
224236 `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`,
225237 );
......@@ -227,7 +239,7 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa
227239 command,
228240 args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand],
229241 options: {
230 detached: true,
242 detached,
231243 shell: false,
232244 stdin: "ignore",
233245 stdout: "ignore",
......@@ -407,11 +419,15 @@ function buildBrowserLaunch(
407419 }
408420
409421 if (platform === "win32") {
410 return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env));
422 return resolveWindowsBrowserLaunch(
423 target,
424 resolvePowerShellPath(env),
425 WINDOWS_POWERSHELL_DETACHED,
426 );
411427 }
412428
413429 if (shouldUseWindowsHostFromWsl(platform, env)) {
414 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath());
430 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath(), true);
415431 }
416432
417433 return {
......@@ -588,14 +604,8 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
588604 * when the path has spaces and Explorer misparses it, silently opening a
589605 * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1
590606 * reaches the child's command line verbatim, preserving the raw switch.
591 *
592 * Exported so the Windows smoke test can drive the identical source through a
593 * real PowerShell against a recording stub instead of Explorer.
594607 */
595export function buildFileExplorerRevealPowerShellSource(
596 explorerCommand: string,
597 target: string,
598): string {
608function buildFileExplorerRevealPowerShellSource(explorerCommand: string, target: string): string {
599609 return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`;
600610}
601611
......@@ -636,11 +646,14 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch
636646 }
637647
638648 if (platform === "win32") {
639 return fileExplorerRevealLaunch(
640 target,
641 normalizeWindowsFileManagerPath(target),
642 resolvePowerShellPath(env),
643 );
649 return {
650 ...fileExplorerRevealLaunch(
651 target,
652 normalizeWindowsFileManagerPath(target),
653 resolvePowerShellPath(env),
654 ),
655 detached: WINDOWS_POWERSHELL_DETACHED,
656 };
644657 }
645658
646659 if (
......@@ -744,7 +757,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
744757 command: spawnCommand.command,
745758 args: spawnCommand.args,
746759 options: {
747 detached: true,
760 detached: launch.detached ?? true,
748761 shell: spawnCommand.shell,
749762 stdin: "ignore",
750763 stdout: "ignore",