* 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>
This commit is contained in:
16 files changed
+218
-40
No files matched your search
@@ -5,6 +5,8 @@
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **Git operations no longer flash a console window on Windows.** Every git subprocess boundary (agent runtime, rollback, updates, workspace, worktrees) now routes through a shared `windows_hide_flags()` helper that passes `CREATE_NO_WINDOW` on Win32 and stays a no-op on POSIX, so short-lived git commands no longer pop a visible console window on Windows while keeping captured stdout/stderr intact. Thanks @ya1yi2fa3. (#6534)
|
||||||
|
|
||||||
- **A `/goal` launch no longer silently switches providers mid-session after a deliberate model pick.** When a session had a deliberate cross-provider model/provider choice, running `/goal` omitted the `explicit_model_pick` marker that `/api/chat/start` sends, so the model resolver treated the persisted pick as stale and "repaired" it back to the profile default — silently changing providers while the goal ran. Both the frontend `/goal` command and the backend `_handle_goal_command` (initial kickoff and kickoff-prompt fallback paths) now carry the explicit-pick marker and stamp the pick signature, matching `/api/chat/start`; ordinary default sessions are unaffected (they still omit the marker, so a default pick stays repairable). Thanks @webtecnica. (#6705)
|
- **A `/goal` launch no longer silently switches providers mid-session after a deliberate model pick.** When a session had a deliberate cross-provider model/provider choice, running `/goal` omitted the `explicit_model_pick` marker that `/api/chat/start` sends, so the model resolver treated the persisted pick as stale and "repaired" it back to the profile default — silently changing providers while the goal ran. Both the frontend `/goal` command and the backend `_handle_goal_command` (initial kickoff and kickoff-prompt fallback paths) now carry the explicit-pick marker and stamp the pick signature, matching `/api/chat/start`; ordinary default sessions are unaffected (they still omit the marker, so a default pick stays repairable). Thanks @webtecnica. (#6705)
|
||||||
|
|
||||||
- **A completed assistant turn no longer renders twice in the feed.** In `renderMessages()`, the live-turn preserve guard kept the in-flight assistant node across the DOM rebuild whenever it held any rendered content, so when a stream ended but its inflight bookkeeping was not yet cleared the just-completed turn was re-attached over the rebuilt settled transcript (a first copy without the model label, then a second with it). The stored data was always clean (state.db, sidecar, and `/api/session` each hold one row) — the duplicate existed only in the DOM. Preservation now requires a provable live owner (`S.activeStreamId` or explicit live-projection markers) instead of bare DOM content; the mid-stream flicker guard and blank-turn guard are preserved. Thanks @webtecnica. (#7109)
|
- **A completed assistant turn no longer renders twice in the feed.** In `renderMessages()`, the live-turn preserve guard kept the in-flight assistant node across the DOM rebuild whenever it held any rendered content, so when a stream ended but its inflight bookkeeping was not yet cleared the just-completed turn was re-attached over the rebuilt settled transcript (a first copy without the model label, then a second with it). The stored data was always clean (state.db, sidecar, and `/api/session` each hold one row) — the duplicate existed only in the DOM. Preservation now requires a provable live owner (`S.activeStreamId` or explicit live-projection markers) instead of bare DOM content; the mid-stream flicker guard and blank-turn guard are preserved. Thanks @webtecnica. (#7109)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import threading
|
|||||||
# Retain the discovered path as a diagnostic/test-visible compatibility value;
|
# Retain the discovered path as a diagnostic/test-visible compatibility value;
|
||||||
# runtime identity is deliberately captured from the loaded module below.
|
# runtime identity is deliberately captured from the loaded module below.
|
||||||
from api.config import _AGENT_DIR # noqa: F401
|
from api.config import _AGENT_DIR # noqa: F401
|
||||||
|
from api.subprocess_utils import windows_hide_flags
|
||||||
|
|
||||||
_RESTART_MESSAGE = (
|
_RESTART_MESSAGE = (
|
||||||
"Hermes Agent was updated while Hermes WebUI was running. "
|
"Hermes Agent was updated while Hermes WebUI was running. "
|
||||||
@@ -49,6 +50,7 @@ def _read_agent_revision(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=2,
|
timeout=2,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
if worktree_result.returncode != 0:
|
if worktree_result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
@@ -69,6 +71,7 @@ def _read_agent_revision(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=2,
|
timeout=2,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
if tracked_result.returncode != 0:
|
if tracked_result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
@@ -78,6 +81,7 @@ def _read_agent_revision(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
timeout=2,
|
timeout=2,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError):
|
except (OSError, subprocess.TimeoutExpired, RuntimeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ from datetime import datetime
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from api.subprocess_utils import windows_hide_flags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Checkpoint identifiers are SHA-style hex hashes from the agent's
|
# Checkpoint identifiers are SHA-style hex hashes from the agent's
|
||||||
@@ -102,6 +104,7 @@ def _checkpoint_entry_modes(git: str, ckpt_dir: Path) -> dict[str, int]:
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[git, "-C", str(ckpt_dir), "ls-files", "-s"],
|
[git, "-C", str(ckpt_dir), "ls-files", "-s"],
|
||||||
capture_output=True, text=True, timeout=10,
|
capture_output=True, text=True, timeout=10,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise ValueError("Failed to list checkpoint files")
|
raise ValueError("Failed to list checkpoint files")
|
||||||
@@ -136,6 +139,7 @@ def _read_checkpoint_blob(git: str, ckpt_dir: Path, rel_path: str) -> bytes | No
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[git, "-C", str(ckpt_dir), "show", f"HEAD:{rel_path}"],
|
[git, "-C", str(ckpt_dir), "show", f"HEAD:{rel_path}"],
|
||||||
capture_output=True, timeout=10,
|
capture_output=True, timeout=10,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
return None
|
return None
|
||||||
@@ -250,6 +254,7 @@ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
|
|||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
[git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"],
|
[git, "-C", str(ckpt_path), "log", "--format=%H%n%s%n%aI", "-1"],
|
||||||
capture_output=True, text=True, timeout=5,
|
capture_output=True, text=True, timeout=5,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
if result.returncode != 0 or not result.stdout.strip():
|
if result.returncode != 0 or not result.stdout.strip():
|
||||||
return None
|
return None
|
||||||
@@ -272,6 +277,7 @@ def _inspect_checkpoint(ckpt_path: Path, git: str) -> dict[str, Any] | None:
|
|||||||
files_result = subprocess.run(
|
files_result = subprocess.run(
|
||||||
[git, "-C", str(ckpt_path), "ls-files"],
|
[git, "-C", str(ckpt_path), "ls-files"],
|
||||||
capture_output=True, text=True, timeout=5,
|
capture_output=True, text=True, timeout=5,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0
|
file_count = len(files_result.stdout.strip().split("\n")) if files_result.stdout.strip() else 0
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""Dependency-light helpers for launching child processes consistently."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def windows_hide_flags() -> int:
|
||||||
|
"""Hide a short-lived console child on Win32 and remain a POSIX no-op.
|
||||||
|
|
||||||
|
``CREATE_NO_WINDOW`` keeps captured stdout and stderr connected, unlike
|
||||||
|
detaching the process. Passing ``0`` elsewhere preserves the subprocess
|
||||||
|
default. See #5692.
|
||||||
|
"""
|
||||||
|
if sys.platform == "win32":
|
||||||
|
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
return 0
|
||||||
+9
-3
@@ -28,6 +28,7 @@ from api.agent_health import get_active_profile_gateway_running_pid
|
|||||||
from api.gateway_restart import restart_active_profile_gateway
|
from api.gateway_restart import restart_active_profile_gateway
|
||||||
from api.profiles import get_active_profile_name
|
from api.profiles import get_active_profile_name
|
||||||
from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK
|
from api.config import REPO_ROOT, STREAMS, STREAMS_LOCK
|
||||||
|
from api.subprocess_utils import windows_hide_flags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -215,9 +216,14 @@ def _run_git(args, cwd, timeout=10):
|
|||||||
return 'git executable not found', False
|
return 'git executable not found', False
|
||||||
try:
|
try:
|
||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
[git_executable] + args, cwd=str(cwd), capture_output=True,
|
[git_executable] + args,
|
||||||
text=True, timeout=timeout,
|
cwd=str(cwd),
|
||||||
encoding='utf-8', errors='replace',
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=timeout,
|
||||||
|
encoding='utf-8',
|
||||||
|
errors='replace',
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
# On non-UTF-8 locales (e.g. Chinese Windows GBK), a binary git
|
# On non-UTF-8 locales (e.g. Chinese Windows GBK), a binary git
|
||||||
# output that fails to decode used to leave r.stdout = None and crash
|
# output that fails to decode used to leave r.stdout = None and crash
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ from api.config import (
|
|||||||
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
|
DEFAULT_WORKSPACE as _BOOT_DEFAULT_WORKSPACE,
|
||||||
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
|
MAX_FILE_BYTES, IMAGE_EXTS, MD_EXTS
|
||||||
)
|
)
|
||||||
|
from api.subprocess_utils import windows_hide_flags
|
||||||
|
|
||||||
|
|
||||||
# ── Profile-aware path resolution ───────────────────────────────────────────
|
# ── Profile-aware path resolution ───────────────────────────────────────────
|
||||||
@@ -1779,6 +1780,7 @@ def _run_git(args, cwd, timeout=3):
|
|||||||
r = subprocess.run(
|
r = subprocess.run(
|
||||||
['git'] + args, cwd=str(cwd), capture_output=True,
|
['git'] + args, cwd=str(cwd), capture_output=True,
|
||||||
text=True, timeout=timeout,
|
text=True, timeout=timeout,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
return r.stdout.strip() if r.returncode == 0 else None
|
return r.stdout.strip() if r.returncode == 0 else None
|
||||||
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
|
||||||
|
|||||||
+5
-19
@@ -12,7 +12,6 @@ import logging
|
|||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import re
|
import re
|
||||||
@@ -21,24 +20,11 @@ from dataclasses import dataclass
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Iterable
|
from typing import Iterable
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
from api.subprocess_utils import windows_hide_flags
|
||||||
|
|
||||||
|
|
||||||
def _windows_hide_flags() -> int:
|
|
||||||
"""Win32 ``creationflags`` that hide a short-lived console child's window
|
|
||||||
(``CREATE_NO_WINDOW``) without detaching it, so ``capture_output`` still
|
|
||||||
works. Returns ``0`` on non-Windows — the ``subprocess`` default, a genuine
|
|
||||||
no-op. Mirrors the ``api/updates.py`` pattern; kept local so workspace-git
|
|
||||||
never takes a hard dependency on the optional ``hermes_cli`` package (a
|
|
||||||
standalone/agent-less WebUI must keep full git functionality). See #5692.
|
|
||||||
"""
|
|
||||||
if sys.platform == "win32":
|
|
||||||
return getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored
|
from api.workspace import rmtree_anchored, safe_resolve_ws, unlink_anchored
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
GIT_TIMEOUT = 5
|
GIT_TIMEOUT = 5
|
||||||
GIT_REMOTE_TIMEOUT = 60
|
GIT_REMOTE_TIMEOUT = 60
|
||||||
@@ -263,7 +249,7 @@ def _run_git(
|
|||||||
text=True,
|
text=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
env=run_env,
|
env=run_env,
|
||||||
creationflags=_windows_hide_flags(),
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
except subprocess.TimeoutExpired as exc:
|
except subprocess.TimeoutExpired as exc:
|
||||||
raise GitWorkspaceError("Git command timed out", "timeout") from exc
|
raise GitWorkspaceError("Git command timed out", "timeout") from exc
|
||||||
@@ -306,7 +292,7 @@ def _config_names_for_scope(
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=GIT_TIMEOUT,
|
timeout=GIT_TIMEOUT,
|
||||||
env=env,
|
env=env,
|
||||||
creationflags=_windows_hide_flags(),
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
if result.returncode not in {0, 1}:
|
if result.returncode not in {0, 1}:
|
||||||
if ignore_unsupported:
|
if ignore_unsupported:
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ from pathlib import Path
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
|
from api.subprocess_utils import windows_hide_flags
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ def _run_git(args: list[str], cwd: str | Path, timeout: float = 2) -> subprocess
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
check=False,
|
check=False,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -326,6 +329,7 @@ def find_git_repo_root(workspace: str | Path) -> Path:
|
|||||||
capture_output=True,
|
capture_output=True,
|
||||||
timeout=5,
|
timeout=5,
|
||||||
check=False,
|
check=False,
|
||||||
|
creationflags=windows_hide_flags(),
|
||||||
)
|
)
|
||||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||||
raise ValueError("Workspace is not inside a git repository") from exc
|
raise ValueError("Workspace is not inside a git repository") from exc
|
||||||
|
|||||||
+1
-1
@@ -3513,7 +3513,7 @@ window._mirrorSpeechSettingsFromServer=_mirrorSpeechSettingsFromServer;
|
|||||||
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
const _testUpdates=new URLSearchParams(location.search).get('test_updates')==='1';
|
||||||
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
if(_testUpdates||(_bootSettings.check_for_updates!==false&&!sessionStorage.getItem('hermes-update-checked')&&!sessionStorage.getItem('hermes-update-dismissed'))){
|
||||||
const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
|
const _checkUrl='api/updates/check'+(_testUpdates?'?simulate=1':'');
|
||||||
api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false})}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false}),timeoutMs:300000}).then(d=>{if(!_testUpdates)sessionStorage.setItem('hermes-update-checked','1');if((d.webui&&d.webui.behind>0)||(d.agent&&d.agent.behind>0))_showUpdateBanner(d);}).catch(()=>{});
|
||||||
}
|
}
|
||||||
const _bootActiveProfileUnauthRedirectBudget=(()=>{
|
const _bootActiveProfileUnauthRedirectBudget=(()=>{
|
||||||
const markerKey='hermes-webui-active-profile-bootstrap-401';
|
const markerKey='hermes-webui-active-profile-bootstrap-401';
|
||||||
|
|||||||
+1
-1
@@ -12128,7 +12128,7 @@ async function checkUpdatesNow(channelOverride){
|
|||||||
// saved setting. (Fable UX gate.)
|
// saved setting. (Fable UX gate.)
|
||||||
const _checkBody={force:true};
|
const _checkBody={force:true};
|
||||||
if(channelOverride==='stable'||channelOverride==='experimental') _checkBody.channel=channelOverride;
|
if(channelOverride==='stable'||channelOverride==='experimental') _checkBody.channel=channelOverride;
|
||||||
const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:60000});
|
const data=await api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:300000});
|
||||||
if(data.disabled){
|
if(data.disabled){
|
||||||
if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';}
|
if(status){status.textContent=t('settings_updates_disabled');status.style.color='var(--muted)';}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -344,7 +344,12 @@ console.log(JSON.stringify({
|
|||||||
prefill_pos = BOOT_JS.find(
|
prefill_pos = BOOT_JS.find(
|
||||||
"const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null;"
|
"const prefillIntent=(typeof _composerPrefillIntentFromLocation==='function')?_composerPrefillIntentFromLocation():null;"
|
||||||
)
|
)
|
||||||
first_await_pos = BOOT_JS.find("const s=await api('/api/settings');", prefill_pos)
|
first_await_match = re.search(
|
||||||
|
r"const s=await api\('/api/settings'(?:,\{[^)]*\})?\);",
|
||||||
|
BOOT_JS[prefill_pos:],
|
||||||
|
)
|
||||||
|
assert first_await_match, "settings fetch with optional options not found after prefill intent"
|
||||||
|
first_await_pos = prefill_pos + first_await_match.start()
|
||||||
active_profile_pos = BOOT_JS.find(
|
active_profile_pos = BOOT_JS.find(
|
||||||
"const activeProfileState = await _resolveActiveProfileBootstrapState();",
|
"const activeProfileState = await _resolveActiveProfileBootstrapState();",
|
||||||
first_await_pos,
|
first_await_pos,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ flipped the default to 'steer'; this file was updated for the rename while
|
|||||||
preserving the persistence-mirror guarantees (the load-failure path must still
|
preserving the persistence-mirror guarantees (the load-failure path must still
|
||||||
honor the saved preference, not clobber it with a hardcoded default).
|
honor the saved preference, not clobber it with a hardcoded default).
|
||||||
"""
|
"""
|
||||||
|
import re
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).parent.parent
|
ROOT = Path(__file__).parent.parent
|
||||||
@@ -47,8 +48,12 @@ class TestEagerDefault:
|
|||||||
eager_idx = BOOT_JS.find("window._defaultMessageMode=_readPersistedDefaultMessageMode()")
|
eager_idx = BOOT_JS.find("window._defaultMessageMode=_readPersistedDefaultMessageMode()")
|
||||||
assert eager_idx >= 0, "eager default assignment not found"
|
assert eager_idx >= 0, "eager default assignment not found"
|
||||||
# The async IIFE awaits /api/settings; the success-path assignment lives inside it.
|
# The async IIFE awaits /api/settings; the success-path assignment lives inside it.
|
||||||
await_idx = BOOT_JS.find("const s=await api('/api/settings')")
|
await_match = re.search(
|
||||||
assert await_idx >= 0, "async settings fetch not found"
|
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, (
|
assert eager_idx < await_idx, (
|
||||||
"the eager window._defaultMessageMode default must be set BEFORE the async "
|
"the eager window._defaultMessageMode default must be set BEFORE the async "
|
||||||
"/api/settings fetch — otherwise the boot-window race (#5167) persists"
|
"/api/settings fetch — otherwise the boot-window race (#5167) persists"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import textwrap
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
BOOT_JS = ROOT / "static" / "boot.js"
|
||||||
WORKSPACE_JS = ROOT / "static" / "workspace.js"
|
WORKSPACE_JS = ROOT / "static" / "workspace.js"
|
||||||
SESSIONS_JS = ROOT / "static" / "sessions.js"
|
SESSIONS_JS = ROOT / "static" / "sessions.js"
|
||||||
UI_JS = ROOT / "static" / "ui.js"
|
UI_JS = ROOT / "static" / "ui.js"
|
||||||
@@ -208,12 +209,15 @@ def test_api_has_default_timeout_and_per_call_override_contract():
|
|||||||
|
|
||||||
|
|
||||||
def test_update_flows_keep_explicit_longer_timeouts():
|
def test_update_flows_keep_explicit_longer_timeouts():
|
||||||
"""Legitimately long update flows should not inherit the generic 30s guard."""
|
"""Network-bound update checks need a single-attempt 300s latency budget."""
|
||||||
|
boot = _source(BOOT_JS)
|
||||||
src = _source(UI_JS)
|
src = _source(UI_JS)
|
||||||
panels = _source(PANELS_JS)
|
panels = _source(PANELS_JS)
|
||||||
# /api/updates/check builds its body in a _checkBody var (to optionally add
|
# /api/updates/check builds its body in a _checkBody var (to optionally add
|
||||||
# an explicit channel), but must still carry the 60s timeout override.
|
# an explicit channel). Both boot and manual callers need enough time for
|
||||||
assert "api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:60000})" in panels
|
# the endpoint's bounded fetches; api() does not retry ordinary timeouts.
|
||||||
|
assert "api(_checkUrl,{method:_testUpdates?'GET':'POST',body:_testUpdates?undefined:JSON.stringify({force:false}),timeoutMs:300000})" in boot
|
||||||
|
assert "api('/api/updates/check',{method:'POST',body:JSON.stringify(_checkBody),timeoutMs:300000})" in panels
|
||||||
assert "api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000})" in src
|
assert "api('/api/updates/summary',{method:'POST',body:JSON.stringify({updates:scopedUpdates,target:target||null}),timeoutMs:60000})" in src
|
||||||
# apply/force now build their body inline to optionally carry the offered
|
# apply/force now build their body inline to optionally carry the offered
|
||||||
# channel (Codex debounce-race fix), but MUST still carry the 120s override.
|
# channel (Codex debounce-race fix), but MUST still carry the 120s override.
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"""Inventory coverage for windowless production Git subprocesses on Windows."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
EXPECTED_GIT_RUNS = Counter(
|
||||||
|
{
|
||||||
|
"api/agent_runtime.py": 3,
|
||||||
|
"api/rollback.py": 4,
|
||||||
|
"api/updates.py": 1,
|
||||||
|
"api/workspace.py": 1,
|
||||||
|
"api/workspace_git.py": 2,
|
||||||
|
"api/worktrees.py": 2,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_subprocess_run(node: ast.Call) -> bool:
|
||||||
|
"""Return whether ``node`` is a direct ``subprocess.run(...)`` boundary."""
|
||||||
|
return (
|
||||||
|
isinstance(node.func, ast.Attribute)
|
||||||
|
and isinstance(node.func.value, ast.Name)
|
||||||
|
and node.func.value.id == "subprocess"
|
||||||
|
and node.func.attr == "run"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _git_argv_kind(node: ast.AST) -> str | None:
|
||||||
|
"""Recognize the production argv forms used by direct Git subprocess calls."""
|
||||||
|
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||||
|
return _git_argv_kind(node.left)
|
||||||
|
if isinstance(node, (ast.List, ast.Tuple)) and node.elts:
|
||||||
|
executable = node.elts[0]
|
||||||
|
if isinstance(executable, ast.Constant) and executable.value == "git":
|
||||||
|
return "git"
|
||||||
|
if isinstance(executable, ast.Name) and executable.id in {"git", "git_executable"}:
|
||||||
|
return executable.id
|
||||||
|
if (
|
||||||
|
isinstance(node, ast.Call)
|
||||||
|
and isinstance(node.func, ast.Name)
|
||||||
|
and node.func.id == "_hardened_git_argv"
|
||||||
|
):
|
||||||
|
return node.func.id
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _production_git_runs() -> list[tuple[str, ast.Call]]:
|
||||||
|
"""Inventory every recognizable direct Git ``subprocess.run`` in production code."""
|
||||||
|
calls: list[tuple[str, ast.Call]] = []
|
||||||
|
production_files = [*sorted((ROOT / "api").glob("*.py")), ROOT / "bootstrap.py", ROOT / "server.py"]
|
||||||
|
for path in production_files:
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
relative_path = path.relative_to(ROOT).as_posix()
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if (
|
||||||
|
isinstance(node, ast.Call)
|
||||||
|
and _is_subprocess_run(node)
|
||||||
|
and node.args
|
||||||
|
and _git_argv_kind(node.args[0]) is not None
|
||||||
|
):
|
||||||
|
calls.append((relative_path, node))
|
||||||
|
return calls
|
||||||
|
|
||||||
|
|
||||||
|
def test_git_subprocess_inventory_uses_shared_windows_flags() -> None:
|
||||||
|
"""All production Git boundaries must use the one dependency-light helper."""
|
||||||
|
calls = _production_git_runs()
|
||||||
|
assert Counter(path for path, _node in calls) == EXPECTED_GIT_RUNS
|
||||||
|
|
||||||
|
missing_shared_helper: list[str] = []
|
||||||
|
for path, node in calls:
|
||||||
|
creationflags = next(
|
||||||
|
(keyword.value for keyword in node.keywords if keyword.arg == "creationflags"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if not (
|
||||||
|
isinstance(creationflags, ast.Call)
|
||||||
|
and isinstance(creationflags.func, ast.Name)
|
||||||
|
and creationflags.func.id == "windows_hide_flags"
|
||||||
|
):
|
||||||
|
missing_shared_helper.append(f"{path}:{node.lineno}")
|
||||||
|
|
||||||
|
assert not missing_shared_helper, (
|
||||||
|
"Git subprocess.run calls must pass creationflags=windows_hide_flags(): "
|
||||||
|
+ ", ".join(missing_shared_helper)
|
||||||
|
)
|
||||||
|
|
||||||
|
helper_definitions: list[str] = []
|
||||||
|
for path in sorted((ROOT / "api").glob("*.py")):
|
||||||
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||||||
|
if any(
|
||||||
|
isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||||
|
and node.name in {"windows_hide_flags", "_windows_hide_flags"}
|
||||||
|
for node in ast.walk(tree)
|
||||||
|
):
|
||||||
|
helper_definitions.append(path.relative_to(ROOT).as_posix())
|
||||||
|
assert helper_definitions == ["api/subprocess_utils.py"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_hide_flags_returns_win32_sentinel(monkeypatch) -> None:
|
||||||
|
"""The helper must expose the platform's CREATE_NO_WINDOW value on Win32."""
|
||||||
|
from api import subprocess_utils
|
||||||
|
|
||||||
|
sentinel = 0x08000000
|
||||||
|
monkeypatch.setattr(subprocess_utils, "sys", SimpleNamespace(platform="win32"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess_utils,
|
||||||
|
"subprocess",
|
||||||
|
SimpleNamespace(CREATE_NO_WINDOW=sentinel),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert subprocess_utils.windows_hide_flags() == sentinel
|
||||||
|
|
||||||
|
|
||||||
|
def test_windows_hide_flags_is_zero_on_posix(monkeypatch) -> None:
|
||||||
|
"""Passing creationflags=0 must remain a genuine no-op off Windows."""
|
||||||
|
from api import subprocess_utils
|
||||||
|
|
||||||
|
monkeypatch.setattr(subprocess_utils, "sys", SimpleNamespace(platform="linux"))
|
||||||
|
monkeypatch.setattr(
|
||||||
|
subprocess_utils,
|
||||||
|
"subprocess",
|
||||||
|
SimpleNamespace(CREATE_NO_WINDOW=0x08000000),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert subprocess_utils.windows_hide_flags() == 0
|
||||||
@@ -61,8 +61,12 @@ class TestBootJsProfileDefaultWorkspace:
|
|||||||
src = read('static/boot.js')
|
src = read('static/boot.js')
|
||||||
# Find the settings fetch and the _profileDefaultWorkspace ASSIGNMENT
|
# Find the settings fetch and the _profileDefaultWorkspace ASSIGNMENT
|
||||||
# (the if(s.default_workspace) line, not usages elsewhere in the file)
|
# (the if(s.default_workspace) line, not usages elsewhere in the file)
|
||||||
settings_idx = src.find("await api('/api/settings')")
|
settings_match = re.search(
|
||||||
assert settings_idx != -1, "await api('/api/settings') not found in boot.js"
|
r"await api\('/api/settings'(?:,\{[^)]*\})?\)",
|
||||||
|
src,
|
||||||
|
)
|
||||||
|
assert settings_match, "await api('/api/settings', optionalOptions) not found in boot.js"
|
||||||
|
settings_idx = settings_match.start()
|
||||||
# Find the assignment specifically — it uses 's.default_workspace'
|
# Find the assignment specifically — it uses 's.default_workspace'
|
||||||
ws_assign_idx = src.find('S._profileDefaultWorkspace=s.default_workspace')
|
ws_assign_idx = src.find('S._profileDefaultWorkspace=s.default_workspace')
|
||||||
assert ws_assign_idx != -1, "S._profileDefaultWorkspace assignment not found in boot.js"
|
assert ws_assign_idx != -1, "S._profileDefaultWorkspace assignment not found in boot.js"
|
||||||
|
|||||||
@@ -2518,12 +2518,12 @@ def test_dirty_worktree_uses_filter_neutralization(tmp_path):
|
|||||||
|
|
||||||
|
|
||||||
def test_run_git_passes_windows_hide_flags(monkeypatch, tmp_path):
|
def test_run_git_passes_windows_hide_flags(monkeypatch, tmp_path):
|
||||||
"""_run_git must pass creationflags=_windows_hide_flags() so git child
|
"""_run_git must pass creationflags=windows_hide_flags() so git child
|
||||||
processes don't accumulate visible console windows on Windows (#5692).
|
processes don't accumulate visible console windows on Windows (#5692).
|
||||||
windows_hide_flags() is 0 on non-Windows, so this is a safe no-op there;
|
windows_hide_flags() is 0 on non-Windows, so this is a safe no-op there;
|
||||||
the test asserts the kwarg is wired regardless of platform."""
|
the test asserts the kwarg is wired regardless of platform."""
|
||||||
import api.workspace_git as wg
|
import api.workspace_git as wg
|
||||||
from api.workspace_git import _windows_hide_flags
|
from api.subprocess_utils import windows_hide_flags
|
||||||
|
|
||||||
repo = _init_repo(tmp_path / "repo")
|
repo = _init_repo(tmp_path / "repo")
|
||||||
(repo / "f.txt").write_text("x\n", encoding="utf-8")
|
(repo / "f.txt").write_text("x\n", encoding="utf-8")
|
||||||
@@ -2539,17 +2539,17 @@ def test_run_git_passes_windows_hide_flags(monkeypatch, tmp_path):
|
|||||||
monkeypatch.setattr(wg.subprocess, "run", fake_run)
|
monkeypatch.setattr(wg.subprocess, "run", fake_run)
|
||||||
wg._run_git(repo, ["rev-parse", "HEAD"])
|
wg._run_git(repo, ["rev-parse", "HEAD"])
|
||||||
|
|
||||||
assert captured.get("creationflags") == _windows_hide_flags(), (
|
assert captured.get("creationflags") == windows_hide_flags(), (
|
||||||
"_run_git must pass creationflags=_windows_hide_flags() to subprocess.run"
|
"_run_git must pass creationflags=windows_hide_flags() to subprocess.run"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_config_names_for_scope_passes_windows_hide_flags(monkeypatch, tmp_path):
|
def test_config_names_for_scope_passes_windows_hide_flags(monkeypatch, tmp_path):
|
||||||
"""_config_names_for_scope must also pass creationflags=_windows_hide_flags()
|
"""_config_names_for_scope must also pass creationflags=windows_hide_flags()
|
||||||
(#5692) — the git-config probe spawns a console window on Windows too."""
|
(#5692) — the git-config probe spawns a console window on Windows too."""
|
||||||
import re as _re
|
import re as _re
|
||||||
import api.workspace_git as wg
|
import api.workspace_git as wg
|
||||||
from api.workspace_git import _windows_hide_flags
|
from api.subprocess_utils import windows_hide_flags
|
||||||
|
|
||||||
repo = _init_repo(tmp_path / "repo")
|
repo = _init_repo(tmp_path / "repo")
|
||||||
|
|
||||||
@@ -2570,6 +2570,6 @@ def test_config_names_for_scope_passes_windows_hide_flags(monkeypatch, tmp_path)
|
|||||||
ignore_unsupported=True,
|
ignore_unsupported=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
assert captured.get("creationflags") == _windows_hide_flags(), (
|
assert captured.get("creationflags") == windows_hide_flags(), (
|
||||||
"_config_names_for_scope must pass creationflags=_windows_hide_flags()"
|
"_config_names_for_scope must pass creationflags=windows_hide_flags()"
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user