Skip to content

LOC-7228: Relax psutil upper bound to allow psutil 7.x - #63

Open
vivianludrick wants to merge 1 commit into
masterfrom
LOC-7228-relax-psutil-upper-bound
Open

LOC-7228: Relax psutil upper bound to allow psutil 7.x#63
vivianludrick wants to merge 1 commit into
masterfrom
LOC-7228-relax-psutil-upper-bound

Conversation

@vivianludrick

@vivianludrick vivianludrick commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Relax the psutil runtime dependency constraint from psutil>=5.6.6,<7 to psutil>=5.6.6
  • Bump package version to 1.2.16 for release

Context

LOC-7228 — a customer installing the BrowserStack Python SDK hit a dependency conflict:

browserstack-local 1.2.15 has requirement psutil<7,>=5.6.6, but you have psutil 7.2.2.

psutil is a runtime dependency (declared in install_requires, imported in browserstack/local.py), not a dev dependency. The upper bound is removed entirely (rather than bumped to <8) so this conflict doesn't recur on the next psutil major release, given the minimal API surface (see below).

The upper bound was added as a response for a CVE raised in LOC-6720. CVE-2019-18874 affected range (NVD + OSV/GHSA-qfc5-mcwq-26q8): psutil through 5.6.5 has a double free from refcount mishandling; the affected range is [0, 5.6.6) with fixed = 5.6.6. The mitigation is entirely the >=5.6.6 floor. Newer versions, including all of 6.x and 7.x — are fixes, not risks, for this CVE.

Verification

1. API surface auditpsutil.pid_exists() (browserstack/local.py:131, inside isRunning()) is the only psutil API used anywhere in the package, including tests.

2. psutil 7.x changelog review (psutil.io/changelog) — 7.0.0 dropped Python 2.7 and removed only Process.memory_info_ex() (deprecated since 4.0.0, unused here). pid_exists() was not removed or signature-changed in any 7.x release. One behavior fix: on Windows, pid_exists() now returns the correct answer under ERROR_ACCESS_DENIED instead of disagreeing with Process. For our usage (checking the daemonized binary's pid, same user) this is neutral-to-beneficial — the new behavior is the correct answer for isRunning().

3. Legacy Python safety — psutil 7.x declares requires_python >= 3.6 on PyPI (verified via PyPI JSON API), so pip on Python 2.7/3.5 automatically resolves to psutil 6.1.x; removing our upper bound cannot break those installs.

4. Live install + functional test — clean venv (Python 3.11), pip install psutil==7.2.2 + this package from source:

  • pip checkNo broken requirements found (the exact failure from the ticket, resolved)
  • isRunning() exercised against a live pid / killed pid / missing pid attr → all correct under psutil 7.2.2

5. Wheel coverage — psutil 7.2.2 ships prebuilt wheels for win_amd64, win_arm64, macOS, manylinux, musllinux.

Known edge cases (32-bit) and one improvement

psutil dropped 32-bit wheels after 7.0.0 — both Windows (win32) and Linux (i686) (6.1.1 and 7.0.0 ship them; 7.2.2 ships neither). A fresh install on a 32-bit interpreter without a build toolchain would fail to build psutil from sdist. 32-bit Linux is a platform this package explicitly serves (local_binary.py downloads BrowserStackLocal-linux-ia32 when sys.maxsize <= 2**32), so this is accepted knowingly rather than accidentally:

  • A scoped marker (psutil<7; platform_machine in "i686 i386 x86") was considered and rejected: platform_machine reflects the OS/WOW64 view, so 32-bit CPython on a 64-bit kernel reports x86_64 and the marker wouldn't fire, while pip's wheel selection still follows interpreter bitness — partial coverage for permanent resolver complexity.
  • The failure mode is a loud compiler error at install time, not a silent runtime break.
  • 32-bit interpreters are rare in 2026, and the SDK ecosystem already pulls psutil 7.x (which is what caused this ticket).

Flip side: 7.2.2 adds musllinux wheels (x86_64 + aarch64) that 6.1.1 lacked entirely, so Alpine users (BrowserStackLocal-alpine, also explicitly served) go from an sdist build to a prebuilt wheel. Net wheel coverage improves for a supported platform.

browserstack-local 1.2.15 pinned psutil>=5.6.6,<7, which conflicts with
environments that already have psutil 7.x installed (e.g. alongside the
BrowserStack SDK). The only psutil API used is psutil.pid_exists(),
which is unchanged in psutil 7, so the upper bound is unnecessary.

Fixes LOC-7228

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vivianludrick
vivianludrick requested a review from a team as a code owner July 30, 2026 18:19

@07souravkunda 07souravkunda left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed the constraint relaxation and re-checked the claims in the description against primary sources — they all hold. The <7 cap came in with the LOC-6720 commit (b5bb4f7) alongside the >=5.6.6 floor; OSV lists exactly one psutil advisory ever (CVE-2019-18874, introduced: 0, fixed: 5.6.6) with nothing affecting 6.x or 7.x, so the floor is the entire mitigation and removing the ceiling reintroduces no known vulnerability; pid_exists() at local.py:131 is the only psutil API in the package or its tests; and 7.2.2 ships cp36-abi3 wheels, so there is no Python 3.6/3.7 sdist regression (the aarch64 wheel also still carries manylinux_2_17, so no glibc floor bump). 1.2.16 is the right version — PyPI latest is 1.2.15 and setup.py is the only place a version is declared.

Also cross-checked the consumer side: browserstack_sdk requires bare psutil plus browserstack-local>=1.2.5, which confirms the cap came solely from this package and means a fresh SDK install picks up 1.2.16 with no SDK release needed.

One non-blocking comment inline: the documented edge case covers win32 but misses 32-bit Linux (i686), which lost wheels on the same boundary and is a platform local_binary.py explicitly branches for. Not a merge blocker — fine to land with the note extended, or with the cap scoped to 32-bit via an environment marker.

Comment thread setup.py
classifiers = [],
install_requires=[
'psutil>=5.6.6,<7',
'psutil>=5.6.6',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The "Known edge case" section covers 32-bit Windows, but psutil dropped 32-bit Linux (i686) wheels on the same boundary — and 32-bit Linux is a platform this package explicitly serves.

Evidence — wheel platform tags on PyPI:

psutil linux i686 wheel win32 wheel
6.1.1 yes yes
7.0.0 yes yes
7.2.2 no no

browserstack/local_binary.py:17,31-34 computes is_64bits = sys.maxsize > 2**32 and downloads BrowserStackLocal-linux-ia32 for 32-bit interpreters, so 32-bit Linux is a first-class target here. Windows, by contrast, has no bitness branch at all (local_binary.py:36 → a single BrowserStackLocal.exe). With the cap removed, a fresh install on a 32-bit Linux interpreter resolves psutil 7.2.2 and must compile it from sdist (gcc + Python headers) where 6.1.1/7.0.0 supplied a wheel — a minimal CI image without a toolchain fails the install outright.

Worth adding the flip side to the description as well: 7.2.2 gains musllinux wheels (x86_64 + aarch64) that 6.1.1 lacked entirely, so Alpine users (local_binary.py:26BrowserStackLocal-alpine) go from an sdist build to a wheel. Net wheel coverage improves for a supported platform.

Fix — either extend the documented edge case to win32 + linux i686 and accept it knowingly, or scope the cap to 32-bit so wheel coverage is preserved everywhere:

install_requires=[
    'psutil>=5.6.6',
    'psutil<7; platform_machine in "i686 i386 x86"',
]

On "environment markers cannot reliably detect interpreter bitness" — markers do expose platform_machine, which pip evaluates as i686 on 32-bit Linux and x86 for 32-bit CPython on Windows (a WOW64 process reports PROCESSOR_ARCHITECTURE=x86). I haven't tested that resolution myself, and platform_machine reflects the OS/WOW64 view rather than sys.maxsize, so worth a pip install --dry-run --platform manylinux2014_i686 check before relying on it.

Question for author: was 32-bit Linux considered alongside win32, or did the wheel audit look only at the Windows set (the LOC-7228 customer is on Windows)?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — answering your direct question first: the wheel audit only diffed the win32 set against 6.1.1/7.0.0 (the LOC-7228 customer was on Windows); i686 was not checked. You're right that it lost wheels on the same boundary, and unlike Windows, 32-bit Linux is a platform local_binary.py explicitly branches for (is_64bitsBrowserStackLocal-linux-ia32).

Re-verified your table against PyPI JSON:

psutil linux i686 wheels win32 wheels musllinux wheels
6.1.1 3 3 0
7.0.0 1 2 0
7.2.2 0 0 2

Going with extending the documented edge case rather than the scoped marker, for the reason you already half-flagged: platform_machine reflects the OS/WOW64 view, so a 32-bit CPython on a 64-bit kernel reports x86_64 and the marker wouldn't fire — while pip's wheel selection still follows interpreter bitness. That's arguably the more common 32-bit scenario today (i386 Docker images without linux32 personality), so the marker gives partial coverage in exchange for permanent resolver complexity on a shrinking platform. The failure mode it would guard is loud (compiler error at install time), not a silent runtime break.

Also folded your flip side into the description: 7.2.2 adds musllinux wheels that 6.1.1 lacked, so Alpine (BrowserStackLocal-alpine, also explicitly served) goes from sdist-build to wheel — net coverage improves for a supported platform.

PR description updated to cover win32 + linux i686 + the Alpine improvement.

@vivianludrick

Copy link
Copy Markdown
Collaborator Author

Customers who might be affected based on last month's usage data.

os_compatibility_impact:
  - os: "Windows 10 22H2 (10.0.19045)"
    customers_possibly_affected: 5
    why: "x86 OS edition exists → interpreter could be forced 32-bit"

  - os: "Windows 10 1809 / Server 2019 (10.0.17763)"
    customers_possibly_affected: "≤3"
    why: "x86 exists on the client SKU only; if these are Server 2019 it's 0"

  - os: "Windows 11 / Server 2022 / Server 2025"
    customers_possibly_affected: 0
    why: "x64-only OS. Not provably 0 only because of deliberate x86 CPython under WOW64"

  - os: "Linux i686"
    customers_possibly_affected: 0
    why: "the one forced-32-bit path we ship — and it has zero customers, only our QA fleet"

  - os: "macOS, Linux x64, Linux arm64, Alpine"
    customers_possibly_affected: 0
    why: "no 32-bit path at all; Alpine actually gains a musllinux wheel"

This likely won't affect anything since the deprecation is only for psutil >7.0 however our lower bound is 5.6.6 which can support 32bit. Only new install on 32 bit might be affected which can be resolve by the client by pinning the psutil to a lower version(>5.6.6).


Query used:

SELECT
  CONCAT(
    JSON_VALUE(e.event_json,'$.systemParams.platform'), '/',
    JSON_VALUE(e.event_json,'$.systemParams.arch'),     '/',
    IFNULL(JSON_VALUE(e.event_json,'$.systemParams.release'), '-')
  ) AS os,
  COUNT(DISTINCT JSON_VALUE(e.event_json,'$.groupId')) AS customers
FROM `browserstack-production.local.local_tunnel_events_partitioned` e
JOIN `browserstack-production.local.local_tunnel_infos_partitioned` l
  ON e.local_tunnel_info_id = l.id
WHERE e._PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 30 DAY)
  AND l._PARTITIONTIME >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 31 DAY)
  AND e.event_source = 'binary'
  AND e.event_name   = 'POSTBACK_ANALYTICS'
  AND JSON_VALUE(l.info_json,'$.source') LIKE 'python%'
GROUP BY 1
ORDER BY customers DESC

filter the csv from bq using following script:

import csv, sys
from collections import defaultdict

# Windows builds with no x86 edition at all -> 32-bit only via deliberate WOW64 install
WIN_SERVER_ONLY = {"10.0.20348", "10.0.25398"}   # Server 2022, Server 2022 23H2
WIN_AMBIGUOUS   = {"10.0.14393", "10.0.17763"}   # Win10 client (x86 exists) OR Server 2016/2019 (x64 only)

NAMES = {
    "10.0.26200": "Windows 11 25H2",       "10.0.26100": "Windows 11 24H2 / Server 2025",
    "10.0.22631": "Windows 11 23H2",       "10.0.22621": "Windows 11 22H2",
    "10.0.22000": "Windows 11 21H2",       "10.0.20348": "Windows Server 2022",
    "10.0.19045": "Windows 10 22H2",       "10.0.19044": "Windows 10 21H2",
    "10.0.17763": "Windows 10 1809 / Server 2019",
    "10.0.14393": "Windows 10 1607 / Server 2016",
    "6.3.9600":   "Windows 8.1 / Server 2012 R2",
    "6.1.7601":   "Windows 7 SP1 / Server 2008 R2",
}

def classify(platform, arch, build):
    """-> (verdict, os_label, why)"""
    if platform == "linux" and arch == "ia32":
        return "FORCED", "Linux i686", "ships BrowserStackLocal-linux-ia32; interpreter IS 32-bit"
    if platform == "win32":
        label = NAMES.get(build, f"Windows build {build}")
        if build in WIN_SERVER_ONLY:
            return "SAFE", label, "Server SKU, x64-only"
        if build in WIN_AMBIGUOUS:
            return "AMBIGUOUS", label, "x86 exists on client SKU only; 0 if these are Server"
        if build.startswith("6."):
            return "CANDIDATE", label, "Win7/8/8.1 era, x86 edition exists"
        try:
            major, _, b = build.split(".")[:3]
            if int(major) == 10 and int(b) >= 22000:
                return "SAFE", label, "Windows 11+ / Server 2022+, x64-only OS"
            return "CANDIDATE", label, "Windows 10, x86 edition exists"
        except (ValueError, IndexError):
            return "UNKNOWN", label, "unrecognised build string - classify manually"
    if platform == "darwin":
        return "SAFE", f"macOS {arch}", "darwin-x64 only; Apple dropped 32-bit at 10.15"
    return "SAFE", f"{platform} {arch}", "no 32-bit binary or wheel path"

def main(path):
    rows, ceiling = [], 0
    for r in csv.DictReader(open(path)):
        parts = (r["os"].split("/") + ["-", "-", "-"])[:3]
        platform, arch, build = parts
        n = int(r["customers"])
        verdict, label, why = classify(platform, arch, build)
        rows.append((verdict, label, build, n, why))
        if platform == "win32":
            ceiling += n

    order = {"FORCED": 0, "CANDIDATE": 1, "AMBIGUOUS": 2, "UNKNOWN": 3, "SAFE": 4}
    rows.sort(key=lambda x: (order[x[0]], -x[3]))

    totals = defaultdict(int)
    print(f"{'VERDICT':<10} {'OS':<34} {'CUST':>5}  WHY")
    print("-" * 100)
    for verdict, label, build, n, why in rows:
        totals[verdict] += n
        print(f"{verdict:<10} {label:<34} {n:>5}  {why}")

    confirmed = totals["FORCED"]
    plausible = totals["FORCED"] + totals["CANDIDATE"] + totals["AMBIGUOUS"] + totals["UNKNOWN"]
    print("-" * 100)
    print(f"confirmed affected (32-bit forced by platform) : {confirmed}")
    print(f"plausible affected (x86 OS edition possible)   : {plausible}")
    print(f"ceiling (all Windows Python customers, WOW64)  : {ceiling}")
    print()
    print("Counts are per-OS distinct groups, so the sums are group-slots: an org")
    print("testing from several Windows versions is counted once per version.")
    print("Interpreter bitness is unrecorded - CANDIDATE means 'possible', not 'is'.")
    print("Actual breakage additionally needs a fresh resolve (pip leaves an")
    print("already-satisfied psutil alone) and no compiler on the box.")

if __name__ == "__main__":
    main(sys.argv[1] if len(sys.argv) > 1 else "out.csv")

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.

2 participants