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