LOC-7228: Relax psutil upper bound to allow psutil 7.x - #63
Conversation
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>
07souravkunda
left a comment
There was a problem hiding this comment.
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.
| classifiers = [], | ||
| install_requires=[ | ||
| 'psutil>=5.6.6,<7', | ||
| 'psutil>=5.6.6', |
There was a problem hiding this comment.
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:26 → BrowserStackLocal-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)?
There was a problem hiding this comment.
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_64bits → BrowserStackLocal-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.
|
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 DESCfilter 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") |
Summary
psutilruntime dependency constraint frompsutil>=5.6.6,<7topsutil>=5.6.61.2.16for releaseContext
LOC-7228 — a customer installing the BrowserStack Python SDK hit a dependency conflict:
psutilis a runtime dependency (declared ininstall_requires, imported inbrowserstack/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 audit —
psutil.pid_exists()(browserstack/local.py:131, insideisRunning()) 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 underERROR_ACCESS_DENIEDinstead of disagreeing withProcess. For our usage (checking the daemonized binary's pid, same user) this is neutral-to-beneficial — the new behavior is the correct answer forisRunning().3. Legacy Python safety — psutil 7.x declares
requires_python >= 3.6on 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 check→No broken requirements found(the exact failure from the ticket, resolved)isRunning()exercised against a live pid / killed pid / missingpidattr → all correct under psutil 7.2.25. 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.pydownloadsBrowserStackLocal-linux-ia32whensys.maxsize <= 2**32), so this is accepted knowingly rather than accidentally:psutil<7; platform_machine in "i686 i386 x86") was considered and rejected:platform_machinereflects the OS/WOW64 view, so 32-bit CPython on a 64-bit kernel reportsx86_64and the marker wouldn't fire, while pip's wheel selection still follows interpreter bitness — partial coverage for permanent resolver complexity.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.