diff --git a/Tools/windows/Tests/WindowsShell.Tests.ps1 b/Tools/windows/Tests/WindowsShell.Tests.ps1 index a1129956..aedc101b 100644 --- a/Tools/windows/Tests/WindowsShell.Tests.ps1 +++ b/Tools/windows/Tests/WindowsShell.Tests.ps1 @@ -599,6 +599,17 @@ Assert-Contract ($codespaceDialogSource -match 'WM_CTLCOLORLISTBOX' -and $codesp Assert-Contract ($codespaceDialogSource -match 'IsDialogMessageW') ` "the codespace sheet must remain keyboard navigable" +$zmxSessionSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\ZmxSession.zig") -Raw +$terminalSurfaceSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\TerminalSurface.zig") -Raw +$workspaceTeardownSource = Get-Content -LiteralPath (Join-Path $shellRoot "src\WorkspaceTeardown.zig") -Raw +Assert-Contract ($zmxSessionSource -match '(?s)pub fn child\(.*?\.create_no_window = true;') ` + "zmx children must be created without a console window" +Assert-Contract ($terminalSurfaceSource -notmatch 'std\.process\.Child\.init\(' -and + $workspaceTeardownSource -notmatch 'std\.process\.Child\.init\(' -and + [regex]::Matches($terminalSurfaceSource, 'ZmxSession\.child\(').Count -eq 2 -and + [regex]::Matches($workspaceTeardownSource, 'ZmxSession\.child\(').Count -eq 1) ` + "zmx attach, resize, and kill must spawn through ZmxSession.child so the GUI shell never opens a console window" + $zig = Resolve-TestZig Invoke-Native "Accessibility contract executable tests" { Push-Location $shellRoot diff --git a/graphcode-windows/src/TerminalSurface.zig b/graphcode-windows/src/TerminalSurface.zig index 3f3e1b57..4f8dfa98 100644 --- a/graphcode-windows/src/TerminalSurface.zig +++ b/graphcode-windows/src/TerminalSurface.zig @@ -1564,11 +1564,7 @@ pub const Workspace = struct { &session_buffer, )).len; } - var child = std.process.Child.init(attach_args[0..attach_len], self.allocator); - child.cwd = self.cwd; - child.stdin_behavior = .Pipe; - child.stdout_behavior = .Pipe; - child.stderr_behavior = .Ignore; + var child = ZmxSession.child(self.allocator, attach_args[0..attach_len], self.cwd, .attach); try child.spawn(); if (child.stdin) |stdin| { var mode: c.DWORD = c.PIPE_NOWAIT; @@ -1704,12 +1700,7 @@ pub const Workspace = struct { self.setInputError("terminal PTY resize tracking allocation failed"); return; }; - var child = std.process.Child.init(command, self.allocator); - child.cwd = self.cwd; - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; - child.create_no_window = true; + var child = ZmxSession.child(self.allocator, command, self.cwd, .control); child.spawn() catch { self.allocator.free(session); slot.attempted_resize_size = size; diff --git a/graphcode-windows/src/WorkspaceTeardown.zig b/graphcode-windows/src/WorkspaceTeardown.zig index 5f507585..234333b4 100644 --- a/graphcode-windows/src/WorkspaceTeardown.zig +++ b/graphcode-windows/src/WorkspaceTeardown.zig @@ -342,10 +342,7 @@ pub const Live = struct { defer allocator.free(zmx); const argv = killArgv(allocator, zmx, ids) catch return 0; defer freeKillArgv(allocator, argv); - var child = std.process.Child.init(argv, allocator); - child.stdin_behavior = .Ignore; - child.stdout_behavior = .Ignore; - child.stderr_behavior = .Ignore; + var child = ZmxSession.child(allocator, argv, null, .control); _ = child.spawnAndWait() catch return 0; return ids.len; } diff --git a/graphcode-windows/src/ZmxSession.zig b/graphcode-windows/src/ZmxSession.zig index 8bfde974..154d99ef 100644 --- a/graphcode-windows/src/ZmxSession.zig +++ b/graphcode-windows/src/ZmxSession.zig @@ -12,6 +12,50 @@ pub fn allocName(allocator: std.mem.Allocator, session: []const u8) ![]u8 { return std.fmt.allocPrint(allocator, prefix ++ "{s}", .{session}); } +pub const ChildStdio = enum { + /// `zmx attach`: the shell writes terminal input and reads terminal output. + attach, + /// One-shot control commands such as `resize` and `kill`. + control, +}; + +/// zmx.exe is a console-subsystem program. Spawned from the GUI shell without +/// CREATE_NO_WINDOW, Windows allocates it a new console, which the default +/// terminal host shows as a visible window that steals foreground. +pub fn child( + allocator: std.mem.Allocator, + argv: []const []const u8, + cwd: ?[]const u8, + stdio: ChildStdio, +) std.process.Child { + var result = std.process.Child.init(argv, allocator); + result.cwd = cwd; + result.stdin_behavior = if (stdio == .attach) .Pipe else .Ignore; + result.stdout_behavior = if (stdio == .attach) .Pipe else .Ignore; + result.stderr_behavior = .Ignore; + result.create_no_window = true; + return result; +} + +test "zmx children keep their stdio contract and never open a console window" { + const attach_argv = [_][]const u8{ "zmx.exe", "attach", "graphcode-alpha", "--size", "80x24" }; + const attach = child(std.testing.allocator, &attach_argv, "C:\\GraphCode\\bin", .attach); + try std.testing.expectEqual(std.process.Child.StdIo.Pipe, attach.stdin_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Pipe, attach.stdout_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, attach.stderr_behavior); + try std.testing.expectEqualStrings("C:\\GraphCode\\bin", attach.cwd.?); + try std.testing.expectEqual(attach_argv.len, attach.argv.len); + try std.testing.expectEqual(true, attach.create_no_window); + + const kill_argv = [_][]const u8{ "zmx.exe", "kill", "graphcode-alpha" }; + const control = child(std.testing.allocator, &kill_argv, null, .control); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stdin_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stdout_behavior); + try std.testing.expectEqual(std.process.Child.StdIo.Ignore, control.stderr_behavior); + try std.testing.expect(control.cwd == null); + try std.testing.expectEqual(true, control.create_no_window); +} + test "canonical zmx session names add the ownership prefix exactly once" { var storage: [64]u8 = undefined; try std.testing.expectEqualStrings("graphcode-alpha", try nameBuffer("alpha", &storage));