termio: run Windows shell commands without a cmd.exe wrapper

On Windows the shell value was always executed as `cmd.exe /C <shell>`.
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 <noreply@anthropic.com>
This commit is contained in:
Yasuhiro Matsumoto
2026-04-23 14:20:14 +09:00
parent e88c6c0991
commit ef7ecbd3e5

View File

@@ -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 <shell>`. 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.