fix: update banner — conflict recovery path + server self-restart after update (#816)
* fix: update banner conflict recovery + server self-restart after update (#813 #814) * fix(update): restart must wait for in-flight update + reset force button on retry Two defects in the update banner flow found during review of PR #816: 1. Two-target race (webui + agent sequential) The client posts targets sequentially: webui succeeds and schedules a restart timer (2 s delay); client then posts agent; server begins agent fetch+pull; at T=2 s the restart timer fires os.execv mid-pull, killing the agent update and closing the client connection. User sees "Update failed (agent): Failed to fetch" even though webui did update, and the agent repo is in an unknown partial state. Fix: _schedule_restart() now blocks on _apply_lock before calling os.execv. If a second update is in flight when the timer fires, the restart thread waits until it completes. If nothing is in flight the lock acquire is instant, so no-op updates still restart immediately. 2. Stale force-update button across retries _showUpdateError sets btnForceUpdate to display:inline-block when res.conflict / res.diverged. Nothing resets it on the next retry, so a subsequent non-conflict error (e.g. network) leaves the stale force button visible pointing at the previous target. Fix: applyUpdates() now hides the force button and clears its data-target at the start of each attempt. Tests: - test_schedule_restart_waits_for_apply_lock: holds _apply_lock from a helper thread, verifies execv is delayed until the lock is released. - test_schedule_restart_still_fires_when_no_update_in_flight: sanity check that the common path still works with no contention. - test_apply_updates_resets_force_button_at_start: regression guard that the reset appears before the update loop begins. Full suite: 1683 passed, 0 failures. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(update): hold _apply_lock through execv + fix banner error layout Two fixes from Opus review: 1. TOCTOU gap in _schedule_restart (api/updates.py): the original pattern acquired _apply_lock, released it, then called os.execv — leaving a brief window where a new update could start between release and execv. Fixed by moving os.execv inside the 'with _apply_lock:' block so the process is replaced while still holding the lock; no new update can acquire it. 2. Banner CSS layout (static/index.html): #updateError was a direct flex child of .update-banner (display:flex row), so long error messages sat inline between #updateMsg and the buttons instead of below the message. Wrapped #updateMsg + #updateError in a flex-column container so errors stack vertically under the status line. * docs: add v0.50.134 CHANGELOG entry --------- Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com> Co-authored-by: Nathan Esquenazi <nesquena@gmail.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
||||
# Hermes Web UI -- Changelog
|
||||
|
||||
## [v0.50.134] — 2026-04-21
|
||||
|
||||
### Fixed
|
||||
- **Update banner: conflict/diverged recovery path + server self-restart after update** — three failure modes resolved. (1) `Update failed (agent): Repository has unresolved merge conflicts` was a dead-end with no recovery path; the error now includes an actionable `git checkout . && git pull --ff-only` command, a persistent inline display (not a fleeting toast), and a **Force update** button that executes the reset via the new `POST /api/updates/force` endpoint. (2) After a successful update, the server now self-restarts via `os.execv` (2 s delay), eliminating the stale-`sys.modules` bug that broke custom provider chat on the next request. (3) When both webui and agent updates are pending, the restart now correctly waits for the second update to complete before re-executing (`_apply_lock` coordination), preventing the mid-pull kill race. Closes #813, #814. (#816)
|
||||
|
||||
## [v0.50.133] — 2026-04-21
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1469,6 +1469,14 @@ def handle_post(handler, parsed) -> bool:
|
||||
|
||||
return j(handler, apply_update(target))
|
||||
|
||||
if parsed.path == "/api/updates/force":
|
||||
target = body.get("target", "")
|
||||
if target not in ("webui", "agent"):
|
||||
return bad(handler, 'target must be "webui" or "agent"')
|
||||
from api.updates import apply_force_update
|
||||
|
||||
return j(handler, apply_force_update(target))
|
||||
|
||||
# ── CLI session import (POST) ──
|
||||
if parsed.path == "/api/session/import_cli":
|
||||
return _handle_session_import_cli(handler, body)
|
||||
|
||||
136
api/updates.py
136
api/updates.py
@@ -183,6 +183,111 @@ def check_for_updates(force=False):
|
||||
_check_in_progress = False
|
||||
|
||||
|
||||
def _schedule_restart(delay: float = 2.0) -> None:
|
||||
"""Re-exec this process after *delay* seconds.
|
||||
|
||||
Called after a successful update so that the freshly-pulled code is
|
||||
loaded on the next request, rather than running with a mix of old and
|
||||
new Python modules in sys.modules.
|
||||
|
||||
os.execv() replaces the current process image with a fresh interpreter
|
||||
running the same argv — sessions are preserved on disk, the HTTP port
|
||||
is reclaimed within the delay window, and the client's own
|
||||
``setTimeout(() => location.reload(), 2500)`` lands after the restart.
|
||||
|
||||
Coordinates with ``_apply_lock``: when the user updates both webui
|
||||
and agent, the client POSTs them sequentially. Without coordination
|
||||
the restart timer scheduled by the first update's success would fire
|
||||
while the second update's git-pull is still running, killing it mid-
|
||||
stream and leaving the second repo in an unknown partial state.
|
||||
Blocking on ``_apply_lock`` before ``os.execv`` means a pending
|
||||
second update always completes before the restart happens.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
def _do():
|
||||
import time
|
||||
time.sleep(delay)
|
||||
# Hold _apply_lock through os.execv so no new update can start between
|
||||
# the lock-release and the process replacement. Any in-flight update
|
||||
# finishes first (since it holds the lock), and then the process is
|
||||
# replaced while still holding the lock — meaning no new update can
|
||||
# sneak in during the brief TOCTOU window that existed with the
|
||||
# original acquire-release-execv sequence.
|
||||
# Threads die when execv replaces the process image, so the lock is
|
||||
# released atomically by the kernel.
|
||||
with _apply_lock:
|
||||
try:
|
||||
os.execv(sys.executable, [sys.executable] + sys.argv)
|
||||
except Exception:
|
||||
# Last-resort: if execv fails (e.g. frozen binary), just exit
|
||||
# so the process supervisor (start.sh / Docker) restarts us.
|
||||
os._exit(0)
|
||||
|
||||
threading.Thread(target=_do, daemon=True).start()
|
||||
|
||||
|
||||
def apply_force_update(target: str) -> dict:
|
||||
"""Force-reset the target repo to the latest remote HEAD.
|
||||
|
||||
Unlike apply_update() which requires a clean working tree and refuses
|
||||
merge conflicts, this discards all local modifications (checkout .) and
|
||||
resets to origin/<branch> — equivalent to what the diverged/conflict
|
||||
error messages ask the user to run manually.
|
||||
|
||||
Should only be called when apply_update() has already returned a
|
||||
response with ``conflict: True`` or ``diverged: True`` and the user
|
||||
has confirmed they want to discard local changes.
|
||||
"""
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
return {'ok': False, 'message': 'Update already in progress'}
|
||||
try:
|
||||
if target == 'webui':
|
||||
path = REPO_ROOT
|
||||
elif target == 'agent':
|
||||
path = _AGENT_DIR
|
||||
else:
|
||||
return {'ok': False, 'message': f'Unknown target: {target}'}
|
||||
|
||||
if path is None or not (path / '.git').exists():
|
||||
return {'ok': False, 'message': 'Not a git repository'}
|
||||
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
'message': 'Could not reach the remote repository. Check your connection.',
|
||||
}
|
||||
|
||||
upstream, ok = _run_git(['rev-parse', '--abbrev-ref', '@{upstream}'], path)
|
||||
if ok and upstream:
|
||||
compare_ref = upstream
|
||||
else:
|
||||
branch = _detect_default_branch(path)
|
||||
compare_ref = f'origin/{branch}'
|
||||
|
||||
# Discard local modifications then reset to remote HEAD
|
||||
_run_git(['checkout', '.'], path)
|
||||
_, ok = _run_git(['reset', '--hard', compare_ref], path)
|
||||
if not ok:
|
||||
return {'ok': False, 'message': f'Force reset to {compare_ref} failed'}
|
||||
|
||||
with _cache_lock:
|
||||
_update_cache['checked_at'] = 0
|
||||
|
||||
_schedule_restart()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} force-updated to {compare_ref}',
|
||||
'target': target,
|
||||
'restart_scheduled': True,
|
||||
}
|
||||
finally:
|
||||
_apply_lock.release()
|
||||
|
||||
|
||||
def apply_update(target):
|
||||
"""Stash, pull --ff-only, pop for the given target repo."""
|
||||
if not _apply_lock.acquire(blocking=False):
|
||||
@@ -235,7 +340,16 @@ def _apply_update_inner(target):
|
||||
# Fail early on unresolved merge conflicts
|
||||
if any(line[:2] in {'DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'}
|
||||
for line in status_out.splitlines()):
|
||||
return {'ok': False, 'message': 'Repository has unresolved merge conflicts'}
|
||||
return {
|
||||
'ok': False,
|
||||
'message': (
|
||||
f'The local {target} repo has unresolved merge conflicts. '
|
||||
'To reset to the latest remote version run: '
|
||||
'git -C ' + str(path) + ' checkout . && '
|
||||
'git -C ' + str(path) + ' pull --ff-only'
|
||||
),
|
||||
'conflict': True,
|
||||
}
|
||||
stashed = False
|
||||
if status_out:
|
||||
_, ok = _run_git(['stash'], path)
|
||||
@@ -296,4 +410,22 @@ def _apply_update_inner(target):
|
||||
with _cache_lock:
|
||||
_update_cache['checked_at'] = 0
|
||||
|
||||
return {'ok': True, 'message': f'{target} updated successfully', 'target': target}
|
||||
# Schedule a self-restart so the updated code is loaded fresh. A plain
|
||||
# git pull leaves stale Python modules in sys.modules — agent imports that
|
||||
# reference new symbols (functions, classes) added in the update will fail
|
||||
# on the next request with AttributeError / ImportError. os.execv() re-
|
||||
# execs the same interpreter with the same argv, picking up the new code
|
||||
# cleanly without requiring the user to restart manually.
|
||||
#
|
||||
# The 2 s delay gives the HTTP response time to flush to the client before
|
||||
# the process replaces itself. The client already does
|
||||
# setTimeout(() => location.reload(), 1500) on success, so the page reload
|
||||
# and the restart land at roughly the same time.
|
||||
_schedule_restart()
|
||||
|
||||
return {
|
||||
'ok': True,
|
||||
'message': f'{target} updated successfully',
|
||||
'target': target,
|
||||
'restart_scheduled': True,
|
||||
}
|
||||
|
||||
@@ -212,10 +212,14 @@
|
||||
<div id="liveToolCards" style="display:none;max-width:800px;margin:0 auto;width:100%;padding:0 24px;"></div>
|
||||
</div>
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<span id="updateMsg"></span>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0">
|
||||
<div style="display:flex;flex-direction:column;flex:1;min-width:0">
|
||||
<span id="updateMsg"></span>
|
||||
<div id="updateError" style="display:none;font-size:12px;color:var(--error,#e05);margin-top:4px;word-break:break-word"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-shrink:0;flex-wrap:wrap">
|
||||
<button class="update-btn" onclick="dismissUpdate()">Later</button>
|
||||
<button class="update-btn update-primary" id="btnApplyUpdate" onclick="applyUpdates()">Update Now</button>
|
||||
<button class="update-btn" id="btnForceUpdate" style="display:none;background:var(--error,#e05);color:#fff;border-color:var(--error,#e05)" onclick="forceUpdate(this)">Force update</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="reconnect-banner" id="reconnectBanner">
|
||||
|
||||
61
static/ui.js
61
static/ui.js
@@ -1051,6 +1051,12 @@ function dismissUpdate(){
|
||||
async function applyUpdates(){
|
||||
const btn=$('btnApplyUpdate');
|
||||
if(btn){btn.disabled=true;btn.textContent='Updating\u2026';}
|
||||
const errEl=$('updateError');
|
||||
if(errEl){errEl.style.display='none';errEl.textContent='';}
|
||||
// Hide any leftover force-update button from a prior conflict so a fresh
|
||||
// retry starts clean (otherwise stale state points at the wrong target).
|
||||
const forceBtnReset=$('btnForceUpdate');
|
||||
if(forceBtnReset){forceBtnReset.style.display='none';forceBtnReset.dataset.target='';}
|
||||
const targets=[];
|
||||
if(window._updateData?.webui?.behind>0) targets.push('webui');
|
||||
if(window._updateData?.agent?.behind>0) targets.push('agent');
|
||||
@@ -1058,20 +1064,67 @@ async function applyUpdates(){
|
||||
for(const target of targets){
|
||||
const res=await api('/api/updates/apply',{method:'POST',body:JSON.stringify({target})});
|
||||
if(!res.ok){
|
||||
showToast('Update failed ('+target+'): '+(res.message||'unknown error'));
|
||||
_showUpdateError(target,res);
|
||||
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||
return;
|
||||
}
|
||||
}
|
||||
showToast('Updated! Reloading\u2026');
|
||||
showToast('Updated! Restarting\u2026');
|
||||
sessionStorage.removeItem('hermes-update-checked');
|
||||
sessionStorage.removeItem('hermes-update-dismissed');
|
||||
setTimeout(()=>location.reload(),1500);
|
||||
setTimeout(()=>location.reload(),2500);
|
||||
}catch(e){
|
||||
showToast('Update failed: '+e.message);
|
||||
if(errEl){errEl.textContent='Update failed: '+e.message;errEl.style.display='block';}
|
||||
else showToast('Update failed: '+e.message);
|
||||
if(btn){btn.disabled=false;btn.textContent='Update Now';}
|
||||
}
|
||||
}
|
||||
function _showUpdateError(target,res){
|
||||
const errEl=$('updateError');
|
||||
const forceBtn=$('btnForceUpdate');
|
||||
const msg='Update failed ('+target+'): '+(res.message||'unknown error');
|
||||
if(errEl){
|
||||
errEl.textContent=msg;
|
||||
errEl.style.display='block';
|
||||
} else {
|
||||
showToast(msg);
|
||||
}
|
||||
// Show "Force update" button when the error is recoverable by a hard reset
|
||||
if(forceBtn&&(res.conflict||res.diverged)){
|
||||
forceBtn.dataset.target=target;
|
||||
forceBtn.style.display='inline-block';
|
||||
}
|
||||
}
|
||||
async function forceUpdate(btn){
|
||||
const target=btn&&btn.dataset.target;
|
||||
if(!target) return;
|
||||
const confirmed=await showConfirmDialog({
|
||||
title:'Force update '+target+'?',
|
||||
message:'This will discard all local changes in the '+target+' repo and reset to the latest remote version. This cannot be undone.',
|
||||
confirmLabel:'Force update',
|
||||
danger:true,
|
||||
focusCancel:true,
|
||||
});
|
||||
if(!confirmed) return;
|
||||
btn.disabled=true;btn.textContent='Force updating\u2026';
|
||||
const errEl=$('updateError');
|
||||
if(errEl){errEl.style.display='none';}
|
||||
try{
|
||||
const res=await api('/api/updates/force',{method:'POST',body:JSON.stringify({target})});
|
||||
if(!res.ok){
|
||||
if(errEl){errEl.textContent='Force update failed: '+(res.message||'unknown error');errEl.style.display='block';}
|
||||
btn.disabled=false;btn.textContent='Force update';
|
||||
return;
|
||||
}
|
||||
showToast('Force updated! Restarting\u2026');
|
||||
sessionStorage.removeItem('hermes-update-checked');
|
||||
sessionStorage.removeItem('hermes-update-dismissed');
|
||||
setTimeout(()=>location.reload(),2500);
|
||||
}catch(e){
|
||||
if(errEl){errEl.textContent='Force update failed: '+e.message;errEl.style.display='block';}
|
||||
btn.disabled=false;btn.textContent='Force update';
|
||||
}
|
||||
}
|
||||
|
||||
function getPendingSessionMessage(session){
|
||||
const text=String(session?.pending_user_message||'').trim();
|
||||
|
||||
429
tests/test_update_banner_fixes.py
Normal file
429
tests/test_update_banner_fixes.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""Tests for update banner fixes — #813 (conflict recovery) and #814 (restart after update).
|
||||
|
||||
Covers:
|
||||
- conflict error now includes 'conflict: True' flag and actionable git command (#813)
|
||||
- successful update returns 'restart_scheduled: True' (#814)
|
||||
- _schedule_restart() spawns a daemon thread, does not block (#814)
|
||||
- apply_force_update() returns ok on clean reset path (#813)
|
||||
- /api/updates/force route exists in routes.py (#813)
|
||||
- UI: _showUpdateError and forceUpdate functions exist in ui.js (#813)
|
||||
- UI: updateError element and btnForceUpdate element exist in index.html (#813)
|
||||
- UI: success toast says 'Restarting' not 'Reloading' (#814)
|
||||
- UI: reload timeout bumped to 2500 ms to allow server restart (#814)
|
||||
"""
|
||||
|
||||
import pathlib
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import sys
|
||||
import os
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
def read(rel):
|
||||
return (REPO / rel).read_text(encoding='utf-8')
|
||||
|
||||
|
||||
# ── api/updates.py ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestConflictError:
|
||||
"""#813 — conflict error must include flag + recovery command."""
|
||||
|
||||
def test_conflict_returns_conflict_flag(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
|
||||
# Fake a repo with conflict markers in git status output
|
||||
(tmp_path / '.git').mkdir()
|
||||
conflict_status = 'UU some/file.py'
|
||||
|
||||
calls = []
|
||||
def fake_run(args, cwd, timeout=10):
|
||||
calls.append(args)
|
||||
if args[:2] == ['status', '--porcelain']:
|
||||
return conflict_status, True
|
||||
if args[0] == 'fetch':
|
||||
return '', True
|
||||
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
||||
return 'origin/master', True
|
||||
return '', True
|
||||
|
||||
monkeypatch.setattr(upd, '_run_git', fake_run)
|
||||
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
||||
|
||||
result = upd.apply_update('webui')
|
||||
assert result['ok'] is False
|
||||
assert result.get('conflict') is True, "conflict flag must be True"
|
||||
assert 'checkout' in result['message'] or 'pull' in result['message'], (
|
||||
"conflict message must include recovery command"
|
||||
)
|
||||
assert 'merge conflict' in result['message'].lower()
|
||||
|
||||
def test_conflict_message_includes_git_command(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
def fake_run(args, cwd, timeout=10):
|
||||
if args[:2] == ['status', '--porcelain']:
|
||||
return 'AA conflict.txt', True
|
||||
if args[0] == 'fetch':
|
||||
return '', True
|
||||
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
||||
return 'origin/master', True
|
||||
return '', True
|
||||
|
||||
monkeypatch.setattr(upd, '_run_git', fake_run)
|
||||
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
||||
|
||||
result = upd.apply_update('agent')
|
||||
# Message must be actionable — should mention git checkout or pull
|
||||
msg = result['message']
|
||||
assert 'git' in msg.lower(), f"message should mention git: {msg}"
|
||||
|
||||
|
||||
class TestScheduleRestart:
|
||||
"""#814 — _schedule_restart must exist and be non-blocking."""
|
||||
|
||||
def test_schedule_restart_exists(self):
|
||||
from api.updates import _schedule_restart
|
||||
assert callable(_schedule_restart)
|
||||
|
||||
def test_schedule_restart_is_nonblocking(self, monkeypatch):
|
||||
"""_schedule_restart() must return immediately (spawns daemon thread)."""
|
||||
import api.updates as upd
|
||||
|
||||
execv_called = []
|
||||
|
||||
def fake_execv(exe, args):
|
||||
execv_called.append((exe, args))
|
||||
|
||||
# Monkeypatch os.execv inside the module's thread closure
|
||||
import os as _os
|
||||
original_execv = _os.execv
|
||||
|
||||
monkeypatch.setattr(_os, 'execv', fake_execv)
|
||||
|
||||
start = time.monotonic()
|
||||
upd._schedule_restart(delay=0.05)
|
||||
elapsed = time.monotonic() - start
|
||||
|
||||
assert elapsed < 0.5, f"_schedule_restart must return immediately, took {elapsed:.2f}s"
|
||||
# Give the thread time to call execv
|
||||
time.sleep(0.2)
|
||||
assert execv_called, "_schedule_restart must eventually call os.execv"
|
||||
|
||||
|
||||
class TestSuccessfulUpdateReturnsRestartScheduled:
|
||||
"""#814 — successful apply_update must return restart_scheduled: True."""
|
||||
|
||||
def test_apply_update_returns_restart_scheduled(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
def fake_run(args, cwd, timeout=10):
|
||||
if args[0] == 'fetch':
|
||||
return '', True
|
||||
if args[:2] == ['status', '--porcelain']:
|
||||
return '', True # clean tree
|
||||
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
||||
return 'origin/master', True
|
||||
if args[0] == 'pull':
|
||||
return 'Already up to date.', True
|
||||
return '', True
|
||||
|
||||
monkeypatch.setattr(upd, '_run_git', fake_run)
|
||||
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
||||
# Don't actually restart
|
||||
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: None)
|
||||
|
||||
result = upd.apply_update('webui')
|
||||
assert result['ok'] is True
|
||||
assert result.get('restart_scheduled') is True, (
|
||||
"successful update must set restart_scheduled: True"
|
||||
)
|
||||
|
||||
|
||||
class TestApplyForceUpdate:
|
||||
"""#813 — apply_force_update must reset hard and return ok."""
|
||||
|
||||
def test_apply_force_update_ok(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
|
||||
(tmp_path / '.git').mkdir()
|
||||
ran = []
|
||||
|
||||
def fake_run(args, cwd, timeout=10):
|
||||
ran.append(args)
|
||||
if args[0] == 'fetch':
|
||||
return '', True
|
||||
if args[:2] == ['rev-parse', '--abbrev-ref']:
|
||||
return 'origin/master', True
|
||||
if args[0] == 'checkout':
|
||||
return '', True
|
||||
if args[0] == 'reset':
|
||||
return '', True
|
||||
return '', True
|
||||
|
||||
monkeypatch.setattr(upd, '_run_git', fake_run)
|
||||
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
||||
monkeypatch.setattr(upd, '_schedule_restart', lambda delay=2.0: None)
|
||||
|
||||
result = upd.apply_force_update('webui')
|
||||
assert result['ok'] is True
|
||||
assert result.get('restart_scheduled') is True
|
||||
|
||||
git_cmds = [r[0] for r in ran]
|
||||
assert 'reset' in git_cmds, "force update must call git reset --hard"
|
||||
assert 'checkout' in git_cmds, "force update must call git checkout . to clear conflicts"
|
||||
|
||||
def test_apply_force_update_rejects_unknown_target(self, tmp_path, monkeypatch):
|
||||
import api.updates as upd
|
||||
monkeypatch.setattr(upd, 'REPO_ROOT', tmp_path)
|
||||
monkeypatch.setattr(upd, '_AGENT_DIR', tmp_path)
|
||||
result = upd.apply_force_update('invalid')
|
||||
assert result['ok'] is False
|
||||
|
||||
|
||||
# ── api/routes.py ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestForceUpdateRoute:
|
||||
"""#813 — /api/updates/force route must exist in routes.py."""
|
||||
|
||||
def test_force_route_exists(self):
|
||||
src = read('api/routes.py')
|
||||
assert '"/api/updates/force"' in src, (
|
||||
"routes.py must handle POST /api/updates/force"
|
||||
)
|
||||
assert 'apply_force_update' in src, (
|
||||
"routes.py must import and call apply_force_update"
|
||||
)
|
||||
|
||||
|
||||
# ── static/ui.js ──────────────────────────────────────────────────────────────
|
||||
|
||||
class TestUiJsUpdateBanner:
|
||||
"""#813 + #814 — UI must show persistent error, force button, and correct toast."""
|
||||
|
||||
def test_show_update_error_function_exists(self):
|
||||
src = read('static/ui.js')
|
||||
assert 'function _showUpdateError' in src, (
|
||||
"_showUpdateError() must be defined in ui.js"
|
||||
)
|
||||
|
||||
def test_force_update_function_exists(self):
|
||||
src = read('static/ui.js')
|
||||
assert 'function forceUpdate' in src or 'async function forceUpdate' in src, (
|
||||
"forceUpdate() must be defined in ui.js"
|
||||
)
|
||||
|
||||
def test_force_update_uses_confirm_dialog_not_native(self):
|
||||
"""forceUpdate() must use showConfirmDialog(), not the banned native confirm()."""
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'function forceUpdate\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "forceUpdate() not found"
|
||||
fn = m.group(0)
|
||||
assert 'showConfirmDialog' in fn, (
|
||||
"forceUpdate() must use showConfirmDialog() not the native confirm() "
|
||||
"(native confirm is banned by test_sprint33)"
|
||||
)
|
||||
assert 'confirm(' not in fn.replace('showConfirmDialog(', ''), (
|
||||
"forceUpdate() must not use native confirm()"
|
||||
)
|
||||
|
||||
def test_force_update_calls_api_updates_force(self):
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'function forceUpdate\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "forceUpdate() not found"
|
||||
fn = m.group(0)
|
||||
assert '/api/updates/force' in fn, (
|
||||
"forceUpdate() must POST to /api/updates/force"
|
||||
)
|
||||
|
||||
def test_success_toast_says_restarting(self):
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'function applyUpdates\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "applyUpdates() not found"
|
||||
fn = m.group(0)
|
||||
assert 'Restarting' in fn, (
|
||||
"success toast must say 'Restarting' (server self-restarts after update)"
|
||||
)
|
||||
assert 'Reloading' not in fn, (
|
||||
"success toast must not say 'Reloading' — server restarts, page reloads after"
|
||||
)
|
||||
|
||||
def test_reload_timeout_at_least_2000ms(self):
|
||||
"""Reload delay must be >= 2000 ms to give the server time to restart."""
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'function applyUpdates\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "applyUpdates() not found"
|
||||
fn = m.group(0)
|
||||
timeouts = re.findall(r'setTimeout\(.*?(\d+)\)', fn)
|
||||
assert timeouts, "applyUpdates must have a setTimeout for page reload"
|
||||
assert any(int(t) >= 2000 for t in timeouts), (
|
||||
f"reload timeout must be >= 2000 ms to survive server restart; found: {timeouts}"
|
||||
)
|
||||
|
||||
def test_conflict_response_shows_force_button(self):
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'function _showUpdateError\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "_showUpdateError() not found"
|
||||
fn = m.group(0)
|
||||
assert 'conflict' in fn or 'diverged' in fn, (
|
||||
"_showUpdateError must check res.conflict / res.diverged to show force button"
|
||||
)
|
||||
assert 'btnForceUpdate' in fn or 'forceBtn' in fn, (
|
||||
"_showUpdateError must reference the force update button"
|
||||
)
|
||||
|
||||
def test_error_displayed_persistently_not_just_toast(self):
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'function _showUpdateError\b.*?\n\}', src, re.DOTALL)
|
||||
assert m
|
||||
fn = m.group(0)
|
||||
assert 'updateError' in fn, (
|
||||
"_showUpdateError must write to the #updateError element for persistent display"
|
||||
)
|
||||
|
||||
|
||||
# ── static/index.html ─────────────────────────────────────────────────────────
|
||||
|
||||
class TestIndexHtmlBanner:
|
||||
"""#813 — update banner HTML must include error element and force button."""
|
||||
|
||||
def test_update_error_element_exists(self):
|
||||
src = read('static/index.html')
|
||||
assert 'id="updateError"' in src, (
|
||||
"index.html must have #updateError element for persistent error display"
|
||||
)
|
||||
|
||||
def test_force_update_button_exists(self):
|
||||
src = read('static/index.html')
|
||||
assert 'id="btnForceUpdate"' in src, (
|
||||
"index.html must have #btnForceUpdate button (hidden by default)"
|
||||
)
|
||||
|
||||
def test_force_update_button_hidden_by_default(self):
|
||||
src = read('static/index.html')
|
||||
m = re.search(r'id="btnForceUpdate"[^>]*>', src)
|
||||
assert m, "#btnForceUpdate not found"
|
||||
tag = m.group(0)
|
||||
assert 'display:none' in tag, (
|
||||
"#btnForceUpdate must be hidden by default (display:none)"
|
||||
)
|
||||
|
||||
|
||||
# ── Regression: sequential webui+agent update — restart coordination ──────────
|
||||
|
||||
class TestSequentialUpdateRestartCoordination:
|
||||
"""Regression guard for the two-target race: when both webui and agent
|
||||
have updates, the client POSTs them sequentially (webui → agent). The
|
||||
first update's success schedules a restart timer; without coordination
|
||||
that timer fires while the second update's git-pull is still running,
|
||||
killing it mid-stream and leaving the second repo partial.
|
||||
|
||||
Fix: `_schedule_restart` must acquire `_apply_lock` before calling
|
||||
`os.execv`, so a pending second update always completes first.
|
||||
"""
|
||||
|
||||
def test_schedule_restart_waits_for_apply_lock(self, monkeypatch):
|
||||
"""The restart thread must wait for any in-flight update before
|
||||
calling execv. Exercised by holding _apply_lock from another thread
|
||||
and verifying execv is delayed until the lock is released."""
|
||||
import api.updates as upd
|
||||
import threading as _th
|
||||
import time as _t
|
||||
|
||||
execv_called = _th.Event()
|
||||
execv_time = []
|
||||
|
||||
def fake_execv(exe, args):
|
||||
execv_time.append(_t.monotonic())
|
||||
execv_called.set()
|
||||
|
||||
monkeypatch.setattr(os, 'execv', fake_execv)
|
||||
|
||||
# Hold _apply_lock from another thread (simulating an in-flight
|
||||
# second update) for 0.4 s.
|
||||
release_time = []
|
||||
lock_held = _th.Event()
|
||||
|
||||
def holder():
|
||||
with upd._apply_lock:
|
||||
lock_held.set()
|
||||
_t.sleep(0.4)
|
||||
release_time.append(_t.monotonic())
|
||||
|
||||
holder_thread = _th.Thread(target=holder, daemon=True)
|
||||
holder_thread.start()
|
||||
lock_held.wait(timeout=2)
|
||||
|
||||
# Schedule a restart with a short delay. The lock is held;
|
||||
# the restart thread should block on it.
|
||||
upd._schedule_restart(delay=0.05)
|
||||
_t.sleep(0.15)
|
||||
assert not execv_called.is_set(), (
|
||||
"execv called while _apply_lock was still held by another "
|
||||
"thread — restart must wait for in-flight updates to finish"
|
||||
)
|
||||
|
||||
# Let the holder release.
|
||||
holder_thread.join(timeout=2)
|
||||
assert release_time, "holder didn't release the lock"
|
||||
|
||||
# execv should fire shortly after the lock release.
|
||||
assert execv_called.wait(timeout=2), (
|
||||
"execv never fired after _apply_lock was released"
|
||||
)
|
||||
assert execv_time[0] >= release_time[0], (
|
||||
f"execv fired before lock was released "
|
||||
f"(execv={execv_time[0]}, release={release_time[0]})"
|
||||
)
|
||||
|
||||
def test_schedule_restart_still_fires_when_no_update_in_flight(self, monkeypatch):
|
||||
"""Sanity: with nothing holding the lock, restart still fires promptly."""
|
||||
import api.updates as upd
|
||||
import time as _t
|
||||
|
||||
execv_called = []
|
||||
def fake_execv(exe, args):
|
||||
execv_called.append(True)
|
||||
monkeypatch.setattr(os, 'execv', fake_execv)
|
||||
|
||||
upd._schedule_restart(delay=0.05)
|
||||
_t.sleep(0.25)
|
||||
assert execv_called, (
|
||||
"restart must still fire when _apply_lock is free"
|
||||
)
|
||||
|
||||
|
||||
# ── Regression: force button reset on retry ──────────────────────────────────
|
||||
|
||||
class TestForceButtonResetOnRetry:
|
||||
"""#813 UX: if a prior update attempt showed the force button (conflict),
|
||||
the next call to applyUpdates() must reset it — otherwise a subsequent
|
||||
non-conflict error (e.g. network) leaves the stale force button visible
|
||||
pointing at the wrong target."""
|
||||
|
||||
def test_apply_updates_resets_force_button_at_start(self):
|
||||
src = read('static/ui.js')
|
||||
m = re.search(r'async function applyUpdates\b.*?\n\}', src, re.DOTALL)
|
||||
assert m, "applyUpdates() not found"
|
||||
fn = m.group(0)
|
||||
# The reset must appear BEFORE the main update loop, so it runs on
|
||||
# every retry — not only on first invocation.
|
||||
setup, _, rest = fn.partition('const targets=')
|
||||
assert 'btnForceUpdate' in setup, (
|
||||
"applyUpdates must reset btnForceUpdate visibility before "
|
||||
"starting the update loop (stale conflict state otherwise "
|
||||
"persists across retries)"
|
||||
)
|
||||
assert "display='none'" in setup or "display = 'none'" in setup, (
|
||||
"applyUpdates setup must hide btnForceUpdate via display:none"
|
||||
)
|
||||
Reference in New Issue
Block a user