diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d79b80d1..6c7af009 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -77,6 +77,8 @@ jobs: run: bash tools/ci/bash-body-check.sh --self-test && bash tools/ci/bash-body-check.sh - name: run jobs test their fetched kit before use, the wiped-jobs-folder class (self-test first, then the tree) run: bash tools/ci/kit-path-check.sh --self-test && bash tools/ci/kit-path-check.sh + - name: every Windows spawn of the app runs with a hidden console (self-test first, then the tree) + run: node tools/ci/windows-spawn-check.mjs --self-test && node tools/ci/windows-spawn-check.mjs - name: pinned guest programs match their manifest and are built only by pin-guests.sh run: bash tools/ci/pinned-guests-check.sh - name: root prover playbooks kill the GPU server and unlink its socket (the root-socket class, 5 October 2026) diff --git a/app/igneum-app/src/jobrun.rs b/app/igneum-app/src/jobrun.rs index 41754779..3a6e2da5 100644 --- a/app/igneum-app/src/jobrun.rs +++ b/app/igneum-app/src/jobrun.rs @@ -1115,16 +1115,11 @@ fn run_script(shared: &Arc, job: &Job, sink: &Sink, jobs_dir: &Path, dat .map(|(k, v)| format!("$env:{k} = '{}'\r\n", v.replace('\'', "''"))) .collect(); let wrapper = dir.join("elevated.ps1"); - let w = format!("{env_lines}& '{}' *>&1 | Out-File -FilePath '{}' -Encoding utf8\r\nexit $LASTEXITCODE\r\n", script.display().to_string().replace('\'', "''"), out_file.display().to_string().replace('\'', "''")); + let w = elevated_wrapper(&env_lines, &script.display().to_string(), &out_file.display().to_string()); std::fs::write(&wrapper, [b"\xEF\xBB\xBF".as_slice(), w.as_bytes()].concat()).map_err(|e| e.to_string())?; let _ = std::fs::remove_file(&out_file); let inner = format!("-NoProfile -ExecutionPolicy Bypass -File \"{}\"", wrapper.display()); - // A refused or unanswered UAC prompt makes Start-Process throw (`$p` stays null) and `exit $p.ExitCode` - // would exit 0: the 5 October 2026 driver job on PC 1 was reported "done" after Windows cancelled its - // prompt at 122 s. The launch failure is exit 251 and says so on stderr. - let ps = format!("try {{ $p = Start-Process -FilePath powershell.exe -ArgumentList '{}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop }} catch {{ Write-Error ('elevated launch failed (UAC refused, cancelled or timed out): ' + $_.Exception.Message); exit 251 }}; if ($null -eq $p) {{ Write-Error 'elevated launch failed: no process'; exit 251 }}; exit $p.ExitCode", inner.replace('\'', "''")); - cmd = Command::new(crate::platform::tool("powershell")); - cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &ps]); + cmd = crate::platform::elevated_command("powershell.exe", &inner); } else if shell == "powershell" { cmd = Command::new(crate::platform::tool("powershell")); cmd.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", &script.display().to_string()]); @@ -1185,6 +1180,23 @@ fn follow_file(sink: &Sink, path: PathBuf) -> Follow { Follow { stop, handle } } +/// The PowerShell wrapper an elevated job runs (its own process, its own environment): the IGNEUM_* values, then one +/// line about its console (the elevated process cannot inherit the engine's headless console and gets one of its own; +/// `-WindowStyle Hidden` on the launch keeps it hidden, and this line is the running measurement of that on every +/// elevated job: "elevated console: hwnd N visible False"), then the script, everything into `out_file` for the engine +/// to read back. The console-window class, PC 1, 5 October 2026 (tools/windows/console-watch-elevated.ps1). +fn elevated_wrapper(env_lines: &str, script: &str, out_file: &str) -> String { + let (script, out) = (crate::platform::ps_quote(script), crate::platform::ps_quote(out_file)); + format!( + "{env_lines}$ErrorActionPreference = 'Continue'\r\n\ + $igc = ''\r\n\ + try {{ Add-Type -Name IgCon -Namespace Igneum -MemberDefinition '[DllImport(\"kernel32.dll\")] public static extern System.IntPtr GetConsoleWindow(); [DllImport(\"user32.dll\")] public static extern bool IsWindowVisible(System.IntPtr h);'; $h = [Igneum.IgCon]::GetConsoleWindow(); $igc = \"elevated console: hwnd $h visible $([Igneum.IgCon]::IsWindowVisible($h))\" }} catch {{ $igc = \"elevated console: unknown ($_)\" }}\r\n\ + $igc | Out-File -FilePath '{out}' -Encoding utf8\r\n\ + & '{script}' *>&1 | Out-File -FilePath '{out}' -Encoding utf8 -Append\r\n\ + exit $LASTEXITCODE\r\n" + ) +} + fn finish_ran(ran: Ran, what: &str) -> Result { match ran.code { Some(0) => Ok(Done { status: "done".into(), exit: 0, summary: format!("{what} finished, exit 0"), extra: json!({}) }), @@ -1311,10 +1323,24 @@ mod tests { assert!(d.summary.contains("administrator prompt"), "{}", d.summary); let d = finish_ran(Ran { code: Some(0), timed_out: false }, "script").unwrap(); assert_eq!(d.status, "done"); - // the launcher string itself: a thrown Start-Process must not fall through to `exit $p.ExitCode` - let src = include_str!("jobrun.rs"); - assert!(src.contains("-Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop }} catch {{")); - assert!(src.contains("if ($null -eq $p) {{ Write-Error 'elevated launch failed: no process'; exit 251 }}")); + // the launcher string itself (platform::elevated_ps_line since 13755b9): a thrown Start-Process must not fall + // through to `exit $p.ExitCode` + let l = crate::platform::elevated_ps_line("powershell.exe", "-NoProfile -File x.ps1"); + assert!(l.contains("-Verb RunAs -Wait -WindowStyle Hidden -PassThru -ErrorAction Stop } catch {"), "{l}"); + assert!(l.contains("if ($null -eq $p) { Write-Error 'elevated launch failed: no process'; exit 251 }"), "{l}"); + } + + #[test] + fn elevated_wrapper_reports_its_console_then_runs_the_script() { + let w = elevated_wrapper("$env:IGNEUM_JOB_ID = 'j1'\r\n", r"C:\jobs\j1\script.ps1", r"C:\jobs\it's\elevated-output.log"); + assert!(w.starts_with("$env:IGNEUM_JOB_ID = 'j1'\r\n$ErrorActionPreference = 'Continue'\r\n"), "{w}"); + assert!(w.contains("GetConsoleWindow()") && w.contains("IsWindowVisible("), "{w}"); + assert!(w.contains("$igc | Out-File -FilePath 'C:\\jobs\\it''s\\elevated-output.log' -Encoding utf8\r\n"), "{w}"); + assert!(w.contains("& 'C:\\jobs\\j1\\script.ps1' *>&1 | Out-File -FilePath 'C:\\jobs\\it''s\\elevated-output.log' -Encoding utf8 -Append\r\n"), "{w}"); + assert!(w.ends_with("exit $LASTEXITCODE\r\n"), "{w}"); + // every line ends in CRLF (the file is written for Windows PowerShell): the env line and six of its own + assert_eq!(w.matches("\r\n").count(), 7, "{w:?}"); + assert_eq!(w.matches('\n').count(), 7, "{w:?}"); } #[test] @@ -1801,6 +1827,7 @@ fn spawn_relaunch_helper(shared: &Arc) -> Result<(), String> { { let dir = std::env::current_exe().ok().and_then(|p| p.parent().map(|d| d.to_path_buf())).ok_or("cannot find the install folder")?; let exe = dir.join("igneum-app.exe"); + // console: igneum-app.exe is a windows-subsystem program in release builds (main.rs), it never gets a console; SW_HIDE would hide the window host it opens let ps = format!("Start-Sleep 8; Start-Process -FilePath '{}' -ArgumentList '--launch' -WorkingDirectory '{}'", exe.display().to_string().replace('\'', "''"), dir.display().to_string().replace('\'', "''")); c = Command::new(crate::platform::tool("powershell")); c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-WindowStyle", "Hidden", "-Command", &ps]); diff --git a/app/igneum-app/src/ota.rs b/app/igneum-app/src/ota.rs index f119b254..81dde696 100644 --- a/app/igneum-app/src/ota.rs +++ b/app/igneum-app/src/ota.rs @@ -1278,6 +1278,7 @@ function EngineAlive() { return [bool](Get-Process -Id $EnginePid -ErrorAction S function Relaunch() { if (EngineAlive) { return } $exe = Join-Path $InstallDir 'igneum-app.exe' + # console: igneum-app.exe is a windows-subsystem program (no console); -WindowStyle Hidden would hide the window host it opens if (Test-Path $exe) { Log 'engine gone and nothing installed: starting the old app again'; Start-Process -FilePath $exe -ArgumentList '--launch' -WorkingDirectory $InstallDir | Out-Null } } Log "$Mode : engine $EnginePid installer '$Installer' version $Version (the engine keeps mining until the installer runs)" @@ -1292,6 +1293,7 @@ $setupArgs = @('/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART', '/CLOSEAPPLICAT try { # no -Verb RunAs: a per-user installer just runs; an administrator installer makes Windows ask, and a declined or # timed-out prompt comes back here as an exception with the engine still mining + # console: the Inno Setup installer is a GUI program (no console), /VERYSILENT shows nothing $p = Start-Process -FilePath $Installer -ArgumentList $setupArgs -Wait -PassThru if ($p.ExitCode -eq 0) { if ($Mode -eq 'rollback') { Done $false "Igneum Miner $Version did not stay up twice; the previous version was reinstalled" $true $false } diff --git a/app/igneum-app/src/platform.rs b/app/igneum-app/src/platform.rs index d41c26d5..a3e8e694 100644 --- a/app/igneum-app/src/platform.rs +++ b/app/igneum-app/src/platform.rs @@ -396,10 +396,7 @@ pub fn sync_clock() -> Result { #[cfg(windows)] { let cmd = tool("cmd").display().to_string(); - let script = format!("Start-Process -FilePath '{cmd}' -ArgumentList '/c net start w32time & w32tm /resync /force' -Verb RunAs -Wait -WindowStyle Hidden"); - let mut c = Command::new(tool("powershell")); - c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]); - quiet(&mut c); + let mut c = elevated_command(&cmd, "/c net start w32time & w32tm /resync /force"); let out = c.output().map_err(|e| e.to_string())?; if out.status.success() { Ok("asked Windows Time to resync (w32tm /resync)".into()) @@ -425,12 +422,8 @@ pub fn sync_clock() -> Result { pub fn run_elevated(cmdline: &str) -> Result<(), String> { #[cfg(windows)] { - let escaped = cmdline.replace('\'', "''"); let cmd = tool("cmd").display().to_string(); - let script = format!("$p = Start-Process -FilePath '{cmd}' -ArgumentList '/c {escaped}' -Verb RunAs -Wait -WindowStyle Hidden -PassThru; exit $p.ExitCode"); - let mut c = Command::new(tool("powershell")); - c.args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", &script]); - quiet(&mut c); + let mut c = elevated_command(&cmd, &format!("/c {cmdline}")); let out = c.output().map_err(|e| e.to_string())?; if out.status.success() { Ok(()) diff --git a/app/igneum-app/src/wslhost.rs b/app/igneum-app/src/wslhost.rs index f36ff604..9bee6d04 100644 --- a/app/igneum-app/src/wslhost.rs +++ b/app/igneum-app/src/wslhost.rs @@ -189,6 +189,7 @@ pub fn bash_line(file: &Path, login: bool, args: &[&str]) -> String { /// command line exactly as `bash_line` wrote it; elsewhere the words are ordinary arguments (nothing runs wsl there). /// The caller adds stdio, the hidden-window flag and the timeout. pub fn command(wsl_exe: &Path, distro: &str, user: Option<&str>, file: &Path, login: bool, args: &[&str]) -> Command { + // console: a builder; every caller runs it through run_capture, run_streamed or platform::quiet (tools/ci/windows-spawn-check.mjs) let mut c = Command::new(wsl_exe); c.args(["-d", distro]); if let Some(u) = user.filter(|u| !u.is_empty()) { diff --git a/docs/bugs.md b/docs/bugs.md index 4ad0e819..4b551ae5 100644 --- a/docs/bugs.md +++ b/docs/bugs.md @@ -7,6 +7,7 @@ shard run reported as exit 0, 7a7e873). | Date | Symptom | Cause | Fix | Proven by | |---|---|---|---|---| | 4 Oct 2026 | Every `ci` run on master red since 67bf226 (eleven pushes), unnoticed | `sim/difficulty/records/testnet-v2-2026-10-04.schedule.log` carried a home path; `.log` was outside the identity scrub's extension list in `tools/ci/identity-check.sh` (and in the mirror's `tools/sync.sh`) | 2996cca: `.log` scrubbed like the other text files; the record rewritten with `~`; the same list in igneum-public `tools/sync.sh` (local commit e18256d, not pushed) | `bash tools/ci/identity-check.sh` 0 hits locally; run 37226816xxx on master green | +| 5 Oct 2026 | PC 1 (Windows 11 Pro 26200, default terminal Windows Terminal 1.24): "Windows Command Processor" windows whenever a remote job runs (the project lead) | measured, not guessed: `tools/windows/console-watch.ps1` (job run-20261005-182528) started every candidate child from the app's job runner, whose console is headless (`conhost.exe 0x4`, hwnd 0), with a user32 EnumWindows sampler every 30 ms: powershell, cmd, query, curl, nvidia-smi, wsl --status, a distro, interop cmd and powershell, `powershell -WindowStyle Hidden`, `Start-Process -WindowStyle Hidden`: 0 windows each; `Start-Process cmd` in a new console: a Terminal window and a cmd PseudoConsoleWindow (the known-failed case fires). The 25-minute background watcher (console-watch-bg.ps1, run-20261005-184330, 18:44 to 19:09 UTC, every 200 ms) across an app restart, a build job, two run jobs, two collect jobs and the sweep helper's elevated launch at 19:04:43: 0 console or Terminal windows, 69 conhost starts (every one `conhost.exe 0x4`, headless, under curl, wsl, wslhost, powershell), 1 cmd.exe (under wslhost, WSL interop, no window). The one road that creates a console of its own is the elevated launch (`Start-Process -Verb RunAs`, the AppInfo service: the power cap, the sweep helper, the clock sync, an elevated job); it carried `-WindowStyle Hidden` in four copies, and "Windows Command Processor" is also the name on the UAC prompt the engine raises for cmd.exe (the sweep helper prompted at 17:00, 17:30 and 18:12 UTC, the power cap at every start; the elevated watcher's own prompt, run-20261005-184610, timed out unanswered at 122 s) | `platform::elevated_ps_line` + `elevated_command`: one builder for every elevated launch, hidden by construction, exit 251 when the prompt is refused; the elevated job wrapper reports its own console (`elevated console: hwnd N visible False`) on every elevated job; `tools/ci/windows-spawn-check.mjs` fails CI on a Command::new without the quiet flag, a creation_flags other than CREATE_NO_WINDOW, a Start-Process without -WindowStyle Hidden/-NoNewWindow, or a host.cpp spawn without CREATE_NO_WINDOW / SW_HIDE | the watcher's known-failed case (2 windows) and known-finished case (0); the CI check's self-test (9 cases) and the tree (0 hits); the igneum-app test suite on PC 1 | | 4 Oct 2026 | `collect-pc1-board3` printed PowerShell parse errors (`.Name`, `.AdapterRAM`) | the publishing shell expanded `$_` inside double quotes to nothing before the command reached the jobs file; nothing to do with Format-List or Out-String (board2 and board4 printed their values) | publish-jobs.sh refuses a collect command that pipes into a script block without `$_` or `$PSItem` | the eaten form refused with the reason, the single-quoted form published to a test folder | | 4 Oct 2026 | the same job reported `done (exit 0)` over `command exit Some(1)` | `run_collect` in `app/igneum-app/src/jobrun.rs` builds `Done` from the upload count only; the command's exit code is logged and dropped | branch `bugfix-collect-exit`, 35ccdc8 rebased on c257444 (app engine; merge by the main session) | `cargo test --bin igneum-app`: all 28 tests pass on the rebased branch; the new one covers the board3 shape (`Some(1)` is failed exit 1), `Some(0)` done, the cap as timeout, failed uploads still failing | | 4 Oct 2026 | `publish-jobs.sh --deploy` said "not reachable, differs from the local one, or does not verify yet" after a deploy that had succeeded | one check the instant the CLI returned, while the edge still served the previous file; the deploy's own exit status was hidden by `\|\| true` | `verify_live`: up to `--tries` (12) checks 5 s apart, each failure names its condition; `publish-jobs.sh verify` re-checks on its own; a failed deploy stops before the check | finished: `verify --tries 2` against the live file (try 1 of 2); failed: a local server with an older file ("differs", both publish stamps named) and a closed port ("is not reachable") | diff --git a/tools/ci/windows-spawn-check.mjs b/tools/ci/windows-spawn-check.mjs new file mode 100644 index 00000000..898a87ba --- /dev/null +++ b/tools/ci/windows-spawn-check.mjs @@ -0,0 +1,106 @@ +#!/usr/bin/env node +// The console-window class (5 October 2026, PC 1): a child the app starts on Windows without CREATE_NO_WINDOW, or an +// elevated child started without SW_HIDE, gets a console of its own, and on Windows 11 with Windows Terminal as the +// default terminal that console is a visible Terminal window on the user's desk. Rule: every process the app starts +// on Windows runs with a hidden console. This check fails CI when +// - a `Command::new(` in app/igneum-app/src is not quieted within 20 lines: crate::platform::quiet, run_timeout, +// run_capture, run_streamed, spawn_detached, elevated_command, or creation_flags(0x0800_0000) (CREATE_NO_WINDOW); +// programs that only exist off Windows (nohup, osascript, pkexec, hdiutil, ...) are allowed, and a +// `// console: ` comment on the line or the line above allows a builder the caller quiets; +// the check looks 2 lines back as well, for `run_timeout(\n Command::new(...)`; +// - a `creation_flags(` carries anything but 0x0800_0000 (DETACHED_PROCESS made powershell exit at start-up, +// 0.3.0 to 0.3.4, docs/bugs.md); +// - a PowerShell `Start-Process` written by the Rust code lacks `-WindowStyle Hidden` or `-NoNewWindow` (a GUI +// program, which never gets a console, takes a `# console: ` comment on the line or the line above); +// - app/windows/host.cpp calls CreateProcessW without CREATE_NO_WINDOW or sets up a ShellExecuteExW without +// nShow = SW_HIDE (ShellExecuteW "open" of a URL is the browser, allowed). +// node tools/ci/windows-spawn-check.mjs the tree +// node tools/ci/windows-spawn-check.mjs --self-test the rules on known-good and known-bad samples +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import { join, resolve, dirname, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..'); +const QUIET = /\bquiet\(|\brun_timeout\(|\brun_capture\(|\brun_streamed\(|\bspawn_detached\(|\belevated_command\(|creation_flags\(0x0800_0000\)/; +const OFF_WINDOWS = /Command::new\((?:crate::platform::)?tool\("(?:nohup|osascript|pkexec|hdiutil|ditto|open|xattr|system_profiler|sysctl|sntp|scutil|caffeinate)"\)|Command::new\("(?:pkexec|xdg-open|\/bin\/bash|\/usr\/bin\/[a-z]+)"\)|Command::new\(staged\.join\("Contents\/MacOS/; +const WINDOW = 20; + +export function checkRust(text, file) { + const lines = text.split('\n'); + const out = []; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (/Command::new\(/.test(l) && !/^\s*\/\//.test(l)) { + const allowed = OFF_WINDOWS.test(l) || /\/\/ console:/.test(l) || (i > 0 && /\/\/ console:/.test(lines[i - 1])); + if (!allowed) { + const span = lines.slice(Math.max(0, i - 2), i + WINDOW).join('\n'); // 2 lines back: run_timeout(\n Command::new(...) + if (!QUIET.test(span)) out.push(`${file}:${i + 1}: Command::new without a hidden console within ${WINDOW} lines (quiet, run_timeout, run_capture, run_streamed, spawn_detached, elevated_command or creation_flags(0x0800_0000)); add one, or a '// console: ' comment`); + } + } + const cf = /creation_flags\(([^)]*)\)/.exec(l); + if (cf && cf[1].trim() !== '0x0800_0000') out.push(`${file}:${i + 1}: creation_flags(${cf[1]}) is not CREATE_NO_WINDOW alone (0x0800_0000)`); + if (/Start-Process\b/.test(l) && !/^\s*\/\//.test(l) && !/-WindowStyle Hidden|-NoNewWindow/.test(l) && !/(\/\/|#) console:/.test(l) && !(i > 0 && /(\/\/|#) console:/.test(lines[i - 1]))) out.push(`${file}:${i + 1}: Start-Process without -WindowStyle Hidden or -NoNewWindow (a GUI program takes a '# console: ' comment)`); + } + return out; +} + +export function checkHost(text, file) { + const lines = text.split('\n'); + const out = []; + for (let i = 0; i < lines.length; i++) { + const l = lines[i]; + if (/CreateProcessW?\s*\(/.test(l) && !/CREATE_NO_WINDOW/.test(lines.slice(i, i + 3).join('\n'))) out.push(`${file}:${i + 1}: CreateProcess without CREATE_NO_WINDOW`); + if (/ShellExecuteExW?\s*\(/.test(l) && !/nShow\s*=\s*SW_HIDE/.test(lines.slice(Math.max(0, i - 12), i + 1).join('\n'))) out.push(`${file}:${i + 1}: ShellExecuteEx without nShow = SW_HIDE in the 12 lines before it`); + if (/ShellExecuteW?\s*\(/.test(l) && !/ShellExecuteExW?/.test(l) && !/L"open"/.test(l)) out.push(`${file}:${i + 1}: ShellExecute that is not the browser "open" of a URL`); + } + return out; +} + +function walk(dir, ext, acc = []) { + for (const e of readdirSync(dir)) { + const p = join(dir, e); + if (statSync(p).isDirectory()) { if (e !== 'target') walk(p, ext, acc); } else if (p.endsWith(ext)) acc.push(p); + } + return acc; +} + +function selfTest() { + const good = `fn a() {\n let mut c = Command::new(crate::platform::tool("powershell"));\n c.args(["-NoProfile"]);\n crate::platform::quiet(&mut c);\n c.spawn();\n}\n`; + const bad = `fn a() {\n let mut c = Command::new(crate::platform::tool("powershell"));\n c.args(["-NoProfile"]);\n c.spawn();\n}\n`; + const badFlag = `c.creation_flags(0x0000_0008);\n`; + const badPs = `let ps = format!("Start-Process -FilePath '{}' -Wait", exe);\n`; + const okPs = `let ps = format!("Start-Process -FilePath '{}' -Wait -WindowStyle Hidden", exe);\n`; + const offWin = `let out = Command::new(tool("osascript")).args(["-e", "x"]).output();\n`; + const allowed = `// console: the caller quiets it\nlet mut c = Command::new(wsl_exe);\n`; + const hostGood = `sei.nShow = SW_HIDE;\nif (!ShellExecuteExW(&sei)) {}\nCreateProcessW(exe, buf, nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, dir, &si, &pi);\nShellExecuteW(nullptr, L"open", url, nullptr, nullptr, SW_SHOWNORMAL);\n`; + const hostBad = `sei.nShow = SW_SHOW;\nif (!ShellExecuteExW(&sei)) {}\nCreateProcessW(exe, buf, nullptr, nullptr, TRUE, 0, nullptr, dir, &si, &pi);\n`; + const cases = [ + ['quieted Command', checkRust(good, 't.rs').length === 0], + ['bare Command fails', checkRust(bad, 't.rs').length === 1], + ['DETACHED_PROCESS fails', checkRust(badFlag, 't.rs').length === 1], + ['Start-Process without Hidden fails', checkRust(badPs, 't.rs').length === 1], + ['Start-Process with Hidden passes', checkRust(okPs, 't.rs').length === 0], + ['off-Windows program passes', checkRust(offWin, 't.rs').length === 0], + ['console: comment passes', checkRust(allowed, 't.rs').length === 0], + ['host.cpp good passes', checkHost(hostGood, 'h.cpp').length === 0], + ['host.cpp bad fails twice', checkHost(hostBad, 'h.cpp').length === 2], + ]; + let fail = 0; + for (const [name, ok] of cases) { console.log(`${ok ? 'ok ' : 'FAIL'} ${name}`); if (!ok) fail++; } + return fail; +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + if (process.argv.includes('--self-test')) { + const f = selfTest(); + console.log(f ? `windows-spawn: self-test FAILED (${f})` : 'windows-spawn: self-test ok'); + process.exit(f ? 1 : 0); + } + const problems = []; + for (const f of walk(join(ROOT, 'app', 'igneum-app', 'src'), '.rs')) problems.push(...checkRust(readFileSync(f, 'utf8'), relative(ROOT, f))); + const host = join(ROOT, 'app', 'windows', 'host.cpp'); + try { problems.push(...checkHost(readFileSync(host, 'utf8'), relative(ROOT, host))); } catch {} + for (const p of problems) console.log(p); + console.log(problems.length ? `windows-spawn: ${problems.length} spawn(s) without a hidden console` : 'windows-spawn: every Windows spawn in app/igneum-app/src and app/windows/host.cpp runs with a hidden console'); + process.exit(problems.length ? 1 : 0); +} diff --git a/tools/windows/console-watch-bg.ps1 b/tools/windows/console-watch-bg.ps1 new file mode 100644 index 00000000..4a2056f9 --- /dev/null +++ b/tools/windows/console-watch-bg.ps1 @@ -0,0 +1,101 @@ +# Background console-window watcher for a Windows PC running the Igneum Miner app: the second half of +# tools/windows/console-watch.ps1. That one proved (PC 1, 5 October 2026, job run-20261005-182528) that no child a +# job script starts from the app's headless console opens a window; this one finds what does. A signed `run` job +# starts a detached PowerShell (Start-Process -WindowStyle Hidden, the shape the first watcher showed opens nothing) +# and returns at once; the detached process samples for WATCH_MINUTES and writes \console-windows.log: +# window pid

[] a new visible console or terminal window +# <utc> proc <name> pid <p> cmd <command line> <- <parent chain, name pid and command line each> +# a new cmd, powershell, wsl, conhost, OpenConsole or WindowsTerminal +# <utc> gone <name> pid <p> one of those ended (the window's life) +# Read it back with a collect job: packaging/ota/publish-jobs.sh add --kind collect --target ae432dc7 \ +# --glob "app/jobs/<this job id>/console-windows.log" --deploy +$ErrorActionPreference = 'Continue' +$minutes = 25 +$dir = $env:IGNEUM_JOB_DIR +$log = Join-Path $dir 'console-windows.log' +$bg = Join-Path $dir 'bg.ps1' +# the detached body: params first (PowerShell wants them at the top), then the sampler +$body = @' +param([string] $Log, [int] $Minutes) +$ErrorActionPreference = 'Continue' +$src = @" +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +public static class IgWin2 { + public delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc p, IntPtr l); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr h, StringBuilder s, int n); + public static List<string> Visible() { + var list = new List<string>(); + EnumWindows((h, l) => { + if (!IsWindowVisible(h)) return true; + uint pid; GetWindowThreadProcessId(h, out pid); + var t = new StringBuilder(512); GetWindowText(h, t, 512); + var c = new StringBuilder(256); GetClassName(h, c, 256); + list.Add(((long)h).ToString() + "|" + pid + "|" + c + "|" + t); + return true; + }, IntPtr.Zero); + return list; + } +} +"@ +Add-Type -TypeDefinition $src +function Now() { (Get-Date).ToUniversalTime().ToString('HH:mm:ss.fff') } +function Put([string] $l) { Add-Content -Path $Log -Value $l -Encoding UTF8 } +function Chain([int] $procId) { + $out = @(); $seen = @{}; $p = $procId + for ($i = 0; $i -lt 6 -and $p -gt 0 -and -not $seen.ContainsKey($p); $i++) { + $seen[$p] = 1 + $ci = Get-CimInstance Win32_Process -Filter "ProcessId=$p" -ErrorAction SilentlyContinue + if (-not $ci) { $out += ("pid " + $p + " gone"); break } + $cl = [string]$ci.CommandLine; if ($cl.Length -gt 160) { $cl = $cl.Substring(0, 160) + '...' } + $out += ($ci.Name + " pid " + $p + " [" + $cl + "]") + $p = $ci.ParentProcessId + } + return ($out -join ' <- ') +} +$watch = 'cmd', 'powershell', 'pwsh', 'wsl', 'wslhost', 'conhost', 'OpenConsole', 'WindowsTerminal' +$classes = 'ConsoleWindowClass', 'CASCADIA_HOSTING_WINDOW_CLASS', 'PseudoConsoleWindow' +$knownWin = @{}; $knownProc = @{} +$first = $true +$end = (Get-Date).AddMinutes($Minutes) +Put ((Now) + " start: watching for " + $Minutes + " min, pid " + $PID) +while ((Get-Date) -lt $end) { + try { + foreach ($w in [IgWin2]::Visible()) { + $f = $w.Split('|', 4) + if ($knownWin.ContainsKey($f[0])) { continue } + $knownWin[$f[0]] = 1 + if ($first) { continue } + $pn = try { (Get-Process -Id ([int]$f[1]) -ErrorAction Stop).ProcessName } catch { 'gone' } + if ($classes -contains $f[2] -or $watch -contains $pn) { Put ((Now) + " window " + $pn + " pid " + $f[1] + " [" + $f[2] + "] " + $f[3]) } + } + $live = @{} + foreach ($p in @(Get-Process -Name $watch -ErrorAction SilentlyContinue)) { + $live[$p.Id] = 1 + if ($knownProc.ContainsKey($p.Id)) { continue } + $knownProc[$p.Id] = $p.ProcessName + if ($first) { continue } + Put ((Now) + " proc " + $p.ProcessName + " pid " + $p.Id + " " + (Chain $p.Id)) + } + foreach ($k in @($knownProc.Keys)) { if (-not $live.ContainsKey($k)) { if (-not $first) { Put ((Now) + " gone " + $knownProc[$k] + " pid " + $k) }; $knownProc.Remove($k) } } + if ($first) { Put ((Now) + " baseline: " + $knownWin.Count + " visible windows, " + $knownProc.Count + " watched processes: " + (($knownProc.GetEnumerator() | ForEach-Object { $_.Value + ' ' + $_.Key }) -join ', ')) } + $first = $false + } catch { Put ((Now) + " error " + $_) } + Start-Sleep -Milliseconds 200 +} +Put ((Now) + " end") +'@ +[IO.File]::WriteAllText($bg, $body, (New-Object System.Text.UTF8Encoding($true))) +if (Test-Path $log) { Remove-Item $log -Force } +$p = Start-Process -FilePath powershell.exe -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $bg, '-Log', $log, '-Minutes', $minutes) -WindowStyle Hidden -PassThru +Start-Sleep -Seconds 3 +$alive = try { -not (Get-Process -Id $p.Id -ErrorAction Stop).HasExited } catch { $false } +Write-Output ("RESULT watcher: pid " + $p.Id + " alive " + $alive + " for " + $minutes + " min, log " + $log) +if (Test-Path $log) { Get-Content $log | ForEach-Object { Write-Output ("RESULT first: " + $_) } } +exit $(if ($alive) { 0 } else { 1 }) diff --git a/tools/windows/console-watch-elevated.ps1 b/tools/windows/console-watch-elevated.ps1 new file mode 100644 index 00000000..ea83b435 --- /dev/null +++ b/tools/windows/console-watch-elevated.ps1 @@ -0,0 +1,84 @@ +# Console-window watcher for the ELEVATED job path (app/igneum-app/src/jobrun.rs run_script with elevated=true: the +# app's headless powershell runs `Start-Process powershell.exe -Verb RunAs -Wait -WindowStyle Hidden`, the AppInfo +# service creates this process after the UAC prompt). The engine's power cap, the sweep helper and the clock sync take +# the same road with cmd.exe (platform.rs run_elevated, sync_clock; app/windows/host.cpp runElevated). This script +# runs INSIDE the elevated process and reports whether its own console has a window, which host serves it, and +# whether a Windows Terminal window appeared for it. One UAC prompt on the PC; a few seconds. +# packaging/ota/publish-jobs.sh add --kind run --target ae432dc7 --elevated --timeout-minutes 3 \ +# --script tools/windows/console-watch-elevated.ps1 --title "PC 1: elevated console watcher" --deploy +$ErrorActionPreference = 'Continue' +$src = @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +public static class IgWin3 { + public delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc p, IntPtr l); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr h, StringBuilder s, int n); + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + public static List<string> Visible() { + var list = new List<string>(); + EnumWindows((h, l) => { + if (!IsWindowVisible(h)) return true; + uint pid; GetWindowThreadProcessId(h, out pid); + var t = new StringBuilder(512); GetWindowText(h, t, 512); + var c = new StringBuilder(256); GetClassName(h, c, 256); + list.Add(((long)h).ToString() + "|" + pid + "|" + c + "|" + t); + return true; + }, IntPtr.Zero); + return list; + } +} +'@ +Add-Type -TypeDefinition $src +function Say([string] $m) { Write-Output $m } +function ProcName([int] $procId) { try { (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch { 'gone' } } +function Chain([int] $procId) { + $out = @(); $seen = @{}; $p = $procId + for ($i = 0; $i -lt 6 -and $p -gt 0 -and -not $seen.ContainsKey($p); $i++) { + $seen[$p] = 1 + $ci = Get-CimInstance Win32_Process -Filter "ProcessId=$p" -ErrorAction SilentlyContinue + if (-not $ci) { $out += ("pid " + $p + " gone"); break } + $cl = [string]$ci.CommandLine; if ($cl.Length -gt 140) { $cl = $cl.Substring(0, 140) + '...' } + $out += ($ci.Name + " pid " + $p + " [" + $cl + "]") + $p = $ci.ParentProcessId + } + return ($out -join ' <- ') +} +$me = [System.Diagnostics.Process]::GetCurrentProcess() +$id = [Security.Principal.WindowsIdentity]::GetCurrent() +$admin = (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +Say ("RESULT elevated: " + $admin + " user " + $id.Name + " session " + $me.SessionId + " chain " + (Chain $me.Id)) +$hwnd = [IgWin3]::GetConsoleWindow() +$cls = '' +if ($hwnd -ne [IntPtr]::Zero) { $sb = New-Object System.Text.StringBuilder 256; [void][IgWin3]::GetClassName($hwnd, $sb, 256); $cls = $sb.ToString() } +$vis = if ($hwnd -ne [IntPtr]::Zero) { [IgWin3]::IsWindowVisible($hwnd) } else { 'no window' } +Say ("RESULT self-console: hwnd " + $hwnd + " class [" + $cls + "] visible " + $vis) +# the hosts that serve this process: a conhost with this pid as parent (classic), or an OpenConsole + WindowsTerminal +# pair started by svchost in the last seconds (the default-terminal handoff) +$since = (Get-Date).AddSeconds(-20) +foreach ($h in @(Get-CimInstance Win32_Process -Filter "Name='conhost.exe' OR Name='OpenConsole.exe' OR Name='WindowsTerminal.exe'" -ErrorAction SilentlyContinue)) { + $created = try { [Management.ManagementDateTimeConverter]::ToDateTime($h.CreationDate) } catch { $null } + if ($h.ParentProcessId -eq $me.Id -or ($created -and $created -gt $since)) { + Say ("RESULT host: " + $h.Name + " pid " + $h.ProcessId + " parent " + (ProcName $h.ParentProcessId) + " started " + $(if ($created) { $created.ToUniversalTime().ToString('HH:mm:ss') } else { '?' }) + " cmd " + $h.CommandLine) + } +} +$wins = @([IgWin3]::Visible() | Where-Object { $f = $_.Split('|', 4); $f[2] -eq 'CASCADIA_HOSTING_WINDOW_CLASS' -or $f[2] -eq 'ConsoleWindowClass' -or $f[2] -eq 'PseudoConsoleWindow' }) +Say ("RESULT console-windows-now: " + $wins.Count) +foreach ($w in $wins) { $f = $w.Split('|', 4); Say ("RESULT window: " + (ProcName ([int]$f[1])) + " pid " + $f[1] + " [" + $f[2] + "] " + $f[3]) } +# a child the elevated script starts the way the sweep helper and the power cap do (cmd, inherited console), watched +$before = @{}; foreach ($w in [IgWin3]::Visible()) { $before[$w.Split('|', 4)[0]] = 1 } +$p = Start-Process -FilePath cmd.exe -ArgumentList '/c ping -n 3 127.0.0.1 >nul' -NoNewWindow -PassThru +$seen = @{} +for ($i = 0; $i -lt 30; $i++) { + foreach ($w in [IgWin3]::Visible()) { $f = $w.Split('|', 4); if (-not $before.ContainsKey($f[0]) -and -not $seen.ContainsKey($f[0])) { $seen[$f[0]] = (ProcName ([int]$f[1])) + " pid " + $f[1] + " [" + $f[2] + "] " + $f[3] } } + Start-Sleep -Milliseconds 100 +} +try { $p.WaitForExit(10000) | Out-Null } catch {} +Say ("RESULT probe cmd-inherit-elevated: " + $seen.Count + " new window(s)") +foreach ($s in $seen.Values) { Say ("RESULT window: " + $s + " (probe cmd-inherit-elevated)") } +exit 0 diff --git a/tools/windows/console-watch.ps1 b/tools/windows/console-watch.ps1 new file mode 100644 index 00000000..e7cf96ea --- /dev/null +++ b/tools/windows/console-watch.ps1 @@ -0,0 +1,181 @@ +# Console-window watcher for a Windows PC running the Igneum Miner app. A signed `run` job (app/igneum-app/src/jobrun.rs, +# shell powershell, not elevated): while a sampler thread enumerates the visible top-level windows (user32 EnumWindows, +# IsWindowVisible, GetWindowThreadProcessId, GetClassName, GetWindowText) and the console host processes (conhost, +# OpenConsole, WindowsTerminal, with their command lines and parents) every 30 ms, the main thread starts each +# candidate child the way a job script or the app does, and every window or host that appears during a probe is +# reported against it: +# RESULT terminal: ... the default-terminal delegation (HKCU\Console\%%Startup) and the host process counts +# RESULT self: ... the job's own console (hidden or not) and the conhost that serves it +# RESULT probe <n>: ... exit code, duration, how many windows and hosts appeared +# RESULT window: <process> [<class>] <title> (probe <n>) +# RESULT host: <name> pid <p> parent <process> cmd <command line> (probe <n>) +# Trust test (CLAUDE.md: a watcher is trusted only after a known-finished and a known-failed case): probe +# start-process-new-console MUST report a window (cmd in a new console); start-process-hidden is the same with +# -WindowStyle Hidden. 5 October 2026: written for PC 1 (ae432dc7, Windows 11 Pro 26200), where the project lead saw +# "Windows Command Processor" windows whenever a remote job ran. +# packaging/ota/publish-jobs.sh add --kind run --target ae432dc7 --script tools/windows/console-watch.ps1 \ +# --timeout-minutes 5 --title "PC 1: console window watcher" --deploy +$ErrorActionPreference = 'Continue' +$src = @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; +public static class IgWin { + public delegate bool EnumProc(IntPtr h, IntPtr l); + [DllImport("user32.dll")] public static extern bool EnumWindows(EnumProc p, IntPtr l); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr h); + [DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetWindowText(IntPtr h, StringBuilder s, int n); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] public static extern int GetClassName(IntPtr h, StringBuilder s, int n); + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + public static List<string> Visible() { + var list = new List<string>(); + EnumWindows((h, l) => { + if (!IsWindowVisible(h)) return true; + uint pid; GetWindowThreadProcessId(h, out pid); + var t = new StringBuilder(512); GetWindowText(h, t, 512); + var c = new StringBuilder(256); GetClassName(h, c, 256); + list.Add(((long)h).ToString() + "|" + pid + "|" + c + "|" + t); + return true; + }, IntPtr.Zero); + return list; + } +} +'@ +try { Add-Type -TypeDefinition $src -ErrorAction Stop } catch { if (-not ([System.Management.Automation.PSTypeName]'IgWin').Type) { Write-Output ("RESULT error: Add-Type failed: " + $_); exit 2 } } + +function Say([string] $m) { Write-Output $m } +function ProcName([int] $procId) { try { (Get-Process -Id $procId -ErrorAction Stop).ProcessName } catch { 'gone' } } + +# ---- the default terminal and the hosts present before anything starts ------------------------------------------------ +$CONHOST_ID = '{B23D10C0-E52E-411E-9D5B-C09FDF709C7D}' +$DECIDE_ID = '{00000000-0000-0000-0000-000000000000}' +$k = Get-ItemProperty -Path 'HKCU:\Console\%%Startup' -ErrorAction SilentlyContinue +$dc = if ($k) { [string]$k.DelegationConsole } else { '(absent)' } +$dt = if ($k) { [string]$k.DelegationTerminal } else { '(absent)' } +$meaning = if ($dc -eq $CONHOST_ID) { 'Windows Console Host (conhost)' } elseif ($dc -eq $DECIDE_ID -or $dc -eq '(absent)') { 'Let Windows decide (Windows Terminal on Windows 11 22H2 and later when it is installed)' } else { 'a terminal package, Windows Terminal or its preview' } +$wtPkg = (Get-AppxPackage -Name 'Microsoft.WindowsTerminal*' -ErrorAction SilentlyContinue | ForEach-Object { $_.Name + ' ' + $_.Version }) -join ', ' +Say ("RESULT terminal: DelegationConsole=" + $dc + " DelegationTerminal=" + $dt + " -> " + $meaning + "; Windows Terminal package: " + $(if ($wtPkg) { $wtPkg } else { 'none' })) +$os = Get-CimInstance Win32_OperatingSystem +Say ("RESULT os: " + $os.Caption + " build " + $os.BuildNumber + " user " + $env:USERNAME + " session " + [System.Diagnostics.Process]::GetCurrentProcess().SessionId) +$counts = @{} +foreach ($n in 'conhost', 'OpenConsole', 'WindowsTerminal', 'cmd', 'powershell', 'wsl', 'wslhost') { $counts[$n] = @(Get-Process -Name $n -ErrorAction SilentlyContinue).Count } +Say ("RESULT hosts-before: conhost " + $counts['conhost'] + " OpenConsole " + $counts['OpenConsole'] + " WindowsTerminal " + $counts['WindowsTerminal'] + " cmd " + $counts['cmd'] + " powershell " + $counts['powershell'] + " wsl " + $counts['wsl'] + " wslhost " + $counts['wslhost']) + +# the job's own console and the chain above it +$me = [System.Diagnostics.Process]::GetCurrentProcess() +$meCim = Get-CimInstance Win32_Process -Filter "ProcessId=$($me.Id)" +$parent = if ($meCim) { ProcName $meCim.ParentProcessId } else { '?' } +$hwnd = [IgWin]::GetConsoleWindow() +$selfVis = if ($hwnd -ne [IntPtr]::Zero) { [IgWin]::IsWindowVisible($hwnd) } else { 'no window' } +$ownHost = Get-CimInstance Win32_Process -Filter "Name='conhost.exe' OR Name='OpenConsole.exe'" | Where-Object { $_.ParentProcessId -eq $me.Id -or $_.ParentProcessId -eq $meCim.ParentProcessId } +$ownLine = if ($ownHost) { ($ownHost | ForEach-Object { $_.Name + ' pid ' + $_.ProcessId + ' parent ' + (ProcName $_.ParentProcessId) + ' cmd ' + $_.CommandLine }) -join ' ; ' } else { 'none with this script or its parent as parent' } +Say ("RESULT self: powershell pid " + $me.Id + " parent " + $parent + " (pid " + $meCim.ParentProcessId + "); console hwnd " + $hwnd + " visible " + $selfVis + "; host " + $ownLine) +foreach ($w in [IgWin]::Visible()) { + $f = $w.Split('|', 4) + if ($f[2] -eq 'ConsoleWindowClass' -or $f[2] -eq 'CASCADIA_HOSTING_WINDOW_CLASS') { Say ("RESULT window-before: " + (ProcName ([int]$f[1])) + " [" + $f[2] + "] " + $f[3]) } +} + +# ---- the sampler thread: every new visible window and every new console host, with the time it was first seen ------- +$sync = [hashtable]::Synchronized(@{ win = [hashtable]::Synchronized(@{}); hosts = [hashtable]::Synchronized(@{}); stop = $false; ticks = 0; err = '' }) +$rs = [runspacefactory]::CreateRunspace() +$rs.Open() +$rs.SessionStateProxy.SetVariable('sync', $sync) +$rs.SessionStateProxy.SetVariable('src', $src) +$sampler = [powershell]::Create() +$sampler.Runspace = $rs +[void]$sampler.AddScript({ + try { + if (-not ([System.Management.Automation.PSTypeName]'IgWin').Type) { Add-Type -TypeDefinition $src } + $first = $true + while (-not $sync.stop) { + $now = Get-Date + foreach ($w in [IgWin]::Visible()) { + $f = $w.Split('|', 4) + if (-not $sync.win.ContainsKey($f[0])) { + $pn = try { (Get-Process -Id ([int]$f[1]) -ErrorAction Stop).ProcessName } catch { 'gone' } + $sync.win[$f[0]] = @{ t = $now; procId = [int]$f[1]; proc = $pn; cls = $f[2]; title = $f[3]; base = $first } + } + } + foreach ($p in @(Get-Process -Name conhost, OpenConsole, WindowsTerminal -ErrorAction SilentlyContinue)) { + if (-not $sync.hosts.ContainsKey($p.Id)) { + $ci = Get-CimInstance Win32_Process -Filter "ProcessId=$($p.Id)" -ErrorAction SilentlyContinue + $ppid = if ($ci) { $ci.ParentProcessId } else { 0 } + $ppn = try { (Get-Process -Id $ppid -ErrorAction Stop).ProcessName } catch { 'gone' } + $sync.hosts[$p.Id] = @{ t = $now; name = $p.ProcessName; cmd = $(if ($ci) { [string]$ci.CommandLine } else { '?' }); ppid = $ppid; pproc = $ppn; base = $first } + } + } + $first = $false + $sync.ticks++ + Start-Sleep -Milliseconds 30 + } + } catch { $sync.err = [string]$_ } +}) +$handle = $sampler.BeginInvoke() +$t = 0 +while ($sync.ticks -lt 2 -and $t -lt 100) { Start-Sleep -Milliseconds 50; $t++ } +if ($sync.ticks -lt 2) { Say ("RESULT error: the sampler did not start: " + $sync.err); exit 2 } +Say ("sampler running: " + $sync.win.Count + " visible windows and " + $sync.hosts.Count + " console hosts at the start") + +# ---- probes: each one as a job script or the app would start it ---------------------------------------------------- +$report = New-Object System.Collections.ArrayList +function Probe([string] $name, [scriptblock] $body) { + Start-Sleep -Milliseconds 400 + $t0 = Get-Date + $global:LASTEXITCODE = 0 + $err = '' + try { & $body 2>&1 | Out-Null } catch { $err = [string]$_ } + $rc = $LASTEXITCODE + Start-Sleep -Milliseconds 600 + $t1 = Get-Date + $ms = [int]($t1 - $t0).TotalMilliseconds - 600 + $wins = @($sync.win.GetEnumerator() | Where-Object { -not $_.Value.base -and $_.Value.t -ge $t0 -and $_.Value.t -le $t1 -and -not $_.Value.reported }) + $hosts = @($sync.hosts.GetEnumerator() | Where-Object { -not $_.Value.base -and $_.Value.t -ge $t0 -and $_.Value.t -le $t1 -and -not $_.Value.reported }) + Say ("RESULT probe " + $name + ": exit " + $rc + " in " + $ms + " ms, " + $wins.Count + " window(s), " + $hosts.Count + " host(s)" + $(if ($err) { "; error " + $err } else { '' })) + foreach ($w in $wins) { $w.Value.reported = $true; Say ("RESULT window: " + $w.Value.proc + " [" + $w.Value.cls + "] " + $w.Value.title + " (probe " + $name + ")") } + foreach ($h in $hosts) { $h.Value.reported = $true; Say ("RESULT host: " + $h.Value.name + " pid " + $h.Key + " parent " + $h.Value.pproc + " cmd " + $h.Value.cmd + " (probe " + $name + ")") } +} +$distro = 'Ubuntu-24.04' +$haveDistro = $false +try { $haveDistro = ((& wsl.exe -l -q 2>$null) -replace "`0", '' | Where-Object { $_.Trim() -eq $distro }).Count -gt 0 } catch {} +Say ("wsl distro " + $distro + ": " + $(if ($haveDistro) { 'present' } else { 'absent, the distro probes are skipped' })) + +# a. the plain children a job script starts (CreateProcess, the console inherited) +Probe 'powershell-inherit' { & powershell.exe -NoProfile -ExecutionPolicy Bypass -Command 'Start-Sleep -Milliseconds 1200' } +Probe 'cmd-c-inherit' { & cmd.exe /c 'ping -n 3 127.0.0.1 >nul' } +Probe 'query-session' { & query.exe session } +Probe 'curl-version' { & curl.exe --version } +Probe 'nvidia-smi-L' { & nvidia-smi.exe -L } +Probe 'powershell-windowstyle-hidden-inherit' { & powershell.exe -NoProfile -WindowStyle Hidden -Command 'Start-Sleep -Milliseconds 1200' } +Probe 'wsl-status' { & wsl.exe --status } +if ($haveDistro) { + Probe 'wsl-distro-sleep' { & wsl.exe -d Ubuntu-24.04 -u root -- sleep 1 } + Probe 'wsl-interop-cmd' { & wsl.exe -d Ubuntu-24.04 -u root -- cmd.exe /c 'ping -n 3 127.0.0.1' } + Probe 'wsl-interop-powershell' { & wsl.exe -d Ubuntu-24.04 -u root -- powershell.exe -NoProfile -Command 'Start-Sleep -Milliseconds 1200' } +} +# b. the trust test: a new console (ShellExecute) must show; the same hidden +Probe 'start-process-new-console' { Start-Process -FilePath cmd.exe -ArgumentList '/c ping -n 3 127.0.0.1' -Wait } +Probe 'start-process-hidden' { Start-Process -FilePath cmd.exe -ArgumentList '/c ping -n 3 127.0.0.1' -WindowStyle Hidden -Wait } +# c. the elevated job's shape without the elevation: powershell in a new hidden console through ShellExecute +Probe 'start-process-powershell-hidden' { Start-Process -FilePath powershell.exe -ArgumentList '-NoProfile -ExecutionPolicy Bypass -Command Start-Sleep -Milliseconds 1200' -WindowStyle Hidden -Wait } +# d. candidate fixes: a headless conhost of our own around the child; the children of a headless session +Probe 'conhost-headless-cmd' { & conhost.exe --headless cmd.exe /c 'ping -n 3 127.0.0.1 >nul' } +if ($haveDistro) { + Probe 'conhost-headless-wsl-interop' { & conhost.exe --headless wsl.exe -d Ubuntu-24.04 -u root -- cmd.exe /c 'ping -n 3 127.0.0.1' } +} +Probe 'conhost-headless-start-process-hidden' { & conhost.exe --headless powershell.exe -NoProfile -Command "Start-Process -FilePath cmd.exe -ArgumentList '/c ping -n 3 127.0.0.1' -WindowStyle Hidden -Wait" } + +# ---- the end: anything the probes did not claim --------------------------------------------------------------------- +Start-Sleep -Milliseconds 800 +$sync.stop = $true +try { [void]$sampler.EndInvoke($handle) } catch {} +$sampler.Dispose(); $rs.Close() +$stray = @($sync.win.GetEnumerator() | Where-Object { -not $_.Value.base -and -not $_.Value.reported }) +foreach ($w in $stray) { Say ("RESULT window: " + $w.Value.proc + " [" + $w.Value.cls + "] " + $w.Value.title + " (between probes)") } +$strayH = @($sync.hosts.GetEnumerator() | Where-Object { -not $_.Value.base -and -not $_.Value.reported }) +foreach ($h in $strayH) { Say ("RESULT host: " + $h.Value.name + " pid " + $h.Key + " parent " + $h.Value.pproc + " cmd " + $h.Value.cmd + " (between probes)") } +$counts = @{} +foreach ($n in 'conhost', 'OpenConsole', 'WindowsTerminal') { $counts[$n] = @(Get-Process -Name $n -ErrorAction SilentlyContinue).Count } +Say ("RESULT hosts-after: conhost " + $counts['conhost'] + " OpenConsole " + $counts['OpenConsole'] + " WindowsTerminal " + $counts['WindowsTerminal'] + "; sampler ticks " + $sync.ticks + $(if ($sync.err) { "; sampler error " + $sync.err } else { '' })) +exit 0