Coverage for orchestrant/benchmark/winhost.py: 93%
94 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 00:48 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-18 00:48 +0000
1"""The Windows host's CPU load, read from a WSL2 harness through interop."""
3from __future__ import annotations
5import base64
6import glob
7import html
8import json
9import os
10import platform
11import re
12import shutil
13import subprocess # nosec B404 -- runs powershell.exe with a script built here
14import sys
17POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe"
19# WSL registers its interop handler here; newer builds name it WSLInterop-late.
20_INTEROP_GLOB = "/proc/sys/fs/binfmt_misc/WSLInterop*"
22# Headroom for a cold powershell.exe start and the listener lookup before the window.
23OVERHEAD_S = 20
25TICKS_PER_S = 10_000_000 # WMI times and Stopwatch ticks are 100 ns
27# See docs/source/benchmark.rst § Windows host load from WSL2
28_SCRIPT = r"""
29$ErrorActionPreference = 'Stop'
30$ProgressPreference = 'SilentlyContinue'
31function Get-Idle {
32 $idle = [uint64]0; $ts = [uint64]0; $n = 0
33 foreach ($c in Get-CimInstance -ClassName Win32_PerfRawData_PerfOS_Processor) {
34 if ($c.Name -eq '_Total') { continue }
35 $idle += [uint64]$c.PercentProcessorTime
36 $ts = [uint64]$c.Timestamp_Sys100NS
37 $n += 1
38 }
39 @{ idle = $idle; ts = $ts; n = $n }
40}
41function Get-LaneTicks($rootId, $started) {
42 $kids = @{}; $root = $null
43 $all = Get-CimInstance -ClassName Win32_Process -Property ProcessId,ParentProcessId,CreationDate,KernelModeTime,UserModeTime
44 foreach ($p in $all) {
45 if ($p.ProcessId -eq $rootId) { $root = $p }
46 $key = [int]$p.ParentProcessId
47 if (-not $kids.ContainsKey($key)) { $kids[$key] = New-Object System.Collections.ArrayList }
48 [void]$kids[$key].Add($p)
49 }
50 if ($null -eq $root) { return $null }
51 if ($null -ne $started -and $root.CreationDate -ne $started) { return $null }
52 $ticks = [uint64]0; $seen = @{}; $todo = New-Object System.Collections.Stack
53 $todo.Push($root)
54 while ($todo.Count -gt 0) {
55 $p = $todo.Pop()
56 if ($seen.ContainsKey([int]$p.ProcessId)) { continue }
57 $seen[[int]$p.ProcessId] = $true
58 $ticks += [uint64]$p.KernelModeTime + [uint64]$p.UserModeTime
59 foreach ($c in $kids[[int]$p.ProcessId]) {
60 if ($c.CreationDate -ge $p.CreationDate) { $todo.Push($c) }
61 }
62 }
63 @{ ticks = $ticks; started = $root.CreationDate }
64}
65$port = __PORT__
66$rootId = $null
67$listen = @(Get-NetTCPConnection -LocalPort $port -State Listen -ErrorAction SilentlyContinue)
68# PIDs 0 and 4 are Idle and System (http.sys): their tree is the machine.
69if ($listen.Count -gt 0 -and $listen[0].OwningProcess -gt 4) { $rootId = [int]$listen[0].OwningProcess }
70$clock = [Diagnostics.Stopwatch]::StartNew()
71$lane0 = $null; $at0 = $null; $lane1 = $null; $at1 = $null
72if ($null -ne $rootId) {
73 $t = $clock.Elapsed.Ticks
74 $lane0 = Get-LaneTicks $rootId $null
75 $at0 = [long](($t + $clock.Elapsed.Ticks) / 2)
76}
77$cpu0 = Get-Idle
78Start-Sleep -Milliseconds __MS__
79$cpu1 = Get-Idle
80if ($null -ne $lane0) {
81 $t = $clock.Elapsed.Ticks
82 $lane1 = Get-LaneTicks $rootId $lane0.started
83 $at1 = [long](($t + $clock.Elapsed.Ticks) / 2)
84}
85$l0 = $null; if ($null -ne $lane0) { $l0 = $lane0.ticks }
86$l1 = $null; if ($null -ne $lane1) { $l1 = $lane1.ticks }
87[ordered]@{
88 port = $port; pid = $rootId; cpus = @($cpu0.n, $cpu1.n)
89 idle = @($cpu0.idle, $cpu1.idle); ts = @($cpu0.ts, $cpu1.ts)
90 lane = @($l0, $l1); lane_at = @($at0, $at1)
91} | ConvertTo-Json -Compress
92"""
95class InteropError(RuntimeError):
96 """Why the Windows host could not be read; the text becomes the note."""
99def _kernel_release():
100 try:
101 with open("/proc/sys/kernel/osrelease", encoding="ascii") as f:
102 return f.read()
103 except OSError:
104 return platform.release()
107def in_wsl():
108 """True inside WSL (1 or 2): the interop handler, or Microsoft's kernel."""
109 if not sys.platform.startswith("linux"):
110 return False
111 return bool(glob.glob(_INTEROP_GLOB)) or "microsoft" in _kernel_release().lower()
114def find_powershell():
115 """Windows PowerShell as WSL sees it, or None (interop off, C: elsewhere)."""
116 if os.path.isfile(POWERSHELL):
117 return POWERSHELL
118 return shutil.which("powershell.exe")
121def script(port, seconds):
122 """The PowerShell that samples the host around a `seconds` sleep."""
123 ms = max(0, round(float(seconds) * 1000))
124 return _SCRIPT.replace("__PORT__", str(int(port))).replace("__MS__", str(ms))
127def _run(argv, timeout):
128 """(returncode, stdout, stderr) of one powershell.exe call; the test seam."""
129 p = subprocess.run( # nosec B603 -- powershell.exe, a script built above
130 argv,
131 capture_output=True,
132 text=True,
133 # A German Windows console codepage is not UTF-8; an error text must not raise.
134 errors="replace",
135 stdin=subprocess.DEVNULL,
136 timeout=timeout,
137 check=False,
138 )
139 return p.returncode, p.stdout, p.stderr
142def _first_line(text):
143 lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
144 return lines[0][:160] if lines else "no output"
147# -EncodedCommand with stderr redirected writes the error stream as CLIXML.
148_CLIXML_ERROR = re.compile(r'<S S="Error">(.*?)</S>', re.DOTALL)
149_CLIXML_CHAR = re.compile(r"_x([0-9A-Fa-f]{4})_")
152def error_text(stderr):
153 """The text of `stderr`: its error records when PowerShell sent CLIXML."""
154 if not (stderr or "").lstrip().startswith("#< CLIXML"):
155 return stderr
156 records = html.unescape("".join(_CLIXML_ERROR.findall(stderr)))
157 return _CLIXML_CHAR.sub(lambda m: chr(int(m.group(1), 16)), records)
160def measure(port, seconds):
161 """The Windows host's load over `seconds`, net of the process on `port`.
163 Returns {"load", "wall", "cpus", "pid", "port"}, `load` keyed like
164 hostload.Window.stop(); raises InteropError for every way the reading fails.
165 """
166 exe = find_powershell()
167 if exe is None:
168 raise InteropError(
169 "powershell.exe not found (interop off, or C: not at /mnt/c)"
170 )
171 encoded = base64.b64encode(script(port, seconds).encode("utf-16-le")).decode()
172 argv = [exe, "-NoProfile", "-NonInteractive", "-EncodedCommand", encoded]
173 timeout = seconds + OVERHEAD_S
174 try:
175 code, out, err = _run(argv, timeout)
176 except subprocess.TimeoutExpired as e:
177 raise InteropError(
178 f"powershell.exe gave no reading within {timeout:.0f} s"
179 ) from e
180 except OSError as e: # interop disabled: exec format error
181 raise InteropError(
182 f"powershell.exe did not start: {type(e).__name__}: {e}"
183 ) from e
184 if code != 0:
185 why = _first_line(error_text(err) or out)
186 raise InteropError(f"powershell.exe exited {code}: {why}")
187 return parse(out, port)
190def _fields(stdout):
191 """The raw samples from the script's JSON line, as integers (None kept)."""
192 line = next(
193 (ln for ln in reversed((stdout or "").splitlines()) if ln.startswith("{")),
194 None,
195 )
196 if line is None:
197 raise InteropError(
198 f"no reading in powershell.exe output: {_first_line(stdout)}"
199 )
200 try:
201 doc = json.loads(line)
202 cpus = [int(v) for v in doc["cpus"]]
203 idle = [int(v) for v in doc["idle"]]
204 ts = [int(v) for v in doc["ts"]]
205 lane = [None if v is None else int(v) for v in doc["lane"]]
206 at = [None if v is None else int(v) for v in doc["lane_at"]]
207 pid = None if doc["pid"] is None else int(doc["pid"])
208 except (ValueError, KeyError, TypeError) as e:
209 raise InteropError(f"unreadable reading: {type(e).__name__}: {e}"[:160]) from e
210 if not (len(cpus) == len(idle) == len(ts) == len(lane) == len(at) == 2):
211 raise InteropError("unreadable reading: not two samples")
212 return cpus, idle, ts, lane, at, pid
215def parse(stdout, port):
216 """measure()'s result from the script's output; InteropError when unusable."""
217 cpus, idle, ts, lane, at, pid = _fields(stdout)
218 span = ts[1] - ts[0]
219 if cpus[0] != cpus[1] or cpus[1] <= 0 or span <= 0 or idle[1] < idle[0]:
220 raise InteropError(f"inconsistent counters: cpus {cpus}, ts {ts}, idle {idle}")
221 idle_share = min(1.0, (idle[1] - idle[0]) / (span * cpus[1]))
222 load = {"cpu_busy_percent_window": round(100.0 * (1.0 - idle_share), 1)}
223 known = None not in lane and None not in at
224 if pid is not None and known and at[1] > at[0]:
225 used = max(0.0, (lane[1] - lane[0]) / TICKS_PER_S)
226 load["lane_cpu_s"] = round(used, 2)
227 # Over the lane's own span: its queries bracket the counters' shorter one.
228 load["lane_cores"] = round(used / ((at[1] - at[0]) / TICKS_PER_S), 2)
229 return {
230 "load": load,
231 "wall": span / TICKS_PER_S,
232 "cpus": cpus[1],
233 "pid": pid,
234 "port": port,
235 }