fix(tests): permanent os.execv guard to stop pytest self-restart loop
api.updates._schedule_restart() spawns a daemon thread that calls os.execv() after a short sleep. Tests in test_update_banner_fixes.py monkeypatch os.execv to a no-op, but monkeypatch teardown can win the race against the daemon thread — when the thread wakes up after teardown, the real os.execv is back, and it re-execs pytest with the original argv. From the outside this looked like pytest hanging at 99% and then restarting the entire suite from 0% in a loop. The fix shadows os.execv with a permanent no-op wrapper at conftest module-import time, so late-firing daemon threads can't escape. Tests that need to verify execv was called still patch it themselves; their patches sit on top of the wrapper for their lifetime. Also adds tests/test_pytest_execv_guard.py to pin the guard against future conftest refactors.
This commit is contained in:
parent
8f4d6c1431
commit
20bd845416
3 changed files with 93 additions and 0 deletions
28
CHANGELOG.md
28
CHANGELOG.md
|
|
@ -2,8 +2,36 @@
|
|||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.75] — 2026-05-16 — Release AY (stage-368 — 11-PR safe-lane batch — storage + i18n + run-journal parity + attachments + compression sidebar + restart-recovery + text-mode images + tables + settings i18n + German labels)
|
||||
|
||||
### Test infrastructure
|
||||
|
||||
- Stage-368 maintainer fix — pytest no longer self-loops on the `_schedule_restart` daemon thread. Several existing tests in `tests/test_update_banner_fixes.py` call `api.updates._schedule_restart()`, which spawns a daemon thread that eventually calls `os.execv()`. Those tests monkeypatch `os.execv` for the test scope, but monkeypatch teardown can win the race against the daemon thread, restoring the real `os.execv` before the thread fires it — at which point the daemon re-execs the entire pytest process with the original argv, looking from the outside like pytest hangs at 99 % then restarts the suite from 0 % in an infinite loop. `tests/conftest.py` now installs a permanent no-op wrapper on `os.execv` at module-import time so late-firing daemon threads cannot re-exec pytest. New `tests/test_pytest_execv_guard.py` pins the guard against future regressions.
|
||||
|
||||
### Added
|
||||
|
||||
- **PR #2377** by @franksong2702 (refs #2283, refs #2363, refs #1925) — Run-journal replay timeline parity checks. After #2283 shipped the first run-journal replay slice and #2363 documented the cross-layer state consistency contract, this PR adds explicit parity assertions over the replayed timeline so divergences between the journal and the visible transcript (Thinking → tool calls → assistant text) surface as test failures instead of silent drift.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2391** by @Michaelyklam (fixes #2389) — Reduce browser storage pressure during service-worker updates and over long-running sessions. `static/sw.js` now calls `deleteOldShellCaches()` BEFORE `caches.open(CACHE_NAME)` in the install handler so the new ~2.2 MB shell cache no longer overlaps the old one during a version bump (especially painful on shared-origin quota accounting). A new `_clearSessionViewedCount()` helper plus extended `_clearHandoffStorageForSession()` prune `hermes-session-viewed-counts`, `hermes-session-completion-unread`, and `hermes-session-observed-streaming` on every single-session delete and batch-delete so per-session tracking maps no longer grow unbounded.
|
||||
|
||||
- **PR #2387** by @Michaelyklam (fixes #2386) — Guard `localStorage.setItem('hermes-webui-session', ...)` and workspace-panel runtime-state writes with `try { … } catch (_) {}` across `static/boot.js`, `static/sessions.js`, `static/commands.js`, and `static/messages.js`. These convenience writes were previously fatal UI operations on quota-exhausted browsers (especially Firefox public-domain setups where shared quota fills up after a service-worker shell rotation).
|
||||
|
||||
- **PR #2368** by @Michaelyklam — Hybridize background profile env routing so background title generation, manual compression, and update-summary workers honor a session's non-default profile. The pure thread-local refactor for #2321 was reverted because `hermes_cli.config.load_config()` still reads `HERMES_HOME` from process env. This PR keeps the thread-local layer for WebUI helpers and adds an `os.environ.update(runtime_env)` mirror under a narrow `_ENV_LOCK` for the worker body, with proper restore of prior values. New test asserts `OPENROUTER_API_KEY` is visible from the worker against a non-default profile.
|
||||
|
||||
- **PR #2382** by @Michaelyklam (fixes #2380) — Serve raw chat attachments from the per-session inbox in addition to the session workspace. Chat uploads were intentionally moved out of workspaces into a per-session attachment inbox in an earlier release; the transcript renderer still emits stable `api/file/raw?session_id=...&path=<filename>` URLs, but `_handle_file_raw` only checked `session.workspace` so inbox-backed uploads rendered as broken images. The URL surface is preserved and a session-attachment fallback is added with path-traversal guards intact.
|
||||
|
||||
- **PR #2385** by @franksong2702 — Keep fuller compression snapshots reachable in the sidebar. The default behavior hides `pre_compression_snapshot: true` rows so archived compression segments do not duplicate the active continuation. A real long Kanban session exposed a narrower failure: the fuller transcript was still present on disk but remained marked as `pre_compression_snapshot`, so the sidebar surfaced a shorter row and the fuller transcript became unreachable. The fix preserves discoverability without re-introducing duplication in normal cases.
|
||||
|
||||
- **PR #2371** by @franksong2702 — Clarify interrupted turn recovery after a WebUI restart. WebUI executes browser-originated agent turns inside the WebUI process; if that process restarts mid-turn, the worker dies with it. Run journal replay can only replay events that were already emitted, so the stale-pending repair path is now annotated and refined to make the post-restart state explicit (interrupted, recoverable, or terminal) instead of leaving the user with a half-rendered turn and no signal.
|
||||
|
||||
- **PR #2378** by @Michaelyklam — Strip historical images in text-only mode. Current-turn uploads already respect `agent.image_input_mode: text`, but saved conversation history still passed native `image_url` content parts back into later provider calls, breaking text-only providers on replayed turns. `_sanitize_messages_for_api()` gains a `cfg=` keyword argument so the API-history sanitizer can strip historical native image parts when the mode is text. Default `cfg=None` preserves prior behavior for callers that don't pass the new argument.
|
||||
|
||||
- **PR #2375** by @Michaelyklam — Keep Markdown tables block-level. Pipe tables were already converted to `<table>` markup, but the final paragraph pass did not treat generated tables as block-level output, occasionally wrapping them in `<p>` and breaking the surrounding layout. The fix isolates generated tables and adds `table` to the paragraph-wrap skip list so valid CommonMark tables render predictably.
|
||||
|
||||
- **PR #2372** by @mccxj — Settings → Conversation page action buttons now respect locale selection. Pre-fix, the JSON export, MD export, and Copy buttons had hardcoded English labels/titles. Adds `data-i18n` / `data-i18n-title` attributes plus the missing translation keys so non-English locales no longer see English labels stuck in the middle of a translated screen.
|
||||
|
||||
- **PR #2381** by @Michaelyklam (fixes #2379) — German relative session-time labels now interpolate the elapsed value instead of rendering the literal `{n}` placeholder in the sidebar/header. The German locale now uses function-valued translations for minutes, hours, and days, matching the other locale bundles.
|
||||
|
||||
## [v0.51.74] — 2026-05-16 — Release AX (stage-367 — 4-PR safe-lane batch — #2362 table-cell spacing + #2363 run-state-consistency RFC + #2365 custom_providers list-format + #2367 settings sidebar i18n)
|
||||
|
|
|
|||
|
|
@ -171,6 +171,36 @@ def pytest_configure(config):
|
|||
# imports trigger botocore initialisation.
|
||||
os.environ.setdefault("AWS_EC2_METADATA_DISABLED", "true")
|
||||
|
||||
# ── Permanent os.execv guard for the pytest session ────────────────────────
|
||||
# Several tests in tests/test_update_banner_fixes.py exercise
|
||||
# api.updates._schedule_restart(), which spawns a DAEMON thread that sleeps
|
||||
# for a short delay and then calls ``os.execv(sys.executable, sys.argv)``.
|
||||
# Those tests monkeypatch ``os.execv`` to a no-op for the test scope, but
|
||||
# monkeypatch teardown happens at test exit — if the daemon thread has not
|
||||
# yet woken up by then (system load, GC pause, _apply_lock contention), the
|
||||
# real ``os.execv`` is restored before the thread fires it. The daemon then
|
||||
# REPLACES the pytest process image with a fresh ``pytest tests/ -q ...``
|
||||
# invocation, looking from the outside like pytest "hangs at 99%" and then
|
||||
# restarts the entire suite from 0% — a self-perpetuating loop.
|
||||
#
|
||||
# Daemon threads cannot be reliably joined from a test fixture (they live in
|
||||
# ``api.updates`` module scope), so the only safe answer is to render
|
||||
# ``os.execv`` permanently inert for the pytest session. Production code is
|
||||
# unaffected because production never imports this conftest.
|
||||
#
|
||||
# Tests that need to verify execv WAS called still monkeypatch it themselves
|
||||
# — their patched version takes precedence over this no-op wrapper for the
|
||||
# test's lifetime, and the no-op only kicks in after teardown for daemon
|
||||
# threads that wake up late.
|
||||
_real_execv = os.execv
|
||||
|
||||
def _pytest_session_safe_execv(_exe, _args): # pragma: no cover — never called in prod
|
||||
# Drop the call on the floor. A late-firing daemon thread from
|
||||
# _schedule_restart() must not be able to re-exec the pytest process.
|
||||
return None
|
||||
|
||||
os.execv = _pytest_session_safe_execv
|
||||
|
||||
# ── Hermetic network isolation ─────────────────────────────────────────────
|
||||
# Tests must not reach the public internet. Outbound to Anthropic / OpenAI /
|
||||
# Amazon / OpenRouter / etc. is forbidden by default. The test suite already
|
||||
|
|
|
|||
35
tests/test_pytest_execv_guard.py
Normal file
35
tests/test_pytest_execv_guard.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
"""Regression guard for the pytest "hangs at 99% then restarts from 0%" loop.
|
||||
|
||||
Root cause documented in tests/conftest.py — daemon threads spawned by
|
||||
api.updates._schedule_restart() can fire os.execv() AFTER monkeypatch
|
||||
teardown restores the real os.execv, which re-execs the entire pytest
|
||||
process. The conftest installs a permanent no-op wrapper on os.execv that
|
||||
shadows any late-firing daemon thread.
|
||||
|
||||
This test pins the guard so a future conftest refactor can't silently
|
||||
remove it.
|
||||
"""
|
||||
import os
|
||||
|
||||
|
||||
def test_conftest_installs_permanent_execv_guard():
|
||||
"""os.execv must be replaced by the conftest's safe no-op wrapper."""
|
||||
# The wrapper is named `_pytest_session_safe_execv` in conftest.py.
|
||||
# Verify the module attribute now points to that wrapper, not the real
|
||||
# libc-bound function.
|
||||
assert os.execv.__name__ == '_pytest_session_safe_execv', (
|
||||
f"os.execv must be the conftest-installed pytest-safe no-op, but "
|
||||
f"resolves to {os.execv!r}. Did a recent conftest refactor remove "
|
||||
f"the guard? See conftest.py § 'Permanent os.execv guard for the "
|
||||
f"pytest session' — without it, late-firing _schedule_restart "
|
||||
f"daemon threads re-exec pytest and the suite loops forever."
|
||||
)
|
||||
|
||||
|
||||
def test_safe_execv_returns_none_does_not_exec():
|
||||
"""The wrapper must be a true no-op — it must not raise, exec, or block."""
|
||||
# Pass deliberately bogus args to confirm the wrapper drops them rather
|
||||
# than passing them through to the real execv.
|
||||
result = os.execv('/nonexistent/binary/path/that/should/not/be/executed',
|
||||
['/nonexistent/binary/path/that/should/not/be/executed'])
|
||||
assert result is None
|
||||
Loading…
Reference in a new issue