From ef7ecbd3e5f389402d5030163462ab39ba897630 Mon Sep 17 00:00:00 2001 From: Yasuhiro Matsumoto Date: Thu, 23 Apr 2026 14:20:14 +0900 Subject: [PATCH] termio: run Windows shell commands without a cmd.exe wrapper On Windows the shell value was always executed as `cmd.exe /C `. For even a simple `command = wsl ~` this spawned two processes (the cmd wrapper and the user's actual shell) and had visible side effects: an extra cmd.exe in the process tree, and cmd AutoRun state (DOSKEY aliases, `cd` in init.cmd, etc.) running in the wrapper rather than the user's shell, since AutoRun is per-process. Run the shell value directly. If it contains whitespace, split on whitespace into argv. Bare `cmd.exe` is resolved via %COMSPEC% which is the documented path to the current command processor; other bare values are left to PATH resolution in Command.startWindows. The simple whitespace split does not honor Windows CLI quoting rules. Users who need quoted arguments should use the direct command form. Also skip the termios focus timer on Windows since Windows has no termios; the focusGained callback was starting a timer whose callback would then do nothing. Co-authored-by: Claude --- src/termio/Exec.zig | 52 +++++++++++++++++++++++++++++---------------- 1 file changed, 34 insertions(+), 18 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 0f35b5787..9419ce97b 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -237,6 +237,9 @@ pub fn focusGained( assert(td.backend == .exec); const execdata = &td.backend.exec; + // Windows has no termios, so there is nothing to poll. + if (comptime builtin.os.tag == .windows) return; + if (!focused) { // Flag the timer to end on the next iteration. This is // a lot cheaper than doing full timer cancellation. @@ -1552,26 +1555,39 @@ fn execCommand( defer args.deinit(alloc); if (comptime builtin.os.tag == .windows) { - // We run our shell wrapped in `cmd.exe` so that we don't have - // to parse the command line ourselves if it has arguments. - + // On Windows we run the shell value directly rather than + // wrapping in `cmd.exe /C `. An intermediate cmd + // process is wasteful for the common case (`wsl ~`, + // `pwsh -NoLogo`, etc.) and has visible side effects + // (extra process in the tree, per-process cmd AutoRun + // state not reaching the user's actual shell). + // + // Values with arguments are split on whitespace. This + // does not honor Windows CLI quoting rules; users who + // need quoted arguments should use the direct command + // form, which takes an argv array as-is. + // // Note we don't free any of the memory below since it is // allocated in the arena. - const windir = std.process.getEnvVarOwned( - alloc, - "WINDIR", - ) catch |err| { - log.warn("failed to get WINDIR, cannot run shell command err={}", .{err}); - return error.SystemError; - }; - const cmd = try std.fs.path.joinZ(alloc, &[_][]const u8{ - windir, - "System32", - "cmd.exe", - }); - - try args.append(alloc, cmd); - try args.append(alloc, "/C"); + if (std.mem.indexOfAny(u8, v, " \t") == null) { + // No arguments. If the shell is literally "cmd.exe" + // (the default), resolve via %COMSPEC% which is the + // documented path to the current command processor. + // Other values are passed as-is and resolved by + // `internal_os.path.expand` in Command.startWindows. + const argv0 = if (std.ascii.eqlIgnoreCase(v, "cmd.exe")) + std.process.getEnvVarOwned(alloc, "COMSPEC") catch + try alloc.dupe(u8, v) + else + try alloc.dupe(u8, v); + try args.append(alloc, try alloc.dupeZ(u8, argv0)); + } else { + var it = std.mem.tokenizeAny(u8, v, " \t"); + while (it.next()) |tok| { + try args.append(alloc, try alloc.dupeZ(u8, tok)); + } + } + break :shell try args.toOwnedSlice(alloc); } else { // We run our shell wrapped in `/bin/sh` so that we don't have // to parse the command line ourselves if it has arguments.