* fix: suppress console windows for git subprocess calls on Windows On Windows, every git.exe spawned by the WebUI (update checks, version detection, workspace git info, rollback inspection, worktree management) creates a visible console window. When Windows Terminal is the default terminal, each short-lived git process opens a new tab. Combined with the 60-second version-skew monitor polling /api/settings and the boot-time update check, this produces a flood of flashing terminal tabs. Root cause: subprocess.run() calls in api/updates.py, api/rollback.py, api/worktrees.py, and api/workspace.py did not pass creationflags=CREATE_NO_WINDOW on Windows. api/workspace_git.py already had the fix (#5692); these four files were missed. Backend fix: - Add _windows_hide_flags() helper to each affected module (mirrors the existing pattern in api/workspace_git.py) - Pass creationflags=_windows_hide_flags() to all git subprocess.run() calls (10 call sites across 4 files) Frontend fix (prevent timeout->retry cascades): - Increase update-check timeout from 30s/60s to 300s (5 min) - Increase /api/settings fetches from 30s to 60s - Increase /api/git-info fetch from 30s to 60s The backend git fetch to GitHub can take 15s per repo; with both WebUI and Agent repos checked sequentially plus other git calls, the total easily exceeds the old 30s default frontend timeout, triggering the api() wrapper auto-retry (3 attempts) and amplifying the git process flood. * [verified] fix: address Windows git subprocess review --------- Co-authored-by: ya1yi2fa3 <ya1yi2fa3@users.noreply.github.com> Co-authored-by: n <a@n>
182 lines
9.4 KiB
Python
182 lines
9.4 KiB
Python
"""Regression tests for the default-message-mode boot race (#5167 / #5170).
|
|
|
|
The Default message mode preference (queue/interrupt/steer) was ignored for any
|
|
send that happened before the async boot IIFE resolved `await api('/api/settings')`,
|
|
because `window._defaultMessageMode` had NO eager default — it was `undefined`
|
|
during the boot window, so the send path's `window._defaultMessageMode||'steer'`
|
|
silently fell back. Re-saving from the settings panel set the global
|
|
synchronously, which is why "re-save fixes it".
|
|
|
|
The fix gives `window._defaultMessageMode` a deterministic value at script top,
|
|
read synchronously from a localStorage mirror ('hermes-default-message-mode',
|
|
with a legacy 'hermes-busy-input-mode' fallback read) that the resolved settings
|
|
+ the save handler both keep in sync — mirroring how hermes-lang / hermes-theme
|
|
are bootstrapped from a synchronous source.
|
|
|
|
Reported by @b3nw. Issues: #5167 (boot race), #5170 (autosave + panel-load
|
|
mirror). #5145 renamed the setting busy_input_mode -> default_message_mode and
|
|
flipped the default to 'steer'; this file was updated for the rename while
|
|
preserving the persistence-mirror guarantees (the load-failure path must still
|
|
honor the saved preference, not clobber it with a hardcoded default).
|
|
"""
|
|
import re
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).parent.parent
|
|
BOOT_JS = (ROOT / "static" / "boot.js").read_text(encoding="utf-8")
|
|
PANELS_JS = (ROOT / "static" / "panels.js").read_text(encoding="utf-8")
|
|
MESSAGES_JS = (ROOT / "static" / "messages.js").read_text(encoding="utf-8")
|
|
UI_JS = (ROOT / "static" / "ui.js").read_text(encoding="utf-8")
|
|
|
|
|
|
class TestEagerDefault:
|
|
"""window._defaultMessageMode must be set eagerly, before the async settings fetch."""
|
|
|
|
def test_eager_default_assigned_at_module_scope(self):
|
|
"""An eager top-level assignment must exist so first sends honor the preference."""
|
|
assert "window._defaultMessageMode=_readPersistedDefaultMessageMode()" in BOOT_JS, (
|
|
"boot.js must eagerly initialise window._defaultMessageMode from the persisted "
|
|
"mirror at module scope so sends during the boot window don't default silently"
|
|
)
|
|
|
|
def test_eager_default_precedes_async_settings_fetch(self):
|
|
"""The eager assignment must appear BEFORE the async boot IIFE's /api/settings await.
|
|
|
|
This is the whole point of the fix: the value must be deterministic during
|
|
the window between page load and the settings fetch resolving.
|
|
"""
|
|
eager_idx = BOOT_JS.find("window._defaultMessageMode=_readPersistedDefaultMessageMode()")
|
|
assert eager_idx >= 0, "eager default assignment not found"
|
|
# The async IIFE awaits /api/settings; the success-path assignment lives inside it.
|
|
await_match = re.search(
|
|
r"const s=await api\('/api/settings'(?:,\{[^)]*\})?\)",
|
|
BOOT_JS,
|
|
)
|
|
assert await_match, "async settings fetch with optional options not found"
|
|
await_idx = await_match.start()
|
|
assert eager_idx < await_idx, (
|
|
"the eager window._defaultMessageMode default must be set BEFORE the async "
|
|
"/api/settings fetch — otherwise the boot-window race (#5167) persists"
|
|
)
|
|
|
|
def test_eager_default_precedes_send_definition(self):
|
|
"""The eager default in boot.js loads after messages.js (defer order), but the
|
|
assignment itself must sit at top level so it runs during script evaluation,
|
|
not inside a later-firing callback."""
|
|
eager_idx = BOOT_JS.find("window._defaultMessageMode=_readPersistedDefaultMessageMode()")
|
|
# Must be at the start of a line (top-level statement), not indented inside a fn.
|
|
line_start = BOOT_JS.rfind("\n", 0, eager_idx) + 1
|
|
assert BOOT_JS[line_start:eager_idx].strip() == "", (
|
|
"the eager default must be a top-level statement (not nested in a function "
|
|
"or callback) so it runs during script evaluation"
|
|
)
|
|
|
|
|
|
class TestSyncMirrorHelpers:
|
|
"""Helper functions backing the synchronous localStorage mirror."""
|
|
|
|
def test_persist_helper_defined_and_exposed(self):
|
|
assert "function _persistDefaultMessageMode(" in BOOT_JS
|
|
assert "window._persistDefaultMessageMode=_persistDefaultMessageMode" in BOOT_JS
|
|
|
|
def test_read_helper_defined_and_exposed(self):
|
|
assert "function _readPersistedDefaultMessageMode(" in BOOT_JS
|
|
assert "window._readPersistedDefaultMessageMode=_readPersistedDefaultMessageMode" in BOOT_JS
|
|
|
|
def test_mirror_uses_dedicated_localstorage_key(self):
|
|
assert "localStorage.setItem(_DEFAULT_MESSAGE_MODE_KEY" in BOOT_JS, (
|
|
"persist helper must write the mode to the dedicated localStorage key"
|
|
)
|
|
assert "localStorage.getItem(_DEFAULT_MESSAGE_MODE_KEY" in BOOT_JS, (
|
|
"read helper must read the mode from the same localStorage key"
|
|
)
|
|
assert "_DEFAULT_MESSAGE_MODE_KEY='hermes-default-message-mode'" in BOOT_JS, (
|
|
"the dedicated key must be 'hermes-default-message-mode'"
|
|
)
|
|
|
|
def test_mirror_reads_legacy_key_as_fallback(self):
|
|
"""A pre-rename user's persisted preference (under the old
|
|
'hermes-busy-input-mode' key) must survive the rename via a fallback read."""
|
|
assert "_LEGACY_DEFAULT_MESSAGE_MODE_KEY='hermes-busy-input-mode'" in BOOT_JS, (
|
|
"the legacy localStorage key must still be recognised for back-compat"
|
|
)
|
|
assert "localStorage.getItem(_LEGACY_DEFAULT_MESSAGE_MODE_KEY" in BOOT_JS, (
|
|
"the read helper must fall back to the legacy key so an existing "
|
|
"user's persisted preference survives the #5145 rename"
|
|
)
|
|
|
|
def test_normalize_validates_against_known_modes(self):
|
|
"""Unknown/garbage persisted values must normalize to the safe default, never crash."""
|
|
assert "function _normalizeDefaultMessageMode(" in BOOT_JS
|
|
assert "['queue','interrupt','steer']" in BOOT_JS
|
|
|
|
def test_localstorage_access_is_guarded(self):
|
|
"""localStorage can throw (privacy mode / disabled). Helpers must try/catch."""
|
|
idx = BOOT_JS.find("function _persistDefaultMessageMode(")
|
|
body = BOOT_JS[idx:idx + 400]
|
|
assert "try{" in body and "catch(_)" in body, (
|
|
"_persistDefaultMessageMode must guard localStorage access against exceptions"
|
|
)
|
|
idx = BOOT_JS.find("function _readPersistedDefaultMessageMode(")
|
|
body = BOOT_JS[idx:idx + 600]
|
|
assert "try{" in body and "catch(_)" in body, (
|
|
"_readPersistedDefaultMessageMode must guard localStorage access against exceptions"
|
|
)
|
|
|
|
|
|
class TestAssignmentSitesSyncMirror:
|
|
"""Every place that sets window._defaultMessageMode must keep the mirror in sync."""
|
|
|
|
def test_boot_success_path_persists(self):
|
|
assert "window._defaultMessageMode=_persistDefaultMessageMode(s.default_message_mode||s.busy_input_mode)" in BOOT_JS
|
|
|
|
def test_boot_catch_path_reads_persisted(self):
|
|
"""The critical #5167/#5170 guarantee: settings-load FAILURE must re-read the
|
|
persisted preference, NOT clobber it with a hardcoded default. A saved
|
|
'steer'/'interrupt'/'queue' must still apply when the server is unreachable."""
|
|
assert "window._defaultMessageMode=_readPersistedDefaultMessageMode()" in BOOT_JS
|
|
# Guard against regression: the catch path must NOT hardcode a literal mode.
|
|
assert "window._defaultMessageMode='steer'" not in BOOT_JS, (
|
|
"the settings-load-failure path must read the persisted preference, not "
|
|
"clobber it with a hardcoded 'steer' that ignores a saved 'interrupt'/'queue'"
|
|
)
|
|
|
|
def test_panels_save_persists(self):
|
|
assert "_persistDefaultMessageMode(body.default_message_mode||body.busy_input_mode)" in PANELS_JS, (
|
|
"the settings save handler must persist default_message_mode so the next "
|
|
"reload's eager default reads the saved value"
|
|
)
|
|
|
|
def test_panels_save_guards_helper_availability(self):
|
|
"""panels.js loads before boot.js (defer order); the save handler must
|
|
guard the helper with typeof so a regression in load order can't throw."""
|
|
idx = PANELS_JS.find("_persistDefaultMessageMode(body.default_message_mode||body.busy_input_mode)")
|
|
assert idx >= 0
|
|
window = PANELS_JS[max(0, idx - 160):idx]
|
|
assert "typeof _persistDefaultMessageMode==='function'" in window, (
|
|
"panels.js save handler must guard _persistDefaultMessageMode with a typeof check"
|
|
)
|
|
|
|
def test_panels_autosave_persists(self):
|
|
"""#5170: the preferences autosave path must also persist the mode."""
|
|
assert "_persistDefaultMessageMode(_dmm)" in PANELS_JS, (
|
|
"the autosave handler must persist default_message_mode so a reload after "
|
|
"an autosave honors the saved value (#5170)"
|
|
)
|
|
|
|
def test_panels_panel_load_persists(self):
|
|
"""#5170: opening the settings panel must also mirror the mode into localStorage."""
|
|
assert "_persistDefaultMessageMode(defaultMessageModeSel.value)" in PANELS_JS, (
|
|
"loadSettingsPanel must persist default_message_mode on panel load (#5170)"
|
|
)
|
|
|
|
|
|
class TestReadSitesUnchanged:
|
|
"""The defensive read sites must keep their ||'steer' fallback (belt + braces)."""
|
|
|
|
def test_messages_read_site_intact(self):
|
|
assert "const defaultMessageMode=window._defaultMessageMode||'steer';" in MESSAGES_JS
|
|
|
|
def test_ui_read_site_intact(self):
|
|
assert "window._defaultMessageMode||'steer'" in UI_JS
|