Skip to content

fix(update): defer the Windows self-update out of the running environment (#528) - #543

Merged
padak merged 7 commits into
mainfrom
claude/windows-update-issue-528-0bbc97
Aug 2, 2026
Merged

fix(update): defer the Windows self-update out of the running environment (#528)#543
padak merged 7 commits into
mainfrom
claude/windows-update-issue-528-0bbc97

Conversation

@padak

@padak padak commented Jul 29, 2026

Copy link
Copy Markdown
Member

Fixes #528.

Why v0.76.2 did not fix this

uv tool install recreates a tool environment in place. From
crates/uv-tool/src/lib.rs,
create_environment removes any existing environment and then calls create_venv
at the same path. No temp directory, no atomic swap, no rollback.

On POSIX that is safe by accident of filesystem semantics: unlinking a file another
process holds open leaves that process's inode intact, so a running kbagent survives
having its venv deleted and rebuilt underneath it. That is why we never saw this.

On Windows it cannot work. uv's kbagent.exe trampoline loads the tool venv's
interpreter in-process, so those files are locked for as long as kbagent runs. The
removal deletes every file it can, reaches a locked one, and aborts. What is left is
not a mixture of old and new distributions — it is a partially deleted venv,
exactly matching the reported symptoms: rich present but rich/_windows.py gone,
typer present but typer/rich_utils.py gone. Upstream tracks the same class of
failure as astral-sh/uv#11930.

v0.76.2 fixed a real ordering bug (the missing certifi/cacert.pem in incident 2)
but still ran the installer from inside the environment it replaces — so the
corruption survived it and reproduced on 0.76.2 → 0.76.3.

Ordering was never the mechanism. Running the installer from inside the target
environment is unsafe on Windows by construction, with any combination of uv flags.

Second, independent corruption vector

Both paths ran the installer through subprocess.run(..., timeout=...), which
kills the child on expiry — on Windows a hard TerminateProcess of uv part-way
through recreating a venv. That produces the identical half-deleted environment,
from our own code, on every platform. Default deadline was 300s; a cold resolution
of the [server] extra on a Windows box with real-time AV scanning is not reliably
under that.

What this changes

1. Windows installs after we are gone. The prepared install command is handed to
a detached PowerShell helper that waits twice — Wait-Process -Id <pid> for the
scheduling process (which may not be named kbagent, e.g. python -m keboola_agent_cli), then a Get-Process -Name kbagent loop for every other
kbagent holding the environment open — and only then installs. If kbagent never
exits within the window it installs nothing and says so; doing nothing is always
safe here, a partial install never is.

Windows PowerShell 5.1 is an in-box OS component on every supported Win10/11 edition
and cannot be uninstalled, and ExecutionPolicy governs script files, not
-Command strings, so no policy can block it. If no interpreter is found, scheduling
fails and the user gets the exact command — never a fallback to the in-place
install.

A single-flight guard stops several shells opened in a row from each spawning a
helper and racing each other into the very corruption this prevents.

2. The installer is never killed. The deadline now bounds only how long kbagent
waits; uv is left to finish the transaction it started. The banner says the install
is still running and deliberately offers no recovery command — a second installer
aimed at an environment a live uv is rewriting is the corruption, not the cure.

This covers the keboola-mcp-server upgrade too. That environment is not the one
kbagent runs from, so no file lock is involved — but it sat two functions away on the
same subprocess.run(timeout=...), and a killed installer leaves it half-recreated
just the same; what stops working there is kbagent tool call. Read-only probes
(keboola_mcp_server --version, uv tool list) keep their ordinary timeouts, because
killing a probe is harmless.

3. The outcome is reported once, by the next launch. The process that schedules
an update exits before it runs, so it can never report it.
report_finished_deferred_update() runs before the skip gates — the user is owed
the result even on a run that will not update anything (dev install, opt-out,
kbagent version). Outcomes are distinguished so they read correctly:

Outcome Meaning Recovery offered
SUCCEEDED installed (also prints "What's new") no
ABANDONED kbagent never exited; nothing installed no — nothing to recover
FAILED non-zero exit, or an unreadable result yes
LOST helper never reported, marker went stale yes

An unparseable result counts as a failure: that is precisely when the recovery
command matters, so it must not be optimistic.

4. POSIX is untouched. should_defer() is False there, so the inline install
plus os.execvpe re-exec stays exactly as it is — execve genuinely replaces the
process image and unlinking open files is safe. The majority path keeps its instant
upgrade with no new failure mode. KBAGENT_DEFER_UPDATE=1|0 overrides the platform
default.

User-visible change on Windows

The new version becomes active on the next launch, not the current one.
kbagent update --json marks this with kbagent.deferred: true and the summary reads
(scheduled) — which is not a failure and is not rendered as one.

Testing

make check green: 4730 passed. New tests/test_update_runner.py (36 tests) plus
extensions to the two existing suites.

Verifiable on POSIX CI, and covered:

  • run_install leaves a slow child alive — a real subprocess writing a sentinel
    after the wait expires. This is the one behaviour directly observable everywhere,
    and it is the regression test for vector 2.
  • The waiter script's exact contract: PID wait, process-name loop, give-up branch
    ordering, the quoted install argv, exit-code recording.
  • Scheduling: marker contents, detached spawn flags, single-flight, no-helper
    refusal, spawn-failure cleanup.
  • Report lifecycle for every outcome, reported exactly once.
  • kbagent update on the deferred platform never reaches run_install (the test
    raises if it does).
  • POSIX still installs inline and re-execs.

Also exercised live on macOS end to end: with no PowerShell present the update
refuses safely and prints the command; with a shim on PATH the full
schedule → detached spawn → exit file → report-once lifecycle runs against the real
filesystem.

Verified on real Windows. The waiter is PowerShell authored on machines that
cannot execute it, so text assertions alone would not be enough. The repo already
runs a windows-latest job for the #320/#529 regressions; this PR extends it to run
the update-runner suite there — which also exercises should_defer() returning its
real Windows default and a real detached spawn — plus three Windows-only tests that
execute the generated script against a stand-in installer:

  • the installer's exit code is recorded (proves the script parses, & <argv> *>> $log
    works, and $LASTEXITCODE survives redirection),
  • a failing installer is reported rather than swallowed,
  • nothing is installed while a watched process is still alive — the branch that
    actually protects the environment, asserted by a sentinel file the installer would
    have written.

That job immediately earned its keep: it caught the helper writing its log as
UTF-16LE. Windows PowerShell 5.1 writes redirection operators (>, >>, *>>)
as UTF-16LE with a BOM, but every other writer and reader of that shared log —
run_install, _tail, and the user we point at it — assumes UTF-8. The output now
goes through [System.IO.File]::AppendAllText with UTF8Encoding($false), and the
Windows test asserts the log carries no UTF-16 BOM so it cannot regress silently.
Notably the failing-installer test passed while only the log assertion failed — a
test that checked just the exit code would have shipped the bug.

What remains unverifiable without a Windows developer machine is the original
corruption itself: a real uv tool install losing a race against a real file lock on
a real live tool environment. Before release, on Windows 11 with
uv tool install "keboola-cli[server] @ <previous release wheel>" — run
kbagent update, confirm (scheduled) and that the next launch reports success;
repeat through the startup hook; and with kbagent serve left running confirm the
update reports skipped with the environment intact.

Known limits

  • The helper is a detached PowerShell process; enterprise EDR may flag or block it.
    The failure mode is benign (scheduling fails, user is told the command).
  • Get-Process -Name kbagent does not see a kbagent running as
    python -m keboola_agent_cli; the PID wait covers the scheduling process, but not
    another such process running concurrently.
  • The standalone PyInstaller distribution (Chocolatey / cli-dist) has no
    sys.frozen guard anywhere in src/, so a frozen binary would still plan a
    uv tool install. Out of scope here — filed separately.

Design notes: docs/superpowers/specs/2026-07-29-issue-528-deferred-windows-self-update-design.md.

padak added 3 commits July 29, 2026 18:30
…ment (#528)

`uv tool install` recreates a tool environment by removing it and then
building a fresh venv at the same path (`uv-tool/src/lib.rs`:
create_environment -> "Remove any existing environment" -> create_venv).
It is not atomic and has no rollback.

On POSIX that is harmless -- unlinking a file another process holds open
leaves that process's inode intact. On Windows uv's `kbagent.exe`
trampoline loads the venv interpreter in-process, so those files are
locked: the removal deletes what it can, hits a locked file, and aborts,
leaving a gutted venv. That is the reported failure -- `rich` present but
`rich/_windows.py` gone, `typer` present but `typer/rich_utils.py` gone.
Upstream: astral-sh/uv#11930.

The v0.76.2 fix reordered discovery ahead of mutation, which was a real
ordering bug, but the installer still ran from inside the environment it
replaces -- so the corruption survived it and reproduced on 0.76.2 ->
0.76.3.

Windows now hands the reinstall to a detached PowerShell helper that
waits for every kbagent process to exit and only then installs; the
outcome is reported once by the next launch, with a recovery command on
failure. When no helper can be spawned the user gets the exact command --
never a fallback to the unsafe inline install. POSIX keeps the inline
install plus re-exec unchanged.

Also removes a second, independent corruption vector: both paths ran the
installer through `subprocess.run(timeout=...)`, which kills the child on
expiry -- on Windows a hard TerminateProcess of uv mid-write, producing
the same half-deleted environment from our own code. The deadline now
bounds only how long kbagent waits.

`KBAGENT_DEFER_UPDATE=1|0` overrides the platform default.
…528)

The waiter is a PowerShell script authored on machines that cannot execute
it, so the unit tests can only pin its text. The repo already runs a
windows-latest job for the #320/#529 regressions; extend it to run the
update-runner suite, plus three Windows-only tests that execute the
generated script against a stand-in installer:

- the installer's exit code is recorded,
- a failing installer is reported rather than swallowed,
- nothing is installed while a watched process is still alive -- the
  branch that actually protects the environment.

This also exercises `should_defer()` returning its real Windows default
and a real detached spawn.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Open in Devin Review

Comment thread src/keboola_agent_cli/update_runner.py Outdated
Comment thread src/keboola_agent_cli/update_runner.py
padak added 4 commits July 29, 2026 18:43
Caught by the Windows CI job added in the previous commit -- the only
place the helper actually executes.

Windows PowerShell 5.1 writes redirection operators (`>`, `>>`, `*>>`) as
UTF-16LE with a BOM. The helper redirected the installer's output into
the shared install log with `*>>`, but every other writer and reader of
that file -- `run_install`, `_tail`, and the user we point at it --
assumes UTF-8. The log came out as mojibake.

The installer's output now goes through
`[System.IO.File]::AppendAllText` with `UTF8Encoding($false)`, so the log
is plain UTF-8 with no BOM regardless of writer. `$LASTEXITCODE` is
captured into `$code` immediately after the installer, before anything
else can run.

The Windows test now asserts the log is UTF-8 and carries no UTF-16 BOM,
so this cannot regress silently.
…ported (#528)

_classify_exit took `marker_is_stale`, but the caller passed
`raw_exit is None` -- which is also true when the exit file exists and
merely could not be read. That case was labelled LOST ("the helper
vanished") when what actually happened is that the helper reported
something unusable, i.e. a FAILED install.

The predicate is now `helper_reported`, derived from whether an exit file
existed at all, so each status means what it says. Both already offered
recovery, so this changes wording rather than behaviour -- but the
parameter was lying about its own meaning.
The no-kill fix covered the two kbagent paths but left the MCP upgrade
two functions away still on `subprocess.run(timeout=...)`, which
terminates uv at 180s. The MCP environment is not the one kbagent runs
from, so no file lock is involved -- but a killed installer leaves it
half-recreated exactly the same way, and what stops working is
`kbagent tool call`.

`_perform_mcp_update` now goes through `run_install`, so the deadline
bounds only how long kbagent waits. A slow upgrade reports "still
running" instead of the untrue "timed out".

Read-only probes (`keboola_mcp_server --version`, `uv tool list`) keep
their ordinary timeouts -- killing a probe is harmless.
… a failure (#528)

Four review findings, all cases of a message not matching what happened.

1. `run_install` appended to one shared log and reported `_tail` of the
   whole file, so a run's `output` carried text it did not produce. This
   regressed the pre-change `capture_output=True` per-run capture, and
   routing the MCP upgrade through the same runner made it reachable
   within a single `kbagent update`: the MCP stage writes seconds before
   the kbagent stage reads. The offset the run starts at is now recorded
   before spawning, and only bytes past it are read. The log itself still
   keeps every run -- that is the diagnosis -- but is rolled once it
   passes a size cap, since every update appends to it forever.

2. A STILL_RUNNING install fell through to `_compose_update_summary`'s
   failure branch and rendered as "kbagent v0.77.0 update FAILED: Update
   still running ...", contradicting itself mid-sentence and the banner
   `auto_update` already got right. It now carries an explicit
   `still_running` flag and reads "(still installing)".

3. The extra human-mode stage message fired on the unschedulable branch
   too, whose message the summary already quotes verbatim -- the user was
   told the same thing twice. Now gated to the scheduled case, which is
   the only one the summary truncates to "(scheduled)".

4. `_classify_exit` returned a bare two-element tuple of semantically
   distinct values, which CONTRIBUTING.md forbids for new code. It
   returns a frozen `ClassifiedExit` instead.

Also strengthens the no-kill test to assert the log is readable while the
child still holds it open and the parent has closed its handle -- the
Windows file-sharing question raised in review, now answered by the
Windows CI job rather than by argument.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Self-update (auto-update on startup and kbagent update) can corrupt the uv tool venv on Windows — non-atomic in-place upgrade of the running venv

1 participant