Repository navigation
fix(launcher): isolate the vendored deps/ from user site-packages (#621) - #623
Conversation
scripts/launcher.py put deps/ first on sys.path and left the interpreter's user site-packages behind it. That is not isolation in either direction. Every package deps/ does not vendor kept resolving from user site-packages. On a Windows machine carrying a coherent CUDA torch/torchvision/torchaudio set, torch came from deps/ (CPU) while torchvision came from user site-packages built against the other torch, so importing them together aborted the MCP server during startup with "RuntimeError: operator torchvision::nms does not exist" -- which transformers re-raised as a misleading "ModuleNotFoundError: Could not import module 'PreTrainedModel'", and which the host surfaced only as CONNECTION_CLOSED. Conversely, sys.path.insert never processes a directory's .pth files, so deps/pywin32.pth -- the only route to pywintypes -- was inert. Cutting user site-packages on its own (PYTHONNOUSERSITE, python -s) therefore traded one import failure for another. scripts/launcher_site.py does both halves, in the order that works: site.addsitedir(deps_dir) so the .pth files run, then drop every sys.path entry that resolves to the user site-packages directory. scripts/launcher.py applies it once for the MCP server and all eleven lifecycle hooks (ADR-0742), and scripts/setup.py's verification block applies it too, so the installer checks what the plugin will actually import. Inside a virtualenv (dev clones, CI) user site-packages is already off sys.path, so the removal is a no-op there; mcp_server/hooks/entry.py needs nothing -- it runs under uvx and never builds a deps/ sys.path. Also fixes the issue's secondary finding: install-plugin.sh ended with "PostgreSQL must be installed and running first" whatever setup.py had reported, including the run above whose four PostgreSQL checks all passed. scripts/lib/setup_py_step.sh captures the run's output and echoes back its "[FAIL] <check>" rows; the PostgreSQL guidance survives as a conditional. Tests exercise the path-list manipulation and real .pth processing against a temporary deps directory, so they run on macOS and Linux without a Windows torch stack, and the new file joins the Windows CI leg's test list. Closes #621 Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
…probe (#621) Review follow-ups on this PR's own diff. run_setup_py piped scripts/setup.py through tee, which makes the child's stdout a pipe. CPython block-buffers a pipe where it line-buffers a tty, and setup.py prints progress with a plain print() and no flush, so the whole install (pip, model pre-cache, database setup) printed nothing until it ended. Measured on this machine: a print() followed by a 2s sleep reaches the reader at t+2s through a pipe, at t+0s with PYTHONUNBUFFERED=1. The variable is now set for the run, and a test asserts the child's environment carries it rather than timing the output. user_site_dir() caught every exception from site.getusersitepackages() and returned None with no trace. isolate_deps() then silently no-ops and user site-packages stays on sys.path, which is this issue's symptom exactly. That is the silent fallback docs/agent-guidance.md forbids. The cause now goes to stderr, the way scripts/launcher.py already reports a failed backend resolution, and stays quiet on the normal path so eleven hooks do not each add a line. isolate_deps() now owns the sys.path insert instead of requiring each call site to do it first in the right order. site.addsitedir on its own APPENDS a directory sys.path does not carry, which would put deps/ behind system site-packages and lose the fix; a contract kept by prose at each call site is one a third call site can drop. scripts/launcher.py calls isolate_deps a second time after ensure_deps / ensure_all_deps. That pip run resolves transitives (only the installers' requirements closure passes --no-deps), so a distribution landing in deps_dir can ship a .pth the first call could not see, which would stay inert until the next launch. Measured cost of the repeat: 0.5 ms median, 0.7 ms p95 over 200 samples on a 600-entry deps directory. setup_database() in scripts/setup.py carries the same non-isolated PYTHONPATH-only child-process pattern as cache_embedding_model(), and unlike it fails fatally. Both now carry a comment naming the gap where it lives rather than only in a PR description. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
…-site cut (#621) Advisor review round on this PR's own diff. setup_py_failed_checks never stripped carriage returns, on exactly the platform the message exists for. CPython's text-mode stdout writes CRLF on Windows and a pipe preserves it byte for byte, so the extracted name carried one. Reproduced against the library at this PR's head: SETUP_PY_FAILURE came out as "sentence-transformers\r." under od -c. In a terminal that \r returns the cursor to column 0 and the rest of the fail() line overwrites the check name the fix exists to show. `tr -d '\r'` now runs ahead of the colour strip. tr rather than a sed `s/\r$//`: POSIX defines the \r escape for tr and defines no such escape inside a basic regular expression. The earlier draft of this comment claimed BSD sed would read it as a literal "r" and eat the last letter of "FlashRank reranker"; that was asserted, not measured, and measuring it on macOS's /usr/bin/sed showed the opposite. The comment now says what was measured. without_user_site matched the user site-packages directory exactly, so any directory underneath it survived the cut. site processes user site-packages' own .pth files at interpreter start, so a pywin32 installed there has already appended <user-site>/win32 and <user-site>/win32/lib, which are precisely the packages this cut exists to displace. The comparison is now "is, or is under", with the separator in the prefix so a sibling like user-site-extras is kept. scripts/lib/precache_embedding_model.sh carries the gap comment its two scripts/setup.py siblings already got. The previous PR description claimed all three were annotated while this file was untouched. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
…621) The craftsmanship gate flagged tests_py/scripts/test_launcher_site.py at 317 lines once this round's cases landed. The ratchet only shrinks, so the file is split along the seam the module already has rather than baselined: path filtering (without_user_site, user_site_dir) moves to test_launcher_site_paths.py, isolate_deps and the call-site wiring stay. The module loader both files need lives in _launcher_site_fixture.py so the dotted name mutmut keys trampolines on (issue #262) is written once. The fixture reaches the tests through a new tests_py/scripts/conftest.py: importing a fixture by name into a test module makes it a module-level binding, and every test taking it as a parameter then reads as a redefinition (ruff F811). The new file joins the Windows CI leg beside its sibling. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
47ea2c5 to
5b12eb2
Compare
…ndition (#621) The postcondition still described the exact-match filter it had before this round widened without_user_site to drop directories under user site-packages as well. A postcondition that understates what a function guarantees is the kind a later reader relies on and a later change quietly breaks. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
|
ZETETIC-REVIEW: APPROVE Fixes GitHub issue #621 (Windows: vendored deps/ not isolated from user site-packages, causing a torch-ecosystem version mismatch that stopped the MCP server from starting). CI green on a27dc7e. Went through one full independent /code-review pass (6 findings, all addressed: silent-fallback rule violation fixed with a stderr diagnostic, an undisclosed subprocess-isolation gap closed with a disclosure comment matching its sibling, an isolate_deps/ensure_deps ordering coupling closed by calling isolate_deps() a second time after dependency installation, a prose-only call-order contract made structural, a measured per-launch filesystem-cost note added) plus two rounds of independent advisor verification (fresh detached worktrees, re-running the actual gates rather than trusting prior reports). The first advisor pass found two additional issues beyond the code-review: a real Windows CRLF bug (stdout carriage returns not stripped, which would have corrupted the exact failed-check message this fix exists to show, reproduced with od -c) and a PR-body overstatement (claimed 3 files got a disclosure comment, only 2 did). Both fixed and independently re-verified by a second advisor pass: the CRLF fix is correctly placed ahead of the ANSI-strip stage, the disclosure comment is now on all 3 files, and an additional voluntary improvement (widening without_user_site() from exact-match to prefix-match, since a .pth-appended subdirectory of user site-packages was surviving the cut) was verified correct and sibling-safe with a targeted mutation test. Test coverage confirmed unchanged through a mid-PR file split (18 of 18 test names identical before and after), the mid-review rebase onto main after PR #620 merged was confirmed clean (CHANGELOG conflict resolved correctly, diffstat matches exactly the declared file list), and all gates (ruff, craftsmanship, shellcheck, the real bash scripts/install-plugin.sh drive on the Windows CI leg) pass on the final state. Remaining non-blocking items, disclosed in the PR body, not concealed. The three PYTHONPATH-only child-process isolation gaps are documented but not fixed, scoped out since closing them would need a bootstrap-API change. A real CUDA/CPU torch-mismatch import rests on the original reporter's measurement, not this repo's CI, since no Windows machine with a conflicting torch install is available here. os.path.normcase as a no-op survives mutation on POSIX; only the Windows CI leg actually exercises it. |
Summary
scripts/launcher.pyput the plugin's vendoreddeps/first onsys.pathand left the interpreter's user site-packages behind it. That is not isolation, and it leaks in both directions.Every package
deps/does not vendor kept resolving from user site-packages. On a Windows machine carrying a coherent CUDAtorch/torchvision/torchaudioset,torchcame fromdeps/(CPU) whiletorchvisioncame from user site-packages built against the other torch, so importing them together aborted the MCP server during startup withRuntimeError: operator torchvision::nms does not exist.transformersre-raised that as a misleadingModuleNotFoundError: Could not import module 'PreTrainedModel', and the host surfaced it only asCONNECTION_CLOSED.In the other direction,
sys.path.insertnever processes a directory's.pthfiles, sodeps/pywin32.pth(the only route topywintypes) was inert. Cutting user site-packages on its own withPYTHONNOUSERSITEorpython -stherefore traded one import failure for another, which is what the reporter measured on the affected machine.A new stdlib-only
scripts/launcher_site.pydoes both halves in the order that works:site.addsitedir(deps_dir)so the.pthfiles run, then drop everysys.pathentry that resolves to the user site-packages directory or sits under it.isolate_deps()owns thesys.pathinsert too, so no call site can perform the two steps out of order (site.addsitediralone appends a directorysys.pathdoes not carry, which would putdeps/behind system site-packages and lose the fix).Seams it is applied to, verified against the code rather than assumed:
scripts/launcher.pymain(), the one bootstrap shared by the MCP server and all eleven lifecycle hooks (ADR-0742). It runs twice: once beforeensure_deps/ensure_all_deps, so those see an isolated path, and once after, because that pip run resolves transitives (only the installers' requirements closure passes--no-deps) and can land a.pththe first call could not see. An unprocessed.pthis this issue's own bug class for a different package. The repeat costs 0.5 ms median and 0.7 ms p95 over 200 samples on a 600-entry deps directory.scripts/setup.pyverify(), the block that produced the issue's[FAIL] sentence-transformersrow. Without it the installer keeps failing on a machine whose server now starts fine.mcp_server/hooks/entry.py(added by feat(hooks): a console entry point runs any lifecycle hook from the wheel #605) needs nothing. It runs underuvx, which owns dependency resolution in its own environment, and it never putsdeps/onsys.path. This is one seam, not two.A
site.getusersitepackages()that cannot resolve reports the cause on stderr rather than returningNonequietly, since a quietNoneleaves user site-packages onsys.pathand reproduces this issue's symptom with no trace. It stays silent on the normal path so eleven hooks do not each add a line.Cross-platform safety was measured rather than assumed. Inside a virtualenv (dev clones and CI)
site.ENABLE_USER_SITEis False and the user site-packages directory is already offsys.path, so the removal is a no-op there. Outside one,deps/is self-contained by construction:launcher_deps.ensure_depsandensure_all_depsdecide what to install from.dist-infoinsidedeps_dir, never from general importability, so cutting user site-packages cannot strand a package they thought was present.The issue's secondary finding is fixed too. When
scripts/setup.pyfailed,install-plugin.shended with "PostgreSQL must be installed and running first" whatever the run had reported, including the reported run whose four PostgreSQL checks all passed and whose only failing row wassentence-transformers. A newscripts/lib/setup_py_step.shcaptures the run's output and echoes back its[FAIL] <check>rows. The PostgreSQL install guidance survives as a conditional instead of an assertion. Capturing means piping, and a pipe makes CPython block-buffer the child's stdout where a tty line-buffers it, so the run is made unbuffered: measured, aprint()followed by a two-second sleep reaches the reader at t+2s through a pipe and at t+0s withPYTHONUNBUFFERED=1. Carriage returns are stripped for the same reason the message exists at all: CPython's text-mode stdout writes CRLF on Windows and a pipe preserves it byte for byte, so the extracted name arrived assentence-transformers\rand, from column 0, the rest of the installer's line overwrote the check it had just named.Closes #621
Type of change
Test plan
This is a Windows-only failure mode and it was fixed on macOS, so the tests exercise the logic (which entries
sys.pathends up carrying, and whether.pthprocessing fires) instead of requiring a real torch/torchvision mismatch.tests_py/scripts/test_launcher_site.pyandtest_launcher_site_paths.py, 18 tests across the two (split along the module's own seam to stay under the 300-line cap; the shared loader lives in_launcher_site_fixture.pyand reaches them through a newtests_py/scripts/conftest.py).without_user_siteis driven with a fakesys.path, including spellings thatsitenever reports (a trailing separator, a.segment) which a raw string comparison would keep.isolate_depsruns against a temporary deps directory holding a real.pthfile that names nested directories and carries animportline, exactly asdeps/pywin32.pthdoes.site.addsitedir's.pthhandling is identical on every platform, so this exercises the pywin32 half for real on macOS and Linux. Further tests cover: an absentdeps_dirlanding at index 0 ahead of system site-packages, idempotence across two calls, a.pththat appears between two calls being picked up, directories under user site-packages being dropped while a sibling sharing their prefix is kept, the stderr diagnostic whengetusersitepackages()raises, and silence when it resolves.tests_py/scripts/test_setup_py_failure_message.py, 9 tests. They source the realscripts/lib/setup_py_step.shand replay the issue's verification block with its ANSI colour codes. The failure sentence must namesentence-transformersand must not say "PostgreSQL". A real PostgreSQL failure is still named, and a run that dies before reporting any check says so instead of guessing. The unbuffered run is asserted on the mechanism, the variable the child actually receives, rather than on a clock. Two cases feed CRLF, since CPython's text-mode stdout writes it on Windows.isolate_depsto a plainsys.path.insertbody fails two of its tests (8 passed, 2 failed) and restoring it returns them. RemovingPYTHONUNBUFFERED=1failstest_the_run_is_unbuffered_so_tee_does_not_hide_progresswithunbuffered=unsetand restoring it returns 7 passed.test_launcher_site.pyandtest_launcher_site_paths.pyjoin the "Test (Windows, SQLite backend)" leg's test list, sinceos.path.normcaseonly carries meaning there. That leg also runsbash scripts/install-plugin.shfor real, so the new shell library and thesetup.pyisolation call both execute on Windows on every PR.Gates run locally after the final commit:
pytest tests_py/scripts/: 1069 passed, 3 skipped, 360 subtests passed (rebased ontoorigin/mainat feat(codex): full profile and the same eleven lifecycle hooks under Codex #620)pytest tests_py/hooks tests_py/architecture tests_py/invariants: 675 passed, 13 subtests passedruff check .: All checks passed.ruff format --check .: 3410 files already formatted (ruff 0.16.6, the pinned version)python scripts/check_craftsmanship.py: Craftsmanship gate: OKpython scripts/check_doc_claims.py,check_version_surfaces.py,check_ci_gate_complete.py,check_project_wiki.py,check_no_deps_invariant.py: all OKRebased onto
origin/mainat 4400d7a (feat(codex): full profile and the same eleven lifecycle hooks under Codex #620) after it moved mid-review; the only conflict was the CHANGELOG's[Unreleased]block, resolved by keeping both sections. Every gate above was re-run on the rebased tree.bash -nandshellcheck -xon both changed shell scripts, plus an end-to-end drive of the failing installer path, which now printsscripts/setup.py failed. Failed check(s): sentence-transformers. Re-run manually: ...All existing tests pass.
New tests added for new behavior.
Mutation survival check: I considered what mutations would NOT be caught.
Manual verification of any UI / CLI / MCP-tool behavior changes.
Two mutations the tests would not catch, stated rather than hidden. Swapping
os.path.normcasefor a no-op survives on POSIX, where normcase is the identity function, and is only killed by the Windows CI leg. And no test asserts that a real torch/torchvision mismatch now imports; that claim rests on the reporter's measurement on the affected machine, not on this repo's CI.Audit notes
scripts/launcher.py,mcp_server/hooks/entry.py,scripts/setup.py,scripts/launcher_deps.py,scripts/launcher_pip.py,scripts/launcher_pins.py) instead of applying the reporter's patch verbatim. Two of its assumptions were re-derived and one was narrowed:entry.pydoes not need the fix, and the removal is safe cross-platform becausedeps/presence detection isdist-info-scoped todeps_dir.site.addsitedirsemantics were measured on this machine instead of recalled. Withdeps_diralready atsys.path[0]it keeps that index, does not duplicate the entry, appends the directories the.pthnames, and executes the.pthimportline.site.ENABLE_USER_SITEandgetusersitepackages()were measured inside and outside a virtualenv. The per-launch cost ofaddsitedirwas measured rather than assumed negligible: 0.5 ms median, 0.7 ms p95, 1.3 ms max over 200 samples on a 600-entry directory, against an interpreter start of tens of milliseconds.od -cshowingsentence-transformers\r.) and again after. The first draft of its fix carried a claim that BSDsedwould read\ras a literalrand truncate "FlashRank reranker"; measuring macOS's/usr/bin/sedshowed the opposite, so the comment now states only what was measured and rests the choice oftron what POSIX actually defines.setup.pythroughteehad silently turned its live progress output into block-buffered output that appeared only at exit. The fix and its measurement are described above.setup_database()andcache_embedding_model()inscripts/setup.pyboth handdeps/to a child process throughPYTHONPATHalone, which cannot process.pthfiles and cannot cut user site-packages, and the macOS/Linux twinscripts/lib/precache_embedding_model.shdoes the same.cache_embedding_modeland the shell twin are warn-only steps whose failure is absorbed by the lazy first-use download, which after this PR runs inside an isolated process.setup_databasefails fatally, but its child importspsycopgandmcp_server.infrastructure.pg_schema, neither of which is part of the torch ecosystem split this issue reports, so nothing observed reaches it. Closing any of the three needs a child-process bootstrap API and, for the shell twin, a signature change to a library covered by scripts/setup.sh step 5/7 reports [ok] after the model cache failed #537's tests. All three now carry a comment naming the gap at the code, so it does not live only in this description; fixing them is left for the owner's call rather than folded in here.Coding-standards compliance
any(oras any, or untyped dicts at boundaries) in production code.// source:annotation.scripts/launcher_site.pyis 118 lines and declares no numeric constants. The craftsmanship gate was verified clean on the committed diff.Breaking changes
None for a working install. One behavioural change is worth stating: a package that was previously reachable only from user site-packages is no longer visible to the MCP server, to the lifecycle hooks, or to
setup.py's verification. That is the point of the fix, sincedeps/is self-contained by construction, and it means an install that was silently borrowing from user site-packages will install the package intodeps/on the nextensure_depsrun.Screenshots / logs
Installer failure message, before and after, on the reported verification block:
Buffering measurement behind the
PYTHONUNBUFFERED=1line,print()then a 2s sleep then a secondprint():Reviewer checklist
docs/orREADME.mddocuments thesys.pathconstruction, and CHANGELOG is this repo's convention for a bug fix.Co-Authored-By: Claude Sonnet 5 [email protected]
Claude-Session: https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH
🤖 Generated with Claude Code
https://claude.ai/code/session_01Cz7A6goxVzfKL9NT7k4zDH