Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ GitHub Releases page; `0.8.0` is the new starting line.

## Unreleased

- **Update notice renders below the input box.** The "Updated → vX. Restart to
apply." line now appears underneath the prompt's bottom border instead of inside
the input area.
- **Silent native updates no longer crash a running session.** The downloaded
build is now staged beside the running executable and swapped in at exit (it goes
live on the next launch), instead of overwriting the live onefile bundle in place.
Overwriting it mid-session corrupted later lazy imports with a
`zlib.error: incorrect header check`. A boundary guard also converts any residual
post-update archive-corruption crash into a clear "restart to apply" message.

## 0.49.0 (2026-06-20)

- **Reduction ladder in the default agent prompt.** The agent now walks an explicit,
Expand Down
48 changes: 48 additions & 0 deletions src/pythinker_code/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,43 @@ def _version_callback(value: bool) -> None:
raise typer.Exit()


def _is_post_update_bundle_corruption(exc: BaseException) -> bool:
"""True when a frozen build's archive read failed mid-session.

This is the signature of a silent self-update having replaced the running
bundle on disk (see ``_install_native_archive``): the old process keeps
running, and the next not-yet-loaded lazy import reads from the now-stale
on-disk archive and raises ``zlib.error: incorrect header check``.

Gated on the PyInstaller ``sys.frozen`` marker so source / pip installs (which
never self-replace this way) are unaffected, and narrowed to the documented
corruption message (``incorrect header check``) so an unrelated decompression
failure elsewhere on a frozen build is *not* misclassified as a stale bundle and
masked behind a restart-only message. The zlib error class is matched by
``__module__``/``__name__`` rather than ``isinstance`` so the handler never
imports ``zlib`` from a possibly-corrupted archive — ``sys`` is a C built-in and
is always safe to import. The cause/context chain is walked with a cycle guard so
a self-referential chain can't spin.
"""
import sys

if not getattr(sys, "frozen", False):
return False
seen: set[int] = set()
current: BaseException | None = exc
while current is not None and id(current) not in seen:
seen.add(id(current))
cls = type(current)
if (
cls.__module__ == "zlib"
and cls.__name__ == "error"
and "incorrect header check" in str(current).lower()
):
return True
current = current.__cause__ or current.__context__
return False


@cli.callback(invoke_without_command=True)
def pythinker(
ctx: typer.Context,
Expand Down Expand Up @@ -1310,6 +1347,17 @@ def _restore_term_and_exit(signum: int, frame: object) -> None:
# ClickException includes the errors Typer knows how to render; don't
# wrap them, or we'd lose the standard error UI and exit codes.
raise
if _is_post_update_bundle_corruption(exc):
# A silent self-update replaced this running build on disk; lazy imports
# from the stale archive now fail. This only resolves with a restart —
# surface a clear, actionable message instead of a fatal traceback.
logger.warning("Post-update bundle corruption detected; prompting restart")
_emit_fatal_error(
"A Pythinker update was installed during this session and the "
"running build was replaced on disk.\n"
"Please restart Pythinker to finish applying the update."
)
raise typer.Exit(code=1) from exc
logger.exception("Fatal error when running CLI")
if debug:
import traceback
Expand Down
2 changes: 1 addition & 1 deletion src/pythinker_code/ui/shell/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2135,7 +2135,7 @@ def _surface_installed_update_notice(self) -> None:
style="fg:ansiyellow",
)
return
# The persistent under-input line (_prepend_update_notice) already renders
# The persistent under-input line (_append_update_notice) already renders
# the restart message; a toast duplicates it on the footer's second row.
self._refresh_update_notice_line()

Expand Down
15 changes: 8 additions & 7 deletions src/pythinker_code/ui/shell/prompt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3915,10 +3915,11 @@ def _append_history_entry(self, text: str) -> None:
error=exc,
)

def _prepend_update_notice(self, fragments: list[tuple[str, str]], columns: int) -> None:
"""Prepend a persistent yellow 'update available' line above the footer
separator, so it renders directly under the prompt input. No-op when no
update is pending; style-agnostic across both toolbar layouts."""
def _append_update_notice(self, fragments: list[tuple[str, str]], columns: int) -> None:
"""Append a persistent yellow 'update available' line *below* the footer
separator, so it renders underneath the prompt input box rather than
inside it. Call this right after the separator rule. No-op when no update
is pending; style-agnostic across both toolbar layouts."""
provider = getattr(self, "_update_notice_provider", None)
if provider is None:
return
Expand All @@ -3930,7 +3931,7 @@ def _prepend_update_notice(self, fragments: list[tuple[str, str]], columns: int)
return
tokens = _get_tui_tokens()
style = f"fg:{tokens.warning or 'ansiyellow'} bold"
fragments[:0] = [(style, line), ("", "\n")]
fragments.extend([(style, line), ("", "\n")])

def _render_bottom_toolbar(self) -> FormattedText:
if (
Expand All @@ -3954,9 +3955,9 @@ def _render_bottom_toolbar(self) -> FormattedText:
fragments: list[tuple[str, str]] = []
tc = get_toolbar_colors()

self._prepend_update_notice(fragments, columns)
fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns)))
fragments.append(("", "\n"))
self._append_update_notice(fragments, columns)

remaining = columns

Expand Down Expand Up @@ -4185,9 +4186,9 @@ def _render_card_bottom_toolbar(self, columns: int) -> FormattedText:
secondary_style = f"fg:{tokens.muted}"

fragments: list[tuple[str, str]] = []
self._prepend_update_notice(fragments, columns)
fragments.append((self._prompt_separator_style(tc.separator), _prompt_rule(columns)))
fragments.append(("", "\n"))
self._append_update_notice(fragments, columns)

try:
ctx = self._build_statusline_context(columns)
Expand Down
69 changes: 63 additions & 6 deletions src/pythinker_code/ui/shell/update.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import asyncio
import atexit
import contextlib
import os
import platform
Expand Down Expand Up @@ -1075,6 +1076,57 @@ def _install_linux_package(asset: Path, package_kind: str) -> UpdateResult:
return UpdateResult.UPDATED if result.returncode == 0 else UpdateResult.FAILED


def staged_native_path() -> Path:
"""Side path next to the running executable where a downloaded native update is
held until it is promoted into place. Deterministic so the orchestrator's smoke
check and the exit-time promotion agree on the same file."""
target = Path(sys.executable).resolve()
return target.with_name(f".{target.name}.staged")


def register_staged_native_promotion() -> None:
"""Arrange for the staged native binary to replace the running executable at
process exit.

Swapping at exit — rather than in place mid-session — is the whole point: the
running onefile build reads its Python archive lazily from ``sys.executable``,
so overwriting that path while the process is alive corrupts later imports
(``zlib.error: incorrect header check``). At exit no further imports happen, so
the swap is safe, and the new binary goes live on the next launch — exactly what
the "Updated → vX. Restart to apply." notice promises.

Registering more than once (e.g. a silent update plus a manual ``/update`` in the
same session) is harmless: the handler no-ops once the staged file has been
promoted, so any duplicate registration just runs a second no-op.
"""
atexit.register(_promote_staged_native_update)


def discard_staged_native_update() -> None:
"""Remove a staged binary that must not be promoted (e.g. it failed its smoke
check). Fail-open: a leftover staged file is only ever promoted deliberately."""
with contextlib.suppress(OSError):
staged_native_path().unlink()


def _promote_staged_native_update() -> None:
# ponytail: known residual windows, both far smaller than the mid-session login
# crash this replaces — (1) a hard kill (SIGKILL) skips atexit, so promotion
# defers to the next clean exit (self-healing); (2) a first-time lazy import
# during the rest of interpreter shutdown, after the swap below, could read
# stale bytes. Closing both needs a detached post-exit swapper (the deferred
# seamless-relaunch design); not built here.
staged = staged_native_path()
if not staged.is_file():
return
target = Path(sys.executable).resolve()
try:
os.replace(staged, target)
except OSError:
# Leave the staged binary in place; a later clean exit retries the swap.
logger.exception("Failed to promote staged native update on exit:")


def _install_native_archive(archive: Path) -> UpdateResult:
target = Path(sys.executable).resolve()
extract_dir = archive.parent / "extract"
Expand All @@ -1091,18 +1143,23 @@ def _install_native_archive(archive: Path) -> UpdateResult:
logger.error("Native archive did not contain a pythinker executable")
return UpdateResult.FAILED

replacement = target.with_name(f".{target.name}.new-{os.getpid()}")
# Stage the new binary beside the running executable instead of overwriting it
# in place. Promotion into `target` happens at process exit (see
# register_staged_native_promotion), keeping the live build's on-disk archive
# intact so mid-session lazy imports never read stale bytes.
staged = staged_native_path()
staging_tmp = target.with_name(f".{target.name}.new-{os.getpid()}")
try:
shutil.copyfile(extracted, replacement)
replacement.chmod(target.stat().st_mode | 0o755)
os.replace(replacement, target)
shutil.copyfile(extracted, staging_tmp)
staging_tmp.chmod(target.stat().st_mode | 0o755)
os.replace(staging_tmp, staged)
(target.parent / ".pythinker-native").write_text(
"pythinker-native-build\n", encoding="utf-8"
)
except OSError:
logger.exception("Failed to replace native executable:")
logger.exception("Failed to stage native executable:")
with contextlib.suppress(OSError):
replacement.unlink()
staging_tmp.unlink()
return UpdateResult.FAILED
return UpdateResult.UPDATED

Expand Down
32 changes: 31 additions & 1 deletion src/pythinker_code/ui/shell/update_orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,8 +382,11 @@ async def run_update_job(
if smoke_ok:
message = smoke_message
_write_last_success(job_id=job_id, message=message)
_finalize_native_staging(promote=True)
else:
message = f"{SMOKE_CHECK_FAILED_PREFIX}{smoke_message}"
# Never promote a staged binary that can't even print --version.
_finalize_native_staging(promote=False)

write_update_status(
_new_status(
Expand Down Expand Up @@ -432,10 +435,37 @@ def _write_last_success(*, job_id: str, message: str) -> None:

def _smoke_check_command() -> list[str]:
if is_native_build():
return [sys.executable, "--version"]
# A native update is staged beside the running exe, not swapped in place,
# so validate the staged binary — checking the still-running old exe would
# prove nothing about the update.
from pythinker_code.ui.shell.update import staged_native_path

staged = staged_native_path()
exe = str(staged) if staged.is_file() else sys.executable
return [exe, "--version"]
return [sys.executable, "-P", "-m", "pythinker_code", "--version"]


def _finalize_native_staging(*, promote: bool) -> None:
"""After the smoke check, either arm the staged native binary for exit-time
promotion (it ran) or discard it (it failed). No-op for non-native installs and
when nothing was staged."""
if not is_native_build():
return
from pythinker_code.ui.shell.update import (
discard_staged_native_update,
register_staged_native_promotion,
staged_native_path,
)

if not staged_native_path().is_file():
return
if promote:
register_staged_native_promotion()
else:
discard_staged_native_update()


def _smoke_check_cwd() -> Path:
try:
return Path(sys.executable).resolve().parent
Expand Down
66 changes: 66 additions & 0 deletions tests/cli/test_post_update_corruption_guard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Guard that turns post-self-update archive corruption into a restart prompt.

A silent native update replaces the running bundle on disk; the next not-yet-loaded
lazy import then reads a stale archive and raises ``zlib.error``. The CLI boundary
must recognize that signature (frozen build + zlib error in the cause chain) and
surface a clear restart message instead of a fatal traceback — without re-importing
anything from the corrupted archive.
"""

import zlib

import pytest

from pythinker_code.cli import _is_post_update_bundle_corruption


@pytest.fixture
def frozen(monkeypatch):
monkeypatch.setattr("sys.frozen", True, raising=False)


def test_detects_direct_zlib_error_on_frozen_build(frozen):
assert _is_post_update_bundle_corruption(zlib.error("incorrect header check")) is True


def test_detects_zlib_error_in_cause_chain(frozen):
try:
try:
raise zlib.error("incorrect header check")
except zlib.error as inner:
raise ImportError("cannot load module") from inner
except ImportError as exc:
assert _is_post_update_bundle_corruption(exc) is True


def test_detects_zlib_error_in_implicit_context(frozen):
try:
try:
raise zlib.error("incorrect header check")
except zlib.error:
raise RuntimeError("boom") # noqa: B904 - implicit __context__ is the point
except RuntimeError as exc:
assert _is_post_update_bundle_corruption(exc) is True


def test_ignores_zlib_error_when_not_frozen(monkeypatch):
monkeypatch.delattr("sys.frozen", raising=False)
assert _is_post_update_bundle_corruption(zlib.error("incorrect header check")) is False


def test_ignores_unrelated_error_on_frozen_build(frozen):
assert _is_post_update_bundle_corruption(ValueError("nope")) is False


def test_ignores_zlib_error_with_unrelated_message(frozen):
# A decompression failure that is NOT the bundle-corruption signature (e.g. a
# bad gzip HTTP response) must not be masked behind a restart-only message.
assert _is_post_update_bundle_corruption(zlib.error("invalid distance too far back")) is False


def test_handles_cyclic_cause_chain(frozen):
a = RuntimeError("a")
b = RuntimeError("b")
a.__cause__ = b
b.__cause__ = a # cycle: must terminate, not spin
assert _is_post_update_bundle_corruption(a) is False
Loading
Loading