Plugin installer: the record keeps only dependencies whose version changed, so removing one plugin breaks another #597

Closed
opened 2026-10-01 10:48:09 +00:00 by tiagoagueda · 0 comments
Owner

Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the installer and running install_wheel and remove against a plugins directory in a temporary folder, with the installer replaced by one that lays down dist-info directories.

Checked again on 2026-10-01 against main at 0a5cad587: still present. The code is now at plugins/installing.py:783-790 (install_wheel, the arrived list), plugins/installing.py:865-885 (unshared_dependencies), plugins/installing.py:852-862 (remove) and plugins/installing.py:266-304 (pin_owners).

install_wheel writes into a plugin's dependencies only the packages whose version in the plugins directory differs from before the install. unshared_dependencies() reference-counts from those lists, so the count is wrong whenever a package was already there: a second plugin needing it records nothing, and an upgrade or reinstall records an empty list. Removing a plugin then deletes packages another plugin is using, or leaves its own behind for good. Related to #246 (closed), which introduced the reference count; this is an incomplete fix.

  • Where: plugins/installing.py:957-964 (install_wheel, the arrived list), plugins/installing.py:1039-1059 (unshared_dependencies), plugins/installing.py:1026-1036 (remove), and plugins/installing.py:440-478 (pin_owners, which treats any unclaimed package as Postulo's).
  • What happens:
    1. Plugin A is installed and brings shared==2.0; its record lists ["shared==2.0"]. Plugin B, which also needs shared, is installed next; before.get("shared") == "2.0", so B's record lists []. Removing A deletes shared from the volume. B then fails to import in every process that loads it afresh, is logged and skipped, and its notifications or syncs stop for everybody using it.
    2. Upgrading A to 1.1, or pressing Reinstall on the same version, with shared unchanged, rewrites A's record with dependencies: []. Removing A afterwards leaves shared on the volume. Because no record claims it, pin_owners() then counts it as one of Postulo's own pins, and an A 1.2 needing shared==3.0 is refused with "shared: needs 3.0, and Postulo has 2.0.", which is untrue.
      Both reproduced in the snapshot: "record {'plug-a': ['shared==2.0'], 'plug-b': []}", then after removing A only plug_b was left; and "A 1.0 ['shared==2.0'] / A 1.1 []", the false refusal, and shared left behind after removal.
  • Why it matters: removing one plugin silently breaks another, and the dependency bookkeeping that #246 added cannot be trusted after the first upgrade.
  • Fix: record each plugin's full dependency closure rather than the difference. After the install, walk Requires-Dist from the plugin's own dist-info through the dist-infos in the plugins directory (evaluating markers), or take the resolution the installer reports (uv pip install --report/pip install --report). unshared_dependencies() is then correct as written, and an upgrade keeps the shared packages it still needs.

Done when

  • tests/test_plugin_rollback.py has tests, using the fake installer extended to lay down dependency dist-infos, that two plugins installed one after the other both list a shared dependency and removing either keeps it; and that an upgrade and a same-version reinstall keep the dependency list.
Found on 2026-09-29 in a read-only review of postulo at 95348908d, reading the installer and running `install_wheel` and `remove` against a plugins directory in a temporary folder, with the installer replaced by one that lays down dist-info directories. Checked again on 2026-10-01 against `main` at `0a5cad587`: still present. The code is now at `plugins/installing.py:783-790` (`install_wheel`, the `arrived` list), `plugins/installing.py:865-885` (`unshared_dependencies`), `plugins/installing.py:852-862` (`remove`) and `plugins/installing.py:266-304` (`pin_owners`). `install_wheel` writes into a plugin's `dependencies` only the packages whose version in the plugins directory differs from before the install. `unshared_dependencies()` reference-counts from those lists, so the count is wrong whenever a package was already there: a second plugin needing it records nothing, and an upgrade or reinstall records an empty list. Removing a plugin then deletes packages another plugin is using, or leaves its own behind for good. Related to #246 (closed), which introduced the reference count; this is an incomplete fix. - **Where:** `plugins/installing.py:957-964` (`install_wheel`, the `arrived` list), `plugins/installing.py:1039-1059` (`unshared_dependencies`), `plugins/installing.py:1026-1036` (`remove`), and `plugins/installing.py:440-478` (`pin_owners`, which treats any unclaimed package as Postulo's). - **What happens:** 1. Plugin A is installed and brings `shared==2.0`; its record lists `["shared==2.0"]`. Plugin B, which also needs `shared`, is installed next; `before.get("shared") == "2.0"`, so B's record lists `[]`. Removing A deletes `shared` from the volume. B then fails to import in every process that loads it afresh, is logged and skipped, and its notifications or syncs stop for everybody using it. 2. Upgrading A to 1.1, or pressing *Reinstall* on the same version, with `shared` unchanged, rewrites A's record with `dependencies: []`. Removing A afterwards leaves `shared` on the volume. Because no record claims it, `pin_owners()` then counts it as one of Postulo's own pins, and an A 1.2 needing `shared==3.0` is refused with "shared: needs 3.0, and Postulo has 2.0.", which is untrue. Both reproduced in the snapshot: "record {'plug-a': ['shared==2.0'], 'plug-b': []}", then after removing A only `plug_b` was left; and "A 1.0 ['shared==2.0'] / A 1.1 []", the false refusal, and `shared` left behind after removal. - **Why it matters:** removing one plugin silently breaks another, and the dependency bookkeeping that #246 added cannot be trusted after the first upgrade. - **Fix:** record each plugin's full dependency closure rather than the difference. After the install, walk `Requires-Dist` from the plugin's own dist-info through the dist-infos in the plugins directory (evaluating markers), or take the resolution the installer reports (`uv pip install --report`/`pip install --report`). `unshared_dependencies()` is then correct as written, and an upgrade keeps the shared packages it still needs. ## Done when - `tests/test_plugin_rollback.py` has tests, using the fake installer extended to lay down dependency dist-infos, that two plugins installed one after the other both list a shared dependency and removing either keeps it; and that an upgrade and a same-version reinstall keep the dependency list.
tiagoagueda added this to the 0.5.0 milestone 2026-10-01 10:48:09 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Postulo/postulo#597
No description provided.