File size: 5,704 Bytes
8d6541a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 | """Regression: the Windows Desktop update hand-off must run through python.exe.
`scripts/desktop-update/windows.ps1` drives `hermes update` for the in-app
Desktop updater. It used to invoke the update through the venv's
`venv\\Scripts\\hermes.exe` console-script launcher. On Windows that launcher is
a real process that keeps `hermes.exe` mapped as its running image and spawns
`python.exe` as a child. The update ends in `uv pip install -e .`, which rewrites
the console-script shims -- including the `hermes.exe` the launcher still has
mapped -- and Windows refuses to replace a file mapped as a running image
("os error 32"). The rename fallback then defers to next reboot via
`MOVEFILE_DELAY_UNTIL_REBOOT`, which needs elevation a Desktop-driven update
does not have, so `uv pip install -e .` exits non-zero, the ZIP fallback repeats
the same sequence, the desktop build stage is never reached, and the pre-build
clean has already removed `apps/desktop/release` -- leaving an install whose
Start Menu shortcut points at a `Hermes.exe` that no longer exists.
Driving the update as `python.exe -m hermes_cli.main update` puts the inherited
image handle on `python.exe`, which uv never has to replace, so the shim is an
ordinary unlocked file when uv rewrites it.
This test is source-level because Linux CI cannot execute the PowerShell
hand-off. The invariant it guards is that every `Invoke-HermesStep` call site
(the update, its retry, and the desktop rebuild) drives `$pythonExe`, never the
`$hermesExe` shim. `hermes.exe` may still be *named* in the file for the
step-2 unlock preflight -- that is a lock probe, not an invocation -- so we
assert against the invocation sites specifically.
"""
from __future__ import annotations
import re
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
WINDOWS_PS1 = REPO_ROOT / "scripts" / "desktop-update" / "windows.ps1"
def _read() -> str:
# windows.ps1 is eol=crlf in .gitattributes, so checkouts materialize
# CRLF on disk (CI included). Normalize so the SelfTest-block strip's
# `\n}\n` anchors match regardless of the working-copy line endings.
return WINDOWS_PS1.read_text(encoding="utf-8").replace("\r\n", "\n")
def _handoff_source() -> str:
"""The script with its ``-SelfTest*`` fixture blocks removed.
Those blocks exercise the hand-off machinery deliberately -- the pipe-drain
fixture runs a synthetic PowerShell step through ``Invoke-HermesStep`` to
prove the drain cannot deadlock (#90455) -- so they are not update steps
and the "must drive python.exe" rule does not apply to them. Each exits
before any venv/desktop machinery runs.
Scoped here rather than allow-listing a target, so the rule stays absolute
for every real step. The non-greedy match ends at the first closing brace
at the opening statement's indentation; inner braces are deeper.
"""
return re.sub(
r"\n(?P<indent> *)if \(\$SelfTest\w+\) \{.*?\n(?P=indent)\}\n",
"\n",
_read(),
flags=re.S,
)
def test_invoke_hermes_step_calls_drive_python_not_the_shim() -> None:
source = _handoff_source()
invocations = re.findall(r"Invoke-HermesStep\s+(\$\w+)", source)
assert invocations, (
"Expected at least one Invoke-HermesStep call in "
"scripts/desktop-update/windows.ps1; the update hand-off structure "
"changed -- update this guard."
)
offenders = [exe for exe in invocations if exe != "$pythonExe"]
assert not offenders, (
"Every Invoke-HermesStep call in scripts/desktop-update/windows.ps1 "
"must drive $pythonExe, not the hermes.exe shim. Driving the update "
"through the shim keeps hermes.exe mapped as a running image, so uv's "
"final shim rewrite fails with os error 32 and the Desktop update can "
"never complete. Offending target(s): "
f"{sorted(set(offenders))}."
)
def test_update_invocation_uses_module_entrypoint() -> None:
source = _read()
assert '@("-m", "hermes_cli.main", "update"' in source, (
"The update step must invoke `python.exe -m hermes_cli.main update ...` "
"so the inherited image handle lands on python.exe, which uv never has "
"to replace."
)
assert (
'@("-m", "hermes_cli.main", "desktop", "--force-build", "--build-only")'
in source
), (
"The desktop rebuild step must also go through "
"`python.exe -m hermes_cli.main desktop ...` for the same reason."
)
def test_update_no_longer_invokes_the_hermes_exe_shim() -> None:
source = _read()
assert "Invoke-HermesStep $hermesExe" not in source, (
"scripts/desktop-update/windows.ps1 still invokes the update through "
"the hermes.exe shim (`Invoke-HermesStep $hermesExe`). That is the "
"exact self-lock this fix removes -- route it through $pythonExe "
"instead."
)
def test_desktop_relaunch_waits_for_an_in_place_rebuild() -> None:
source = _read()
relaunch = re.search(
r"function Start-DesktopRelaunch \{(?P<body>.*?)\n\}\n\nfunction Invoke-HermesStep",
source,
re.DOTALL,
)
assert relaunch, "Expected Start-DesktopRelaunch in the Windows hand-off script."
body = relaunch.group("body")
assert "if (-not $RelaunchExe) { return $false }" in body
assert "$relaunchDeadline = (Get-Date).AddSeconds(120)" in body
assert "while (-not (Test-Path -LiteralPath $RelaunchExe))" in body
assert "if ((Get-Date) -ge $relaunchDeadline)" in body
assert "Start-Sleep -Milliseconds 500" in body
assert "[System.Windows.Forms.Application]::DoEvents()" in body
|