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,5 +1,4 @@
1// @effect-diagnostics nodeBuiltinImport:off - the Windows reveal smoke test drives a real PowerShell through Node process and filesystem APIs.1// @effect-diagnostics nodeBuiltinImport:off - the Windows launch smoke tests use filesystem notifications to observe real PowerShell launches.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";2import * as NodeFS from "node:fs";
4import * as NodeOS from "node:os";3import * as NodeOS from "node:os";
5import * as NodePath from "node:path";4import * as NodePath from "node:path";
...@@ -124,6 +123,50 @@ it.effect("launches the default browser through the platform command", () => {...@@ -124,6 +123,50 @@ it.effect("launches the default browser through the platform command", () => {
124 );123 );
125});124});
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
127it.effect("launches an installed editor with platform-safe arguments", () =>170it.effect("launches an installed editor with platform-safe arguments", () =>
128 Effect.gen(function* () {171 Effect.gen(function* () {
129 const fileSystem = yield* FileSystem.FileSystem;172 const fileSystem = yield* FileSystem.FileSystem;
...@@ -163,6 +206,7 @@ it.effect("launches an installed editor with platform-safe arguments", () =>...@@ -163,6 +206,7 @@ it.effect("launches an installed editor with platform-safe arguments", () =>
163 );206 );
164 assert.deepEqual(spawned.args, []);207 assert.deepEqual(spawned.args, []);
165 assert.equal(spawned.options.shell, true);208 assert.equal(spawned.options.shell, true);
209 assert.equal(spawned.options.detached, true);
166 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),210 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
167);211);
168212
...@@ -291,6 +335,7 @@ it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS",...@@ -291,6 +335,7 @@ it.effect.skipIf(windowsHost)("reveals a file in Finder with open -R on macOS",
291 assert.ok(spawned);335 assert.ok(spawned);
292 assert.equal(spawned.command, "open");336 assert.equal(spawned.command, "open");
293 assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]);337 assert.deepEqual(spawned.args, ["-R", "/workspace/media/linux-mini-v2.mp4"]);
338 assert.equal(spawned.options.detached, true);
294 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),339 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
295);340);
296341
...@@ -349,65 +394,95 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () =>...@@ -349,65 +394,95 @@ it.effect("reveals a file in File Explorer through PowerShell on Windows", () =>
349 "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')",394 "$ProgressPreference = 'SilentlyContinue'; Start-Process 'explorer.exe' -ArgumentList ('/select,\"' + 'C:\\workspace with spaces\\media\\author''s clip.mp4' + '\"')",
350 );395 );
351 assert.equal(spawned.options.shell, false);396 assert.equal(spawned.options.shell, false);
397 assert.equal(spawned.options.detached, false);
352 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),398 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
353);399);
354400
355// Real-chain smoke check for the Explorer selection contract: runs the exact401// Exercise real spawning, ignored stdio, unref, and scope cleanup. Only the
356// PowerShell source the reveal launch encodes, against a stub that records402// destination application is replaced, so tests never open Explorer or a browser.
357// the raw argument tail it receives, and asserts a spaced path arrives as the403for (const kind of ["browser", "reveal"] as const) {
358// single `/select,"<path>"` switch. Mock argv assertions cannot prove this —404 it.live.skipIf(!windowsHost)(
359// only Windows' own PowerShell -> CreateProcess quoting chain can, so the405 `completes a ${kind} launch through real PowerShell after the launcher scope closes`,
360// test runs only where that chain exists.406 () =>
361// oxlint-disable-next-line t3code/no-global-process-runtime -- the skip decision needs the real host platform, outside any Effect runtime.407 Effect.gen(function* () {
362it.skipIf(process.platform !== "win32")(408 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-"));
363 "delivers the raw /select switch for spaced paths through real PowerShell",409 const outputPath = NodePath.join(tempDir, "argv.txt");
364 { timeout: 60_000 },410 const watcher = NodeFS.watch(tempDir, { signal: AbortSignal.timeout(20_000) });
365 async () => {411 try {
366 const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-reveal-smoke-"));412 const recorderPath = NodePath.join(tempDir, "recorder.cmd");
367 try {413 const stagingPath = NodePath.join(tempDir, "argv.tmp");
368 const recorderPath = NodePath.join(tempDir, "recorder.cmd");414 NodeFS.writeFileSync(
369 const outputPath = NodePath.join(tempDir, "argv.txt");415 recorderPath,
370 NodeFS.writeFileSync(recorderPath, `@echo off\r\n>"${outputPath}" echo(%*\r\n`);416 `@echo off\r\n>"${stagingPath}" echo(%*\r\nmove /y "${stagingPath}" "${outputPath}" >nul\r\n`,
371417 );
372 const target = "C:/workspace with spaces/media/author's clip.mp4";418 // The rename publishes a complete result; no sleeps or file polling.
373 const explorerTarget = target.replaceAll("/", "\\");419 const recorded = new Promise<string>((resolve, reject) => {
374 const source = ExternalLauncher.buildFileExplorerRevealPowerShellSource(420 watcher.on("change", () => {
375 recorderPath,421 if (NodeFS.existsSync(outputPath)) {
376 explorerTarget,422 resolve(NodeFS.readFileSync(outputPath, "utf8").trim());
377 );423 }
378 const powerShellPath = `${process.env.SYSTEMROOT ?? "C:\\Windows"}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`;424 });
379 NodeChildProcess.execFileSync(425 watcher.on("error", reject);
380 powerShellPath,426 watcher.on("close", () =>
381 [427 reject(new Error("PowerShell did not run the recording stub")),
382 "-NoProfile",428 );
383 "-NonInteractive",429 });
384 "-ExecutionPolicy",430
385 "Bypass",431 const target = "C:/workspace with spaces/media/author's clip.mp4";
386 "-EncodedCommand",432 yield* Effect.all(
387 Buffer.from(source, "utf16le").toString("base64"),433 [
388 ],434 Effect.gen(function* () {
389 { timeout: 30_000 },435 const realSpawner = yield* ChildProcessSpawner.ChildProcessSpawner;
390 );436 const recordingSpawner = ChildProcessSpawner.make((command) => {
391437 assert.ok(ChildProcess.isStandardCommand(command));
392 // Start-Process returns before the recorder runs; wait for its output.438 const source = Buffer.from(command.args.at(-1) ?? "", "base64")
393 // The waits run outside the Effect runtime on purpose: the test439 .toString("utf16le")
394 // exercises the real Windows process chain in real time.440 .replace("'explorer.exe'", `'${recorderPath.replaceAll("'", "''")}'`);
395 // @effect-diagnostics-next-line globalTimers:off441 return realSpawner.spawn(
396 const sleep = (millis: number) => new Promise((resolve) => setTimeout(resolve, millis));442 ChildProcess.make(
397 // @effect-diagnostics-next-line globalDate:off443 command.command,
398 const deadline = Date.now() + 20_000;444 [
399 // @effect-diagnostics-next-line globalDate:off445 ...command.args.slice(0, -1),
400 while (!NodeFS.existsSync(outputPath) && Date.now() < deadline) {446 Buffer.from(source, "utf16le").toString("base64"),
401 await sleep(100);447 ],
402 }448 command.options,
403 await sleep(200);449 ),
404 const recorded = NodeFS.readFileSync(outputPath, "utf8").trim();450 );
405 assert.equal(recorded, `/select,"${explorerTarget}"`);451 });
406 } finally {452 const launcher = yield* ExternalLauncher.make.pipe(
407 NodeFS.rmSync(tempDir, { recursive: true, force: true });453 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, recordingSpawner),
408 }454 );
409 },455 if (kind === "browser") {
410);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
412it.effect("does not advertise reveal on Windows when PowerShell is missing", () =>487it.effect("does not advertise reveal on Windows when PowerShell is missing", () =>
413 Effect.gen(function* () {488 Effect.gen(function* () {
...@@ -488,6 +563,7 @@ it.effect.skipIf(windowsHost)(...@@ -488,6 +563,7 @@ it.effect.skipIf(windowsHost)(
488 // The reveal routes through interop PowerShell so Explorer receives its563 // The reveal routes through interop PowerShell so Explorer receives its
489 // raw `/select,"<path>"` switch even for spaced paths.564 // raw `/select,"<path>"` switch even for spaced paths.
490 assert.equal(spawned.command, "powershell.exe");565 assert.equal(spawned.command, "powershell.exe");
566 assert.equal(spawned.options.detached, true);
491 const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";567 const encodedCommand = spawned.args[spawned.args.length - 1] ?? "";
492 const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");568 const decodedCommand = Buffer.from(encodedCommand, "base64").toString("utf16le");
493 assert.equal(569 assert.equal(
apps/server/src/process/externalLauncher.ts+30-17
...@@ -62,6 +62,8 @@ interface EditorLaunch {...@@ -62,6 +62,8 @@ interface EditorLaunch {
62 readonly target: string;62 readonly target: string;
63 readonly command: string;63 readonly command: string;
64 readonly args: ReadonlyArray<string>;64 readonly args: ReadonlyArray<string>;
65 /** Defaults to true. See WINDOWS_POWERSHELL_DETACHED. */
66 readonly detached?: boolean;
65}67}
6668
67interface ProcessLaunch {69interface ProcessLaunch {
...@@ -93,6 +95,12 @@ const POWERSHELL_ARGUMENTS_PREFIX = [...@@ -93,6 +95,12 @@ const POWERSHELL_ARGUMENTS_PREFIX = [
93 "-EncodedCommand",95 "-EncodedCommand",
94] as const;96] 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
96const DETACHED_IGNORE_STDIO_OPTIONS = {104const DETACHED_IGNORE_STDIO_OPTIONS = {
97 detached: true,105 detached: true,
98 stdin: "ignore",106 stdin: "ignore",
...@@ -219,7 +227,11 @@ function shouldUseWindowsHostFromWsl(...@@ -219,7 +227,11 @@ function shouldUseWindowsHostFromWsl(
219 );227 );
220}228}
221229
222function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLaunch {230function resolveWindowsBrowserLaunch(
231 target: string,
232 command: string,
233 detached: boolean,
234): ProcessLaunch {
223 const encodedCommand = encodeUtf16LeBase64(235 const encodedCommand = encodeUtf16LeBase64(
224 `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`,236 `$ProgressPreference = 'SilentlyContinue'; Start ${escapePowerShellStringLiteral(target)}`,
225 );237 );
...@@ -227,7 +239,7 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa...@@ -227,7 +239,7 @@ function resolveWindowsBrowserLaunch(target: string, command: string): ProcessLa
227 command,239 command,
228 args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand],240 args: [...POWERSHELL_ARGUMENTS_PREFIX, encodedCommand],
229 options: {241 options: {
230 detached: true,242 detached,
231 shell: false,243 shell: false,
232 stdin: "ignore",244 stdin: "ignore",
233 stdout: "ignore",245 stdout: "ignore",
...@@ -407,11 +419,15 @@ function buildBrowserLaunch(...@@ -407,11 +419,15 @@ function buildBrowserLaunch(
407 }419 }
408420
409 if (platform === "win32") {421 if (platform === "win32") {
410 return resolveWindowsBrowserLaunch(target, resolvePowerShellPath(env));422 return resolveWindowsBrowserLaunch(
423 target,
424 resolvePowerShellPath(env),
425 WINDOWS_POWERSHELL_DETACHED,
426 );
411 }427 }
412428
413 if (shouldUseWindowsHostFromWsl(platform, env)) {429 if (shouldUseWindowsHostFromWsl(platform, env)) {
414 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath());430 return resolveWindowsBrowserLaunch(target, resolveWslPowerShellPath(), true);
415 }431 }
416432
417 return {433 return {
...@@ -588,14 +604,8 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (...@@ -588,14 +604,8 @@ const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
588 * when the path has spaces and Explorer misparses it, silently opening a604 * when the path has spaces and Explorer misparses it, silently opening a
589 * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1605 * fallback folder. A single `-ArgumentList` string in Windows PowerShell 5.1
590 * reaches the child's command line verbatim, preserving the raw switch.606 * 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.
594 */607 */
595export function buildFileExplorerRevealPowerShellSource(608function buildFileExplorerRevealPowerShellSource(explorerCommand: string, target: string): string {
596 explorerCommand: string,
597 target: string,
598): string {
599 return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`;609 return `$ProgressPreference = 'SilentlyContinue'; Start-Process ${escapePowerShellStringLiteral(explorerCommand)} -ArgumentList ('/select,"' + ${escapePowerShellStringLiteral(target)} + '"')`;
600}610}
601611
...@@ -636,11 +646,14 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch...@@ -636,11 +646,14 @@ const resolveFileManagerRevealLaunch = Effect.fn("resolveFileManagerRevealLaunch
636 }646 }
637647
638 if (platform === "win32") {648 if (platform === "win32") {
639 return fileExplorerRevealLaunch(649 return {
640 target,650 ...fileExplorerRevealLaunch(
641 normalizeWindowsFileManagerPath(target),651 target,
642 resolvePowerShellPath(env),652 normalizeWindowsFileManagerPath(target),
643 );653 resolvePowerShellPath(env),
654 ),
655 detached: WINDOWS_POWERSHELL_DETACHED,
656 };
644 }657 }
645658
646 if (659 if (
...@@ -744,7 +757,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu...@@ -744,7 +757,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
744 command: spawnCommand.command,757 command: spawnCommand.command,
745 args: spawnCommand.args,758 args: spawnCommand.args,
746 options: {759 options: {
747 detached: true,760 detached: launch.detached ?? true,
748 shell: spawnCommand.shell,761 shell: spawnCommand.shell,
749 stdin: "ignore",762 stdin: "ignore",
750 stdout: "ignore",763 stdout: "ignore",