Skip to content

[BUG] Pip updates can create invalid dist-info / missing RECORD through scope changes and concurrent dependency writes #5488

Closed

Description

Please confirm these before moving forward

  • Searched related issues; references and differences are listed below.
  • Observed on the latest stable release, UniGetUI 2026.3.0, verified against GitHub Releases on 2026-10-08.
  • Checked the repository FAQ and reproduced using the underlying manager commands.
  • Reporting unexpected update behavior.

UniGetUI Version

2026.3.0 (file version 2026.3.0.0), installed under the current user's Local Programs directory. Parallel operation count: 3.

Windows version, edition, and architecture

Windows 11 Pro x64, build 26300 (10.0.26300). Python 3.14.7, Pip 26.2.1. Python executable has a valid Python Software Foundation Authenticode signature.

Describe your issue

Updating related Pip packages through UniGetUI repeatedly leaves inconsistent package metadata. An update may report success while an old .dist-info directory remains; the following update fails with Cannot uninstall botocore None / uninstall-no-record-file.

This report includes the creation path from a verified healthy baseline, the actual UniGetUI command chronology, a sequential control, and a clean standalone reproduction. The Pip installer itself remains byte-identical to its official PyPI wheel before and after the controlled failure.

Two observable orchestration problems are involved:

  1. Independent updates of boto3 and botocore can overlap even though updating boto3 also writes its botocore dependency in the same interpreter environment.
  2. A failed local update can be retried with --user, creating a second installation scheme instead of completing an update in the original scheme. Subsequent default-scope commands can overwrite one copy while selecting metadata from another.

In a clean offline CLI reproduction, this second pattern causes a retained REQUESTED file to become untracked when a dependency installation overwrites the same-version metadata. The next uninstall leaves a REQUESTED-only old directory, and the next dependent update fails due to its missing RECORD. The tested sequence consists entirely of stock Pip CLI operations; distribution metadata is changed by Pip, not manually edited by the experiment.

Actual UniGetUI operation chronology

Times below are the local times recorded by UniGetUI, Europe/Kiev, UTC+03:00 on these dates. Personal path prefixes are replaced with placeholders.

Date Operation Start End Result
2026-10-02 pip install --upgrade botocore ... 16:13:02 16:14:05 Exit 1, missing temporary INSTALLER file, uninstall rollback
2026-10-02 pip install --upgrade boto3 ... 16:13:10 16:14:01 Exit 1, WinError 32 on botocore/signers.py
2026-10-02 automatic boto3 retry with --user 16:14:01 16:14:54 Exit 0, user-site copies of boto3/botocore installed
2026-10-08 pip install --upgrade botocore ... 10:36:26 10:36:43 Exit 0; downloaded 1.43.109 but summary said 1.43.108
2026-10-08 pip install --upgrade boto3 ... 10:39:39 10:39:44 Exit 1, Cannot uninstall botocore None, no RECORD

All these commands used the same Python314 executable. The actual local operation flags were --no-input --no-color --no-cache.

Package Managers Logs

2026-10-02, overlapping boto3 update:

Arguments: -m pip install --upgrade boto3 --no-input --no-color --no-cache
Start Time: 02.10.2026 16:13:10
Attempting uninstall: botocore
ERROR: Could not install packages due to an OSError: [WinError 32]
The process cannot access the file because it is being used by another process:
'<BASE_SITE>\\botocore\\signers.py'
Consider using the `--user` option or check the permissions.
End Time: 02.10.2026 16:14:01

Arguments: -m pip install --upgrade boto3 --no-input --no-color --no-cache --user
Start Time: 02.10.2026 16:14:01
Successfully installed boto3-1.43.107 botocore-1.43.107
End Time: 02.10.2026 16:14:54

The overlapping botocore operation rolled back its uninstall:

Start Time: 02.10.2026 16:13:02
Successfully uninstalled botocore-1.43.106
Rolling back uninstall of botocore
ERROR: Could not install packages due to an OSError: [Errno 2]
No such file or directory:
'<BASE_SITE>\\botocore-1.43.107.dist-info\\INSTALLERbb82ghik.tmp'
End Time: 02.10.2026 16:14:05

2026-10-08, the next dependent update:

Version: 1.43.108 -> 1.43.109
Arguments: -m pip install --upgrade boto3 --no-input --no-color --no-cache
Installing collected packages: botocore, boto3
Attempting uninstall: botocore
Found existing installation: botocore None
error: uninstall-no-record-file
Cannot uninstall botocore None
The package's contents are unknown: no RECORD file was found for botocore.
Process return value: 1

The retained original botocore-1.43.108.dist-info directory contained only the empty REQUESTED file. Its SHA-256 is e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855. METADATA and RECORD were absent. Actual imported botocore code was 1.43.109 while importlib.metadata.version('botocore') returned None.

UniGetUI Log / configuration evidence

Persisted operation history contains both attempts of the automatic retry in the same operation record, with the process arguments and start/end times above. Read-only CLI log app and log manager --manager pip --verbose were also collected. No updater settings were changed during evidence collection.

{
  "unigetui_version": "2026.3.0",
  "unigetui_file_version": "2026.3.0.0",
  "parallel_operation_count": "3",
  "python_authenticode_status": "Valid",
  "operation_logs_exported": 74
}

Clean experiments and installer integrity

Experiment Checked initial state Result
Installed Pip vs official PyPI wheel 450 wheel files compared; 480 installed RECORD hashes checked Zero mismatches/missing files
Sequential control Fresh venv, official Pip, healthy core/client 0.1.0, pip check exit 0 Separate sequential upgrades to 0.2.0 and 0.3.0 passed; code, metadata, all declared file hashes and pip check remained valid
Mixed-scheme normal CLI Clean copied Windows runtime, original site-packages excluded, private PYTHONUSERBASE; official Pip; every initial distribution had METADATA/RECORD and valid hashes; pip check exit 0 Reproduced code/metadata disagreement, untracked REQUESTED, REQUESTED-only old directory, then missing-RECORD failure
Concurrent shared-dependency CLI Three separate fresh venvs; same official Pip and valid wheel fixtures; healthy initial records 3/3 client update failures; WinError 2, 183, 2; core reached 0.2.0 but client stayed 0.1.0; declared file hashes remained valid
Standalone minimal script Independent temporary runtime and tiny synthetic wheels; single-scheme sequential control included Reproduced Cannot uninstall unigetui-probe-core None; Pip's 450 wheel files remained identical afterwards
Scheme-preserving minimal control The same two-valid-scheme baseline; explicit --user core updates instead of changing the target Both upgrade cycles remained valid; code/metadata/RECORD hashes and pip check passed

Official Pip artifact: pip-26.2.1-py3-none-any.whl, SHA-256 71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e, obtained and checked against https://pypi.org/pypi/pip/26.2.1/json.

The controlled tests use real Pip commands, generated standard wheels, and no network during package operations. No real AWS package, credential, custom index, or third-party fixture dependency is involved.

Steps to reproduce the issue

A. Shared-dependency overlap

  1. Use a fresh Python environment with official Pip and two valid packages where the client update requires an update of the core dependency.
  2. Start python -m pip install --upgrade core and python -m pip install --upgrade client concurrently against that environment.
  3. Compare with running exactly the same updates sequentially.
  4. On Windows the overlapping uninstall/install of core fails in the tested runs, while the sequential control stays healthy. This corresponds to the actual UniGetUI boto3/botocore chronology above.

B. Scheme switch and REQUESTED-only orphan

  1. Create a clean interpreter base-site and private user-site; install fully valid core 0.1.0 in both, and client 0.1.0 in base-site. Verify METADATA, RECORD, hashes and pip check.
  2. Execute the default-scope core update to 0.2.0 without preserving the discovered user-site scheme. Pip selects the user copy to uninstall but writes the update into base-site. The old base-site metadata is still present.
  3. Upgrade client to 0.2.0. Its dependency handling installs core 0.2.0 over the base-site copy and rewrites RECORD, retaining the pre-existing REQUESTED marker without declaring it.
  4. Upgrade core to 0.3.0. Pip removes the files it declares and leaves the untracked REQUESTED-only old directory.
  5. Upgrade client to 0.3.0. It fails to uninstall core None due to the missing RECORD.

The standalone Windows script below builds its fixtures, validates the official installer, runs the passing single-scheme control, and reproduces B without changing the installed Python environment. A valid --user --ignore-installed install is used only to seed the two-copy fixture baseline; the production UniGetUI retry was ordinary --user.

Expected behavior

  • Serialize Pip operations that write to the same interpreter environment, including dependency writes shared by independently queued packages.
  • Preserve the actual discovered interpreter and installation scheme when updating a distribution. Changing to --user changes the target, so it needs explicit scope-aware handling rather than a generic retry based on output text.
  • Refresh the targeted distribution's metadata after an update and surface duplicates/missing METADATA/RECORD as a concrete installation-integrity condition.

Relevant release source:

Related issues and scope of the evidence

Pip's overlay/uninstall behavior contributes to the mechanism; this is not a claim that the missing RECORD by itself proves a UniGetUI-only defect. The orchestration evidence is the GUI's actual overlapping commands and scope-changing retry. The clean experiments replay those CLI patterns; they do not drive a fresh GUI through the whole matrix. The exact historical RECORD contents of every replaced version are unavailable, so the mechanism is established by the clean replay rather than asserted for every past leftover.

The live environment had unrelated sympy/mpmath and pipx/filelock conflicts, recorded before and after the focused repair. The clean experiments began with no such packages and pip check exit 0.

Standalone reproduction

Save the following as minimal-repro.py and run python minimal-repro.py on Windows with Python 3.14. It downloads the SHA-pinned official Pip wheel; all fixture installations then run offline. Exit 0 means both the passing control and expected failing update were observed.

Verified standalone Windows script (click to expand)
"""Windows-only standalone CLI reproduction; uses no existing package environment.

Run: python minimal-repro.py [--pip-wheel pip-26.2.1-py3-none-any.whl]
Without --pip-wheel, download the official pip wheel and verify its published SHA.
The temporary runtime is automatically removed after all child processes exit.
No production package, pip source, or UniGetUI configuration is modified.
"""
import argparse
import base64
import csv
import hashlib
import io
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.request
import zipfile

CORE, CLIENT = "unigetui-probe-core", "unigetui-probe-client"
PIP_VERSION = "26.2.1"
PIP_SHA256 = "71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e"


def build_wheel(folder, name, version):
    module = name.replace("-", "_")
    info = f"{module}-{version}.dist-info"
    requires = f"Requires-Dist: {CORE} (>= {version}, < 1.0)\n" if name == CLIENT else ""
    payload = {
        module + "/__init__.py": f'__version__ = "{version}"\n'.encode(),
        info + "/METADATA": f"Metadata-Version: 2.1\nName: {name}\nVersion: {version}\n{requires}".encode(),
        info + "/WHEEL": b"Wheel-Version: 1.0\nRoot-Is-Purelib: true\nTag: py3-none-any\n",
    }
    record = io.StringIO()
    writer = csv.writer(record, lineterminator="\n")
    for filename, data in payload.items():
        value = base64.urlsafe_b64encode(hashlib.sha256(data).digest()).rstrip(b"=").decode()
        writer.writerow([filename, "sha256=" + value, len(data)])
    writer.writerow([info + "/RECORD", "", ""])
    payload[info + "/RECORD"] = record.getvalue().encode()
    wheel = folder / f"{module}-{version}-py3-none-any.whl"
    with zipfile.ZipFile(wheel, "w") as archive:
        for filename, data in payload.items():
            archive.writestr(filename, data)


PROBE = r'''
import base64,hashlib,importlib.metadata as m,json,pathlib,site
import unigetui_probe_core as core,unigetui_probe_client as client
rows=[]; errors=[]; checked=0
for root in [*site.getsitepackages(),site.getusersitepackages()]:
 for path in pathlib.Path(root).glob('unigetui_probe_*.dist-info'):
  d=m.Distribution.at(path); record=d.read_text('RECORD')
  rows.append({'dir':path.name,'METADATA':(path/'METADATA').is_file(),
   'RECORD':record is not None,'REQUESTED':(path/'REQUESTED').is_file(),
   'tracked_REQUESTED':record is not None and '/REQUESTED,' in record})
  for f in d.files or []:
   p=pathlib.Path(d.locate_file(f))
   if not p.is_file(): errors.append(['missing',str(f)])
   elif f.hash:
    got=base64.urlsafe_b64encode(hashlib.new(f.hash.mode,p.read_bytes()).digest()).rstrip(b'=').decode()
    if got!=f.hash.value: errors.append(['hash',str(f)])
    checked+=1
print(json.dumps({'core':[core.__version__,m.version('unigetui-probe-core')],
 'client':[client.__version__,m.version('unigetui-probe-client')],
 'rows':rows,'hashes_checked':checked,'record_errors':errors}))
'''


def reproduce(root, supplied_wheel):
    runtime = root / "runtime"
    runtime.mkdir()
    original = Path(sys.base_prefix)
    for path in original.iterdir():
        if path.is_file() and path.suffix.lower() in (".exe", ".dll"):
            shutil.copyfile(path, runtime / path.name)
    shutil.copytree(original / "Lib", runtime / "Lib",
                    ignore=shutil.ignore_patterns("site-packages", "__pycache__"))
    shutil.copytree(original / "DLLs", runtime / "DLLs")
    python = runtime / "python.exe"
    env = {k: v for k, v in os.environ.items() if not k.upper().startswith(("PIP_", "PYTHON"))}
    env.update(PYTHONUSERBASE=str(root / "userbase"), PIP_CONFIG_FILE=os.devnull,
               PIP_DISABLE_PIP_VERSION_CHECK="1", PYTHONUTF8="1")

    def run(*args, expected=0):
        command = [str(python), *map(str, args)]
        result = subprocess.run(command, env=env, cwd=root, capture_output=True,
                                text=True, encoding="utf-8", errors="replace", timeout=180)
        output = result.stdout + result.stderr
        public = "$ " + subprocess.list2cmdline(command) + "\n" + output
        for source, replacement in ((str(root), "<SANDBOX>"), (str(Path.home()), "<USER_HOME>")):
            public = re.sub(re.escape(source), lambda _: replacement, public, flags=re.I)
        print(public, flush=True)
        if expected is not None:
            assert result.returncode == expected, (command, result.returncode)
        return result, output

    wheel = supplied_wheel
    if wheel is None:
        with urllib.request.urlopen(f"https://pypi.org/pypi/pip/{PIP_VERSION}/json") as response:
            manifest = json.load(response)
        artifact = next(item for item in manifest["urls"] if item["filename"].endswith(".whl"))
        assert artifact["digests"]["sha256"] == PIP_SHA256
        wheel = root / artifact["filename"]
        urllib.request.urlretrieve(artifact["url"], wheel)
    assert hashlib.sha256(wheel.read_bytes()).hexdigest() == PIP_SHA256
    run("-m", "ensurepip", "--default-pip")
    run("-m", "pip", "install", "--no-index", "--no-deps", "--no-cache-dir", "--force-reinstall", wheel)

    def pip_integrity():
        checked = 0
        with zipfile.ZipFile(wheel) as archive:
            for member in archive.namelist():
                if member.startswith("pip/") and not member.endswith("/"):
                    assert (runtime / "Lib/site-packages" / member).read_bytes() == archive.read(member)
                    checked += 1
        print("OFFICIAL_PIP_FILES_IDENTICAL", checked, flush=True)

    indexes = {}
    for version in ("0.1.0", "0.2.0", "0.3.0"):
        indexes[version] = root / ("index-" + version)
        indexes[version].mkdir()
        for name in (CORE, CLIENT):
            build_wheel(indexes[version], name, version)

    def install(version, *targets):
        return run("-m", "pip", "install", "--no-index", "--find-links", indexes[version],
                   "--no-input", "--no-color", "--no-cache-dir", *targets)

    def state():
        result, _ = run("-c", PROBE)
        return json.loads(result.stdout)

    def healthy(version, count, allow_untracked=False):
        snapshot = state()
        assert snapshot["core"] == [version, version]
        assert snapshot["client"] == [version, version]
        assert len(snapshot["rows"]) == count
        assert not snapshot["record_errors"]
        assert all(r["METADATA"] and r["RECORD"] for r in snapshot["rows"])
        if not allow_untracked:
            assert all(not r["REQUESTED"] or r["tracked_REQUESTED"] for r in snapshot["rows"])
        run("-m", "pip", "check")

    pip_integrity()
    install("0.1.0", CORE + "==0.1.0", CLIENT + "==0.1.0")
    for version in ("0.2.0", "0.3.0"):
        install(version, "--upgrade", CORE + "==" + version)
        install(version, "--upgrade", CLIENT + "==" + version)
        healthy(version, 2)
    print("SINGLE_SCHEME_SEQUENTIAL_CONTROL_PASSED", flush=True)

    install("0.1.0", "--force-reinstall", CORE + "==0.1.0", CLIENT + "==0.1.0")
    install("0.1.0", "--user", "--ignore-installed", CORE + "==0.1.0")
    healthy("0.1.0", 3)
    for version in ("0.2.0", "0.3.0"):
        install(version, "--user", "--upgrade", CORE + "==" + version)
        install(version, "--upgrade", CLIENT + "==" + version)
        healthy(version, 3)
    print("PRESERVED_USER_SCHEME_CONTROL_PASSED", flush=True)
    install("0.1.0", "--user", "--force-reinstall", CORE + "==0.1.0")
    install("0.1.0", "--no-deps", "--force-reinstall", CLIENT + "==0.1.0")
    healthy("0.1.0", 3)
    print("TWO_SCHEMES_HEALTHY_BASELINE_PASSED", flush=True)
    install("0.2.0", "--upgrade", CORE + "==0.2.0")
    changed = state()
    assert changed["core"] == ["0.2.0", "0.1.0"]
    install("0.2.0", "--upgrade", CLIENT + "==0.2.0")
    healthy("0.2.0", 2, allow_untracked=True)
    overlay = state()
    assert any(r["REQUESTED"] and not r["tracked_REQUESTED"] for r in overlay["rows"])
    install("0.3.0", "--upgrade", CORE + "==0.3.0")
    orphan = state()
    assert orphan["core"] == ["0.3.0", None]
    result, output = run("-m", "pip", "install", "--no-index", "--find-links", indexes["0.3.0"],
                         "--upgrade", CLIENT + "==0.3.0", expected=None)
    assert result.returncode != 0 and "uninstall-no-record-file" in output
    pip_integrity()
    print("CLEAN_CLI_REPRODUCED_MISSING_RECORD; pip sources stayed identical", flush=True)


if __name__ == "__main__":
    sys.stdout.reconfigure(encoding="utf-8", errors="backslashreplace")
    parser = argparse.ArgumentParser()
    parser.add_argument("--pip-wheel", type=Path)
    args = parser.parse_args()
    assert os.name == "nt", "This runtime-copy reproduction is for Windows."
    with tempfile.TemporaryDirectory(prefix="unigetui-pip-clean-") as temporary:
        reproduce(Path(temporary), args.pip_wheel.resolve() if args.pip_wheel else None)

Activity

  1. GabrielDuf commented on Oct 8, 2026

    @GabrielDuf
    Contributor

    Hi Pomazan Bohdan (@pomazanbohdan),
    Thanks for the detailed report, it made both problems easy to reproduce. A fix is up in #5489:

    • Pip operations now run one at a time, so two updates can no longer write to the same environment at once.
    • Updates no longer retry with --user, so a failed update can't leave a second copy in your user folder. If a file is in use, UniGetUI now tells you to close the program using it and try again.

    once the fix is out, your current environment will still needs a one-time manual cleanup: remove the user-site boto3/botocore copies and the leftover botocore-1.43.108.dist-info, then reinstall both.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions