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

1"""The Windows host's CPU load, read from a WSL2 harness through interop.""" 

2 

3from __future__ import annotations 

4 

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 

15 

16 

17POWERSHELL = "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" 

18 

19# WSL registers its interop handler here; newer builds name it WSLInterop-late. 

20_INTEROP_GLOB = "/proc/sys/fs/binfmt_misc/WSLInterop*" 

21 

22# Headroom for a cold powershell.exe start and the listener lookup before the window. 

23OVERHEAD_S = 20 

24 

25TICKS_PER_S = 10_000_000 # WMI times and Stopwatch ticks are 100 ns 

26 

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""" 

93 

94 

95class InteropError(RuntimeError): 

96 """Why the Windows host could not be read; the text becomes the note.""" 

97 

98 

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() 

105 

106 

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() 

112 

113 

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") 

119 

120 

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)) 

125 

126 

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 

140 

141 

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" 

145 

146 

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})_") 

150 

151 

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) 

158 

159 

160def measure(port, seconds): 

161 """The Windows host's load over `seconds`, net of the process on `port`. 

162 

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) 

188 

189 

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 

213 

214 

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 }