Skip to content

[Bug]: Windows port discovery re-spawns a doomed PowerShell/WMI probe every 3 s (the port-scanner half of #4182, not fixed by #2679) #5900

Description

@dominicwild

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Closest existing report is #4182, which mentioned this probe among several CPU problems. It was closed as fixed by #2679, but that PR replaced the process-diagnostics polling only; the changed-files list contains no PortScanner.ts, and the code below is unchanged on current main. Filing this as a focused issue for the remaining path.

Area

apps/server

Steps to reproduce

  1. On a Windows machine, time the exact probe command from apps/server/src/preview/PortScanner.ts (line 243):
    Measure-Command { powershell.exe -NoProfile -NonInteractive -Command 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }' }
    The bug triggers whenever this exceeds the scanner's 5 s timeout (WINDOWS_LISTENER_TIMEOUT_MS, line 55). On my machine it takes 48.2 s with 89 listening sockets.
  2. If it finishes under 5 s on your machine, open artificial listeners in a separate PowerShell window and re-run the timing. The cost is per listener (roughly half a second each on my machine), so a few hundred is plenty:
    $listeners = 1..300 | ForEach-Object { $l = [Net.Sockets.TcpListener]::new([Net.IPAddress]::Loopback, 0); $l.Start(); $l }
    Verified here: adding these 300 listeners took the probe from 48.2 s (89 listeners) to 215.2 s (390 listeners), i.e. linear in listener count.
  3. Start T3 Code (Alpha) and leave it open with a session active.
  4. Watch process creation (Process Explorer), or count probe launches in the classic "Windows PowerShell" event log, where event 400 records the full command line:
    (Get-WinEvent -FilterHashtable @{LogName='Windows PowerShell'; Id=400; StartTime=(Get-Date).AddHours(-1)} |
      Where-Object { $_.Message -match 'Get-NetTCPConnection' }).Count

Expected behavior

  • The probe completes well inside its timeout (it can: see the breakdown below, the same data is obtainable in under 1 s on this machine).
  • If a probe does time out repeatedly, the scanner backs off or stops paying for it, rather than re-running a probe that can never succeed at full cost forever. The cheap probeCommonPorts() fallback already exists.

Actual behavior

PortScanner.ts polls every 3 s (POLL_INTERVAL, line 53). Each tick spawns the PowerShell probe with a 5 s timeout. On this machine the probe needs ~48 s, so it is killed at 5 s, the timeout error is caught and only logged at debug level (recoverProcessProbeFailure), the cheap fallback runs instead, and 3 s later the next doomed probe spawns. Nothing surfaces to the user; it just repeats for as long as the app is open: 264 probe launches in the last hour, one every ~11 s (log query above; polling pauses only while no client is using port discovery).

Get-NetTCPConnection reads its data through WMI (the Windows service other tools also use to read process and system information), so every probe spins up WMI's worker process, WmiPrvSE.exe, and killing probes mid-query leaves a stream of errors in the WMI event log. Because each killed probe's WMI query keeps running server-side for tens of seconds while new probes launch every ~11 s, several queries are always in flight, and the cost shows up as a constant plateau rather than spikes: sampled at 1 s intervals over 40 s while the app was open, WmiPrvSE instances held a steady 4–6.5% of this 32-thread machine, roughly one and a half to two full cores, continuously. The symptom for the user is the one described in #4182: constant background CPU, fan noise, and system-wide stutter, because anything else that reads process information through WMI has to queue behind these queries.

Why the probe is slow: the per-connection Get-Process -Id is the cost, not the connection enumeration. Measured on the same machine (88 listeners, ~670 processes):

Step Time
Get-NetTCPConnection -State Listen alone 0.8 s
One Get-Process call building a PID→name map 0.1 s
Formatting all 88 output lines from that map 0.02 s
A single Get-Process -Id call 0.38 s

The shipped command makes that last call once per listener, sequentially: 88 × ~0.4 s ≈ 33 s, plus PowerShell start-up and enumeration, matching the observed 48 s. Cost scales with listeners × total process count, so busy developer machines are hit hardest.

Impact

Major degradation or frequent failure

Version or commit

0.0.32 (stable). Code unchanged on main: apps/server/src/preview/PortScanner.ts lines 53–55 and 240–263.

Environment

Windows 10 Pro 19045, T3 Code (Alpha) desktop 0.0.32, Ryzen 9 7950X3D, ~670 running processes, ~90 listening TCP sockets (several dev servers and agent tooling).

Logs or stack traces

# Probe timing (exact shipped command):
LISTENERS=89 ELAPSED_MS=48179

# "Windows PowerShell" log, event 400, last hour:
Get-NetTCPConnection probe starts: 264
Recent spawn gaps (s): 11.1, 11, 11, 11.2, 11.1, 11, 11.1, 11, 11.2, 11.1, 11.1, 11.2, 11, 11.1, 11, 11.2, 11.2, 11, 11.1

# WMI-Activity/Operational 5858, short-lived (already exited) powershell clients killed mid-query:
Operation = Start IWbemServices::ExecQuery - root\cimv2 : SELECT * FROM Win32_Process WHERE ProcessId=...;
ResultCode = 0x800706BA; PossibleCause = Could not send status to client

Workaround

None user-facing. Quitting T3 Code stops the loop. Patching bin.mjs is possible but reverts on every update.

Suggested fix

  1. Make the probe cheap: build the PID→name map once instead of calling Get-Process -Id per connection. Same output format, measured under 1 s on the machine where the current command takes 48 s:
    $m = @{}; Get-Process | ForEach-Object { $m[$_.Id] = $_.ProcessName }; Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$($m[[int]$_.OwningProcess])" }
  2. Even with a faster probe, add a backstop: after several consecutive timeouts, poll less often or stop attempting the expensive probe and stay on probeCommonPorts(). A probe that has timed out every 3 s for an hour will not succeed on the next tick.
  3. Longer term: read the listener list directly from the Windows API (GetExtendedTcpTable), for example via the t3-resource-monitor binary introduced in Reduce idle work and disk churn with native resource diagnostics #2679. That avoids spawning PowerShell and touching WMI at all, which is exactly what Reduce idle work and disk churn with native resource diagnostics #2679 already did for process diagnostics.

How to verify a fix

On a machine set up as in the repro (or with the 300 artificial listeners): run the app for 10 minutes, then run the event-log query from step 4. Fixed means the probes now complete within the timeout: no more launch every ~11 s (at most one per 3 s tick while port discovery is in use), no sustained WmiPrvSE.exe CPU, and no new "could not send status to client" errors in the WMI-Activity event log.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions