geenforce: kiosk watchdog must see a WINDOW, not just a process
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

Reported from a real display: Edge closed on the desktop but still listed in
Task Manager, and the watchdog never relaunched it.

That is this watchdog's own bug. It asked only whether a --kiosk process
existed. After an Edge update the window can be gone while the process lingers,
so the check said "kiosk is up" and returned - every cycle, forever. Matching on
the command line was chosen to stop a stray renderer masking a dead kiosk; it
does not help when the orphan is the parent.

A kiosk now counts as up only when a --kiosk process still owns a visible
window (MainWindowHandle). Windowless ones are killed BEFORE relaunching:
leaving them would satisfy the next cycle's check again, and a second browser
would fight the first for the display.

Also refuses to run as SYSTEM. MainWindowHandle is session-scoped, so a SYSTEM
caller reads 0 for a perfectly healthy kiosk and would kill and relaunch it on
every cycle. The task uses an interactive Users principal so this cannot
normally happen; the guard makes a mis-registered task fail loudly instead of
thrashing a display in a hallway.

Verified on Windows this time by letting the SCHEDULED TASK do the work rather
than invoking the script by hand - which is what the first version was missing:
  - task fired unattended (rc=0) and launched the kiosk into session 1
  - the next cycle saw the healthy kiosk and did nothing, no relaunch loop
  - a windowless --kiosk process was killed and replaced
  - run as SYSTEM, it refused and the healthy kiosk survived
This commit is contained in:
cproudlock
2026-08-10 09:03:10 -04:00
parent 4d3985ce22
commit c0a7655aab
2 changed files with 70 additions and 8 deletions

View File

@@ -352,6 +352,17 @@ function Log {
-Value ('[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message)
}
# REFUSE to run as SYSTEM. MainWindowHandle is session-scoped: a SYSTEM caller
# reads 0 for a perfectly healthy kiosk in the user's session, so this script
# would judge it dead, kill it and relaunch - every single cycle. The scheduled
# task uses an interactive Users principal precisely so this cannot happen, and
# this guard makes a mis-registered task fail loudly instead of thrashing the
# display.
if ([Security.Principal.WindowsIdentity]::GetCurrent().IsSystem) {
Log 'refusing to run as SYSTEM: window state is not readable across sessions.'
return
}
# The kiosk shortcut is the source of truth for target + arguments.
$startup = Join-Path $env:ProgramData 'Microsoft\Windows\Start Menu\Programs\StartUp'
$lnkPath = Join-Path $startup 'ShopDB Kiosk.lnk'
@@ -375,19 +386,47 @@ if (-not $target -or -not (Test-Path -LiteralPath $target)) {
return
}
# Match on the COMMAND LINE, not just the image name. Edge spawns a crowd of
# msedge.exe children (renderers, GPU, crashpad) that outlive nothing useful;
# testing for "is msedge running" would leave a stray child masking a dead
# kiosk forever.
$running = $false
# A kiosk counts as UP only when a --kiosk process still owns a VISIBLE WINDOW.
#
# Two traps, and the obvious checks fall into one or the other. Testing "is
# msedge running" lets a stray renderer or crashpad child mask a dead kiosk.
# Testing the command line alone is what shipped first, and it is not enough
# either: after an Edge update the window can be gone while a --kiosk process
# lingers, so the watchdog saw "kiosk is up" and never relaunched - observed on
# a real display, Edge closed on the desktop but still listed in Task Manager.
#
# MainWindowHandle is the discriminator. A process with no window cannot be
# showing anything to the floor, whatever its command line says.
$kioskProcs = @()
try {
$running = [bool](Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" -EA SilentlyContinue |
Where-Object { $_.CommandLine -match '--kiosk' })
$kioskProcs = @(Get-CimInstance Win32_Process -Filter "Name = 'msedge.exe'" -EA SilentlyContinue |
Where-Object { $_.CommandLine -match '--kiosk' })
} catch {
Log "process query failed, assuming kiosk is up: $_"
return
}
if ($running) { return }
$visible = @()
foreach ($p in $kioskProcs) {
$proc = Get-Process -Id $p.ProcessId -EA SilentlyContinue
if ($proc -and $proc.MainWindowHandle -ne 0) { $visible += $p }
}
if ($visible.Count -gt 0) { return }
# Windowless --kiosk processes are orphans. They must be killed BEFORE
# relaunching: leaving them would keep the next cycle's check satisfied again,
# and a second browser would fight the first for the display.
if ($kioskProcs.Count -gt 0) {
Log ("found {0} windowless --kiosk process(es) - killing before relaunch" -f $kioskProcs.Count)
foreach ($p in $kioskProcs) {
try {
Stop-Process -Id $p.ProcessId -Force -EA Stop
Log (" killed pid {0}" -f $p.ProcessId)
} catch {
Log (" could not kill pid {0}: {1}" -f $p.ProcessId, $_)
}
}
}
# Debounce. Edge takes a few seconds to present a --kiosk process, and a display
# that fails to start would otherwise get a new browser every couple of minutes.

View File

@@ -180,3 +180,26 @@ def test_watchdog_does_nothing_without_a_kiosk_shortcut():
script = build_watchdog_script()
assert 'ShopDB Kiosk.lnk' in script
assert 'shopdb-kiosk-lastlaunch' in script # debounce stamp
def test_watchdog_requires_a_visible_window_not_just_a_process():
"""Edge can update and leave a --kiosk process behind with no window. The
first version tested the command line alone, so it saw 'kiosk is up' and
never relaunched - observed on a real display."""
script = build_watchdog_script()
assert 'MainWindowHandle' in script
def test_watchdog_kills_windowless_kiosk_before_relaunching():
"""Leaving the orphan would satisfy the next cycle's check again, and two
browsers would fight over the display."""
script = build_watchdog_script()
assert 'Stop-Process' in script
assert 'windowless' in script
def test_watchdog_refuses_to_run_as_system():
"""MainWindowHandle is session-scoped: SYSTEM reads 0 for a healthy kiosk,
so a mis-registered task would kill and relaunch the display every cycle."""
script = build_watchdog_script()
assert 'IsSystem' in script