Release v0.51.312 — Release KB (brick-wave: purge stale __pycache__ after self-update, fixes #3774) (#3778)
Some checks failed
Release & Docker / release (push) Has been cancelled
Some checks failed
Release & Docker / release (push) Has been cancelled
Fix-ourselves pickup of #3774 (@bambalados). _purge_agent_pycache() before os.execv() in _schedule_restart() so the re-exec'd process recompiles freshly-pulled source — fixes AttributeError on first chat after self-update. Full suite 8180 passed, Codex SAFE, Opus SHIP-IT. Co-authored-by: bambalados <bambalados@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,11 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.312] — 2026-06-07 — Release KB (brick-wave — purge stale bytecode after self-update)
|
||||
|
||||
### Fixed
|
||||
- **Self-update no longer fails with an `AttributeError` on the first chat after restarting.** `POST /api/updates/apply` runs `git pull --ff-only` then `os.execv()` to restart with fresh code, but `os.execv()` replaces the process image without touching the on-disk `__pycache__/` bytecode cache. When a pull writes new `.py` files whose mtime lands within the same second as the pre-existing `.pyc` files, CPython can trust the stale cache and serve an old class definition — so a method added in the same update appears missing and the next chat raises `AttributeError`. The restart path now deletes all `__pycache__/` directories under the agent and WebUI repos right before `os.execv()`, forcing clean recompilation on the next startup. (#3774, @bambalados)
|
||||
|
||||
## [v0.51.311] — 2026-06-07 — Release KA (brick-wave — workspace Git RCE hardening + stale-snapshot sidebar visibility)
|
||||
|
||||
### Security
|
||||
|
||||
@@ -13,6 +13,7 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
@@ -1025,6 +1026,34 @@ def summarize_update_payload(updates: dict, llm_callback=None, *, target: str |
|
||||
# ── Self-update application ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _purge_agent_pycache(repo_dir: Path) -> None:
|
||||
"""Delete all __pycache__ dirs under *repo_dir* so the next import
|
||||
recompiles from source, avoiding stale-bytecode errors after git pull.
|
||||
|
||||
``os.execv()`` replaces the process image but does not touch the
|
||||
on-disk bytecode cache. When a ``git pull`` writes new ``.py`` files
|
||||
whose mtime lands within the same second as the pre-existing ``.pyc``
|
||||
files, CPython may trust the stale cache and serve an old class
|
||||
definition. The mismatch between cached class symbols and newly-imported
|
||||
supporting modules causes ``AttributeError`` (e.g. a method added in
|
||||
the same update is missing from the cached ``AIAgent`` class).
|
||||
|
||||
This is safe to call right before ``os.execv()`` because the current
|
||||
process is about to be replaced — losing the bytecode cache is harmless
|
||||
and forces a clean recompilation on the next startup.
|
||||
"""
|
||||
if repo_dir is None or not repo_dir.exists():
|
||||
return
|
||||
try:
|
||||
for pycache in repo_dir.rglob("__pycache__"):
|
||||
try:
|
||||
shutil.rmtree(pycache, ignore_errors=True)
|
||||
except OSError:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _schedule_restart(delay: float = 2.0) -> None:
|
||||
"""Re-exec this process after *delay* seconds.
|
||||
|
||||
@@ -1061,6 +1090,14 @@ def _schedule_restart(delay: float = 2.0) -> None:
|
||||
# released atomically by the kernel.
|
||||
with _apply_lock:
|
||||
_wait_until_restart_safe()
|
||||
# Purge bytecode caches so the new process imports from
|
||||
# current source. Without this, Python may serve stale .pyc
|
||||
# files whose mtime matches the just-pulled .py files,
|
||||
# causing AttributeError when new methods are missing from
|
||||
# cached class definitions.
|
||||
if _AGENT_DIR is not None:
|
||||
_purge_agent_pycache(Path(_AGENT_DIR))
|
||||
_purge_agent_pycache(REPO_ROOT)
|
||||
try:
|
||||
# Re-exec into the just-pulled image.
|
||||
#
|
||||
|
||||
48
tests/test_pycache_purge.py
Normal file
48
tests/test_pycache_purge.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Test that __pycache__ purge runs before restart."""
|
||||
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||
|
||||
|
||||
class TestPyCachePurge:
|
||||
def test_purge_removes_pycache_dirs(self):
|
||||
"""_purge_agent_pycache should remove __pycache__ directories."""
|
||||
from api.updates import _purge_agent_pycache
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
# Create nested __pycache__ dirs with .pyc files
|
||||
cache1 = root / "sub" / "__pycache__"
|
||||
cache1.mkdir(parents=True)
|
||||
(cache1 / "mod.cpython-311.pyc").write_text("# stale")
|
||||
|
||||
cache2 = root / "__pycache__"
|
||||
cache2.mkdir(parents=True)
|
||||
(cache2 / "other.cpython-311.pyc").write_text("# stale")
|
||||
|
||||
# Also a non-pycache dir that should survive
|
||||
keep = root / "keep"
|
||||
keep.mkdir()
|
||||
(keep / "data.txt").write_text("keep me")
|
||||
|
||||
_purge_agent_pycache(root)
|
||||
|
||||
assert not cache1.exists(), "nested __pycache__ should be removed"
|
||||
assert not cache2.exists(), "root __pycache__ should be removed"
|
||||
assert keep.exists(), "non-pycache dirs should survive"
|
||||
assert (keep / "data.txt").read_text() == "keep me"
|
||||
|
||||
def test_purge_none_dir(self):
|
||||
"""_purge_agent_pycache should handle None without error."""
|
||||
from api.updates import _purge_agent_pycache
|
||||
|
||||
_purge_agent_pycache(None) # should not raise
|
||||
|
||||
def test_purge_missing_dir(self):
|
||||
"""_purge_agent_pycache should handle nonexistent dirs."""
|
||||
from api.updates import _purge_agent_pycache
|
||||
|
||||
_purge_agent_pycache(Path("/nonexistent/path/12345")) # should not raise
|
||||
@@ -23,6 +23,8 @@ import json
|
||||
import subprocess
|
||||
import types
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = pathlib.Path(__file__).parent.parent
|
||||
|
||||
|
||||
@@ -59,6 +61,26 @@ def extract_js_function(src: str, name: str) -> str:
|
||||
return src[match.start():end]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _stub_pycache_purge(monkeypatch):
|
||||
"""No-op the __pycache__ purge for the update/restart tests in this module.
|
||||
|
||||
_schedule_restart() purges __pycache__ before os.execv() (#3774) so the
|
||||
re-exec'd process recompiles freshly-pulled source. The real purge walks
|
||||
REPO_ROOT + _AGENT_DIR on disk — slow (~0.4 s on the agent repo's ~17k
|
||||
files) and destructive — which blows these tests' tight restart-timing
|
||||
budgets and, worse, can delay the daemon thread past monkeypatch teardown
|
||||
so it fires the REAL os.execv and corrupts the pytest worker. These tests
|
||||
exercise restart coordination/locking, not the purge (which has dedicated
|
||||
coverage in test_pycache_purge.py), so stub it to a no-op. The wiring
|
||||
(purge happens before execv) is pinned by
|
||||
test_schedule_restart_purges_pycache_before_execv, which re-patches with a
|
||||
recording spy.
|
||||
"""
|
||||
import api.updates as upd
|
||||
monkeypatch.setattr(upd, "_purge_agent_pycache", lambda *a, **k: None)
|
||||
|
||||
|
||||
# ── api/updates.py ────────────────────────────────────────────────────────────
|
||||
|
||||
class TestUpdateChecker:
|
||||
@@ -398,6 +420,39 @@ class TestScheduleRestart:
|
||||
time.sleep(0.2)
|
||||
assert execv_called, "_schedule_restart must eventually call os.execv"
|
||||
|
||||
def test_schedule_restart_purges_pycache_before_execv(self, monkeypatch):
|
||||
"""The restart thread must purge __pycache__ before re-exec (#3774).
|
||||
|
||||
Pins the fix wiring: os.execv() replaces the process image without
|
||||
touching on-disk .pyc files, so stale bytecode could otherwise serve
|
||||
an old class definition after a self-update. Records the call order of
|
||||
_purge_agent_pycache vs os.execv and asserts the purge runs first.
|
||||
"""
|
||||
import api.updates as upd
|
||||
|
||||
events = []
|
||||
|
||||
def spy_purge(repo_dir):
|
||||
events.append(("purge", repo_dir))
|
||||
|
||||
def fake_execv(exe, args):
|
||||
events.append(("execv", exe))
|
||||
|
||||
# Override the autouse no-op stub with a recording spy.
|
||||
monkeypatch.setattr(upd, "_purge_agent_pycache", spy_purge)
|
||||
monkeypatch.setattr(os, "execv", fake_execv)
|
||||
|
||||
upd._schedule_restart(delay=0.05)
|
||||
time.sleep(0.3)
|
||||
|
||||
kinds = [kind for kind, _ in events]
|
||||
assert "purge" in kinds, "_schedule_restart must purge __pycache__"
|
||||
assert "execv" in kinds, "_schedule_restart must call os.execv"
|
||||
assert kinds.index("purge") < kinds.index("execv"), (
|
||||
"__pycache__ purge must happen BEFORE os.execv so the re-exec'd "
|
||||
"process recompiles from fresh source"
|
||||
)
|
||||
|
||||
|
||||
class TestApplyUpdateRestartSafety:
|
||||
"""Self-update must not re-exec while chat streams are active."""
|
||||
|
||||
Reference in New Issue
Block a user