Compare commits

..
Author SHA1 Message Date
ac44b5a6f5 fix: apply CodeRabbit auto-fixes
Fixed 1 file(s) based on 2 unresolved review comments.

Co-authored-by: CodeRabbit <noreply@coderabbit.ai>
2026-08-05 20:59:54 +00:00
ChuckBuildsandClaude Opus 5 df8f69af30 docs(sports): record where B6 stands, and why it is waiting
The phase table had B4 as "next" and B5 as "after B4" while both had shipped,
and described B6 as blocked on B4's gate — which is now merged and released. A
plan that misreports which phase it is in is worse than no plan: the next
person reads it and repeats finished work.

Corrected, and three things that were only ever decided in conversation are now
written down:

  * **B6 is deliberately held.** 3.2.0 published 2026-08-03; 3.1.0 ran nine
    months before it. B6's premise is that cores without the module are gone,
    and there is no release-asset count or install telemetry to show that.
    Running it now strands users on their current plugin versions. The gate
    that makes it safe is already built and tested — it is the calendar that is
    missing, and no amount of further code changes that.
  * **Stop adopting further shared modules** (data_sources, game_renderer,
    base_odds_manager) until B6 closes. Each adoption adds a copy to keep in
    step against a payoff contingent on B6.
  * **A B5 retrospective**, because "the adoption went fine" is not what
    happened: four of eight shipped with scroll mode broken on a 3.2.0 core.
    The bundled fallback did not protect against it — the break was on the
    modern path — which is an argument for the sunset, not against it. Records
    the ledger too: net negative on disk until B6 runs.

Also replaces the "what's next" list, whose first five items were all done,
with what actually remains.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Udr6MfaFLUPhX5Fgo67Jf5
2026-08-05 14:14:00 -04:00
13 changed files with 171 additions and 836 deletions
+1 -5
View File
@@ -80,8 +80,4 @@ jobs:
test/test_version_consistency.py \
test/test_plugin_compatibility_gate.py \
test/test_install_preserves_existing.py \
test/test_core_owned_config_keys.py \
test/test_async_plugin_updates.py \
test/test_registry_id_resolution.py \
test/test_backup_manager.py \
test/test_plugin_update_reservation.py
test/test_core_owned_config_keys.py
-6
View File
@@ -357,12 +357,6 @@ sudo bash ./first_time_install.sh
This single script installs services, dependencies, configures permissions and sudoers, and validates the setup.
It finishes by asking whether to reboot. If you run it non-interactively — piped, over a script, or with `-y` — there is no one to ask, so **it reboots immediately without prompting**. Pass `--no-reboot-prompt` to install without rebooting:
```bash
sudo bash ./first_time_install.sh -y --no-reboot-prompt
```
</details>
</details>
+116 -33
View File
@@ -212,9 +212,9 @@ one of them is safe by construction and the other is not.
| **B1** | Promote the nine universal methods; convert `sports.py` → package | ✅ | Characterization suite green; no behavior change intended |
| **B2** | `CelebrationMixin` + rotation strategies as opt-in capabilities | ✅ | Non-adopters have zero new code in their MRO; strategies checked against verbatim plugin transcriptions |
| **B3** | Upstream the scroll **orchestration** layer as `src/common/sports_scroll.py`, reading `global_config['target_fps']` natively | ✅ | Content building stays per-sport |
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | **next** | Tag, release, and `src.__version__` agree; compatibility gate merged |
| **B5** | Adoption — guarded core imports: three pilots, then the remaining six. **Bundled copies stay.** | after B4 | Per plugin: harness + goldens byte-identical, then a device soak |
| **B6** | Sunset — delete the bundled copies | **blocked** | B4's gate shipped *and* in users' hands (see below) |
| **B4** | Ship 3.2.0 *and* make version reporting trustworthy | ✅ | Released 2026-08-03; tag, release and `src.__version__` agree; compatibility gate merged (#428, #431, #433) |
| **B5** | Adoption — guarded core imports, all eight. **Bundled copies stay.** | ✅ | All eight adopted; harness byte-identical; see the B5 retrospective below — four shipped broken and were repaired in plugins #251 |
| **B6** | Sunset — delete the bundled copies | **blocked, deliberately** | 3.2.0 *in users' hands*. Released 2026-08-03; there is no adoption data yet. See "B6 — the decision as of 2026-08-05" |
### B4 — what "ship 3.2.0" actually requires
@@ -265,10 +265,13 @@ migrate there.)
### B5 — adoption is safe by construction
A plugin adopting core imports keeps its bundled copy and reaches it through the
guarded import (see the Upgradability table above). On a core that ships the
module the plugin uses core code; on one that doesn't it falls back and behaves
exactly as it does today. There is no version of this step that breaks a user,
which is why it does not wait for B6's gate.
guarded import (see the Upgradability table above). On a core that doesn't ship
the module the plugin falls back and behaves exactly as it does today. That
fallback compatibility is safe by construction. On a core that *does* ship the
module, correctness is not automatic — object-level and scroll-mode validation
(building both classes and comparing, per the retrospective below) is required
to prove full behavior. There is no version of this step that breaks a user *on
an old core*, which is why it does not wait for B6's gate.
The hockey scroll-display pilot is **already validated**: adopted against a core
carrying 3.2.0, `scroll_display.py` went from 691 to 289 lines and all 16 harness
@@ -321,35 +324,115 @@ gate rather than trusting the failure to be noticed.
The same suite should exercise the install/update gate, since it is the other
half of the guarantee.
### B6 — the decision as of 2026-08-05
**Do not run B6 yet. Do not abandon it either.** The blocker is no longer
technical; it is calendar time, and it is the one thing here that cannot be
worked around by writing more code.
**Why not yet.** 3.2.0 was published **2026-08-03**. Its predecessor 3.1.0 ran
for nine months. B6's entire safety argument is "cores without
`src.common.sports_scroll` are gone", and two days after release that is not
close to true. There are no release assets to count and no install telemetry, so
we cannot demonstrate otherwise — and that absence of evidence *is* the answer.
Executing B6 now would strand essentially the whole user base on their current
plugin versions.
**What is already done and waiting.** The hard part is built and tested. The
install gate refuses a plugin whose floor exceeds the core's version, and
refuses one whose floor is above 2.0.0 when the core reports an untrustworthy
version — so a v3.1.0-release user (who reports `1.0.0`) keeps a working plugin
instead of receiving one that cannot load. Every adopted plugin has a
`test_core_fallback.py` covering both paths.
**What would unblock it.** Evidence of 3.2.0 uptake — a few months of it being
the default download, or store-side install data if that is ever added. Revisit
then, not on a schedule.
**When it happens, remember:** four plugins declare their floor top-level, where
editing `versions[0]` is a silent no-op, and the floor has three live spellings
(`min_ledmatrix_version`, `requires.min_ledmatrix_version`,
`versions[].ledmatrix_min_version`, plus deprecated `ledmatrix_min`). See
`src/plugin_system/compatibility.py:declared_min_version` for the resolution
order any floor-raising tool must reproduce.
### B5 retrospective — what the adoption actually cost
Recorded because it is the evidence behind the two decisions above, and because
"the adoption went fine" is not what happened.
**Four of the eight shipped with scroll mode broken** on a 3.2.0 core, and were
repaired in plugins-repo #251. The restructure lifted the content methods
verbatim but left the state they read off `self` behind: separator-icon
constants (hockey, basketball, lacrosse) and the game-renderer cache (afl).
hockey/basketball/lacrosse could not construct the scroll display at all; afl
raised inside `prepare_scroll_content`, which the core base *catches*, so its
only symptom was scroll mode silently drawing nothing.
Three things are worth carrying forward:
- **The bundled fallback did not protect anyone from this.** The break was on
the modern path, which the fallback never touches. Carrying the second copy
bought nothing against the actual defect while creating the divergence that
produced it. That is an argument *for* B6, not against it.
- **Every gate was green.** The safety harness renders the scoreboard screens,
not scroll mode; `test_core_fallback.py` checked that methods existed and that
their *globals* resolved, and `self.NHL_SEPARATOR_ICON` is an attribute read,
invisible to an AST scan for `Name` loads. The fix was to stop reasoning about
source and **build the object**: construct both classes on both paths, compare
the separator icons they end up with, and assert the adopted class ends up
with every instance attribute the bundled one sets.
- **Test what the change touches, not what is convenient to render.** Scroll
mode had no coverage because the harness could not reach it. A comparison
harness that renders the same games through both paths and diffs the pixels
needs no per-sport knowledge of the right answer, only that adopting core code
did not change it.
**The ledger.** Before adoption, eight duplicated copies totalled 5,685 lines.
After adoption plus the frozen legacy copies it was 10,610; removing the dead
inline duplication (plugins #252) brought it to roughly 8,620. B6 would take it
to about 3,300 including the shared core module — some 2,400 fewer than before
this project started. **Until B6 runs, the adoption is net negative on disk**,
and its one delivered user-visible gain is that adopted plugins honour the
global `target_fps` instead of hardcoding ~100 FPS.
### Decision: stop adopting further modules until B6 closes
`data_sources.py` (9 copies), `game_renderer.py` (8) and `base_odds_manager.py`
are the obvious next candidates. **Do not adopt them yet.** Each adoption adds
carrying cost — a second copy to keep in step — against a payoff that is
contingent on B6, and B6 is gated on an installed base we cannot currently
measure. Consolidate what is already committed; revisit when B6 does.
## What's next
In order. Each step is independently useful and independently revertible.
Steps 15 of the original plan are **done**: 3.2.0 is tagged and published with
a version number CI now asserts (#428), the compatibility gate is in
`install_plugin` and reads `compatible_versions` as well as the floor (#431,
#433), the newest manifest entry is required to use `ledmatrix_min_version`
(plugins #244), and all eight plugins have adopted the scroll orchestration
(plugins #245#249, repaired in #251, tidied in #252).
1. **Tag and publish v3.2.0.** The code is already on `main` (`21825cbf`).
Nothing else blocks this, and it is what makes `ledmatrix_min_version:
"3.2.0"` refer to something real.
2. **Make the version number honest.** Have the release process assert that the
tag, the GitHub release, and `src.__version__` agree — a check in CI is
cheaper than the confusion of the last two releases. Then revisit the
`< 2.0.0` skip in `_warn_if_incompatible`, which currently silences the
warning for the users who most need it.
3. **Add the compatibility gate** to `StoreManager.install_plugin` and
`.update_plugin`: refuse a plugin whose declared floor exceeds
`src.__version__`, and surface the reason in the store UI rather than only
the log. This is the single change that turns the floor from documentation
into a guarantee, and B6 depends on it.
4. **Migrate the manifests** to `ledmatrix_min_version`, and reconcile them with
`compatible_versions` (see above — that field is the required, canonical one,
and the gate does not read it yet). Currently 28 plugins spell the floor both
ways across their `versions[]` entries, 12 use only the old spelling, and 2
only the new. Scope the sweep to the nine sports plugins if a 42-plugin
version-bump wave isn't worth it — but the `compatible_versions` half has to
cover every manifest the gate can refuse, or define explicit legacy handling,
before the gate is allowed to block anything.
5. **Run B5 adoption** — hockey, soccer, football, then the remaining six.
Bundled copies stay. Byte-identical harness output per plugin, then a soak.
6. **Only then plan B6**, with the compatibility regression test described above
in CI first.
What actually remains, smallest first:
1. **Nothing on the critical path.** B6 is the only remaining phase and it is
waiting on calendar time, not on work. Resist the urge to fill the gap by
adopting more modules — see the decision above.
2. **The stale plugin-test tranche** — 5 failures across baseball, hockey and
basketball, all pre-existing API drift in the plugins' own older tests
(`plugin.initialized`, `CacheManager(config_manager=...)`, a bare
`cache_manager` import, `MockLogger.setLevel`, `BasketballPluginManager`).
None are scroll-related. They make the suite noisy, which is how a real
failure gets ignored.
3. **Soak the remaining adoptions on hardware.** Only baseball has been watched
through a live game, and hockey has been loaded on devpi. The other six are
proven by harness, unit tests and pixel comparison — not by a live match.
Out-of-season sports cannot be soaked until their season starts.
4. **`CLAUDE.md` in the plugins repo says four panel sizes; the harness renders
eight.** A one-line doc fix, and the discrepancy has already produced one
false review finding.
5. **Then, when the evidence supports it, B6** — with the four-case
compatibility regression test above in CI first.
## How to keep this project healthy
+18 -29
View File
@@ -1483,37 +1483,26 @@ if [ -f "$PROJECT_ROOT_DIR/config/config.json" ]; then
fi
# Set proper permissions for secrets file (restrictive: owner rw, group r)
# Owned by whoever WRITES the file, which is the web interface.
#
# This used to read the User= of ledmatrix.service — the display service —
# and, finding root, hand the file to root:ledmatrix 640. But the display
# service only ever reads secrets, and root can read any file regardless of
# mode. The account that *writes* them is the web interface: it saves config
# edits and performs backup restores, and it deliberately does not run as root
# (a web server should not). So a root-owned, group-read-only file left the web
# UI unable to write its own secrets, and restoring a backup failed with
# "Permission denied: config_secrets.json" while every other file in the same
# backup restored fine.
#
# Owning by the writer keeps the tighter 640 rather than loosening to
# group-writable, and root still reads it as superuser.
# If service runs as root, set ownership to root so it can read as owner
# Otherwise, use ACTUAL_USER and rely on group membership
if [ -f "$PROJECT_ROOT_DIR/config/config_secrets.json" ]; then
# The web service is the writer; fall back to the display service, then to
# the installing user, so an unusual layout still lands somewhere sensible.
SECRETS_OWNER=""
for unit in "/etc/systemd/system/ledmatrix-web.service" \
"$PROJECT_ROOT_DIR/systemd/ledmatrix-web.service"; do
if [ -f "$unit" ]; then
SECRETS_OWNER=$(grep -m1 "^User=" "$unit" | cut -d'=' -f2)
[ -n "$SECRETS_OWNER" ] && break
fi
done
if [ -z "$SECRETS_OWNER" ]; then
SECRETS_OWNER="$ACTUAL_USER"
# Check if service runs as root (from service file or template)
SERVICE_USER="root"
if [ -f "/etc/systemd/system/ledmatrix.service" ]; then
SERVICE_USER=$(grep "^User=" /etc/systemd/system/ledmatrix.service | cut -d'=' -f2 || echo "root")
elif [ -f "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" ]; then
SERVICE_USER=$(grep "^User=" "$PROJECT_ROOT_DIR/systemd/ledmatrix.service" | cut -d'=' -f2 || echo "root")
fi
if [ "$SERVICE_USER" = "root" ]; then
# Service runs as root - set ownership to root so it can read as owner
chown "root:$LEDMATRIX_GROUP" "$PROJECT_ROOT_DIR/config/config_secrets.json" || true
echo "✓ Secrets file permissions set (root:ledmatrix for root service)"
else
# Service runs as regular user - use ACTUAL_USER and rely on group membership
chown "$ACTUAL_USER:$LEDMATRIX_GROUP" "$PROJECT_ROOT_DIR/config/config_secrets.json" || true
echo "✓ Secrets file permissions set ($ACTUAL_USER:ledmatrix)"
fi
# A root-owned file is only correct when the writer really is root.
chown "$SECRETS_OWNER:$LEDMATRIX_GROUP" "$PROJECT_ROOT_DIR/config/config_secrets.json" || true
echo "✓ Secrets file owned by the web service user ($SECRETS_OWNER:$LEDMATRIX_GROUP, mode 640)"
chmod 640 "$PROJECT_ROOT_DIR/config/config_secrets.json"
fi
+1 -77
View File
@@ -16,7 +16,6 @@ import json
import logging
import os
import shutil
import stat
import socket
import tempfile
import zipfile
@@ -83,10 +82,6 @@ BUNDLED_FONTS: frozenset[str] = frozenset({
_CONFIG_REL = Path("config/config.json")
_SECRETS_REL = Path("config/config_secrets.json")
_WIFI_REL = Path("config/wifi_config.json")
# Sits in config/ next to the three above and is pure user state — a
# YouTube Music session that has to be re-authenticated by hand if lost.
# It was omitted from backups, so a restore silently signed the user out.
_YTM_REL = Path("config/ytm_auth.json")
_FONTS_REL = Path("assets/fonts")
_PLUGIN_UPLOADS_REL = Path("assets/plugins")
_STATE_REL = Path("data/plugin_state.json")
@@ -308,9 +303,6 @@ def create_backup(
if (project_root / _WIFI_REL).exists():
zf.write(project_root / _WIFI_REL, _WIFI_REL.as_posix())
contents.append("wifi")
if (project_root / _YTM_REL).exists():
zf.write(project_root / _YTM_REL, _YTM_REL.as_posix())
contents.append("ytm_auth")
# User-uploaded fonts.
user_fonts = iter_user_fonts(project_root)
@@ -356,7 +348,6 @@ def preview_backup_contents(project_root: Path) -> Dict[str, Any]:
"has_config": (project_root / _CONFIG_REL).exists(),
"has_secrets": (project_root / _SECRETS_REL).exists(),
"has_wifi": (project_root / _WIFI_REL).exists(),
"has_ytm_auth": (project_root / _YTM_REL).exists(),
"user_fonts": [p.name for p in iter_user_fonts(project_root)],
"plugin_uploads": len(iter_plugin_uploads(project_root)),
"plugins": list_installed_plugins(project_root),
@@ -438,8 +429,6 @@ def validate_backup(zip_path: Path) -> Tuple[bool, str, Dict[str, Any]]:
detected.append("secrets")
if _WIFI_REL.as_posix() in names:
detected.append("wifi")
if _YTM_REL.as_posix() in names:
detected.append("ytm_auth")
if any(n.startswith(_FONTS_REL.as_posix() + "/") for n in names):
detected.append("fonts")
if any(
@@ -492,61 +481,8 @@ def _extract_zip_safe(zip_path: Path, dest_dir: Path) -> None:
def _copy_file(src: Path, dst: Path) -> None:
"""Replace ``dst`` with ``src``, atomically, without needing to own ``dst``.
``shutil.copy2`` opens the destination for writing, so it needs write
permission on the *existing file*. Several config files are installed
root-owned and group-readable while the web interface — which is what runs
a restore — deliberately runs as a non-root user. Restoring those failed
with EACCES even though the account could create files in the same
directory perfectly well.
Writing a temporary file alongside and renaming over the target needs only
directory permission, which the web user has. It is also atomic: a crash
mid-restore can no longer leave a half-written config behind.
The destination's existing mode is preserved when there is one, so
restoring secrets does not silently widen them to the umask default.
"""
dst.parent.mkdir(parents=True, exist_ok=True)
existing_mode: Optional[int] = None
existing_owner: Optional[Tuple[int, int]] = None
if dst.exists():
try:
info = dst.stat()
existing_mode = stat.S_IMODE(info.st_mode)
existing_owner = (info.st_uid, info.st_gid)
except OSError:
existing_mode = None
existing_owner = None
fd, tmp_name = tempfile.mkstemp(dir=str(dst.parent), prefix=f".{dst.name}.", suffix=".tmp")
os.close(fd)
tmp_path = Path(tmp_name)
try:
shutil.copyfile(src, tmp_path)
if existing_mode is not None:
os.chmod(tmp_path, existing_mode)
else:
shutil.copymode(src, tmp_path)
if existing_owner is not None:
# Replacing a file creates a new inode owned by whoever is running,
# which would silently move a root-owned config to the web user.
# Carry the previous owner across when the OS permits it — only
# root can hand a file to another user, so this is best-effort and
# a plain restore as the web user simply keeps its own ownership.
try:
os.chown(tmp_path, existing_owner[0], existing_owner[1])
except (OSError, PermissionError):
pass
os.replace(tmp_path, dst)
except BaseException:
try:
tmp_path.unlink()
except OSError:
pass
raise
shutil.copy2(src, dst)
def restore_backup(
@@ -610,18 +546,6 @@ def restore_backup(
elif (tmp_dir / _WIFI_REL).exists():
result.skipped.append("wifi")
# YouTube Music session. Follows restore_wifi rather than getting its
# own flag: it is device-local auth in the same sense, and a separate
# toggle for one file would be noise in the restore dialog.
if options.restore_wifi and (tmp_dir / _YTM_REL).exists():
try:
_copy_file(tmp_dir / _YTM_REL, project_root / _YTM_REL)
result.restored.append("ytm_auth")
except OSError as e:
result.errors.append(f"Failed to restore ytm_auth.json: {e}")
elif (tmp_dir / _YTM_REL).exists():
result.skipped.append("ytm_auth")
# User fonts — skip anything that collides with a bundled font.
tmp_fonts = tmp_dir / _FONTS_REL
if options.restore_fonts and tmp_fonts.exists():
+2 -9
View File
@@ -44,16 +44,9 @@ class DataSource(ABC):
"""Fetch standings for a sport/league."""
def get_headers(self) -> Dict[str, str]:
"""Get headers for API requests.
The agent carries the project URL deliberately. Around 2026-08-04 ESPN
began returning 403 for bare custom tokens like 'LEDMatrix/1.0' — and
for browser-style strings — while accepting an agent that identifies
the client and links to it. An Accept header alone does not rescue the
bare form when the request goes out through requests.
"""
"""Get headers for API requests."""
return {
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
'User-Agent': 'LEDMatrix/1.0',
'Accept': 'application/json'
}
+1 -3
View File
@@ -56,9 +56,7 @@ class APIHelper:
# Default headers
self.session.headers.update({
# Identifies the client and links to it: ESPN began 403ing bare
# custom tokens (and browser strings) around 2026-08-04.
'User-Agent': 'LEDMatrix/1.0 (+https://github.com/ChuckBuilds/LEDMatrix)',
'User-Agent': 'LEDMatrix-Common/1.0',
'Accept': 'application/json',
'Accept-Language': 'en-US,en;q=0.9',
'Accept-Encoding': 'gzip, deflate, br',
+28 -110
View File
@@ -113,15 +113,6 @@ class PluginManager:
self._update_queue: "queue.Queue[Optional[Tuple[str, float]]]" = queue.Queue()
self._pending_updates: set = set()
self._pending_lock = threading.Lock()
# Serializes the "is this plugin eligible?" -> "claim it (RUNNING)"
# transition. Two schedulers run concurrently in practice — the render
# loop's _tick_plugin_updates() and Vegas mode's vegas-plugin-tick
# daemon thread, which is never joined — so without this both can
# observe ENABLED and both call update() on the same plugin. Held only
# across the check and the state transition, never across update()
# itself: that would serialize slow plugins behind each other and
# reintroduce the stall the async worker exists to avoid.
self._reservation_lock = threading.Lock()
self._plugin_locks: Dict[str, threading.Lock] = {}
self._plugin_locks_guard = threading.Lock()
self._update_worker: Optional[threading.Thread] = None
@@ -817,69 +808,25 @@ class PluginManager:
if self.health_tracker and self.health_tracker.should_skip_plugin(plugin_id):
continue
# Check if plugin can execute
if not self.state_manager.can_execute(plugin_id):
continue
interval = self._get_plugin_update_interval(plugin_id, plugin_instance)
if interval is None:
continue
# Eligibility check, due check and the RUNNING transition happen
# together, so a concurrent scheduler cannot claim the same plugin.
if not self._reserve_for_update(plugin_id, current_time, interval):
continue
with self._plugin_last_update_lock:
last_update = self.plugin_last_update.get(plugin_id, 0.0)
if self._synchronous_updates:
# Kill-switch path: the original inline execution
# (blocks the caller until update() completes/times out)
self._execute_update_now(plugin_id, plugin_instance, current_time)
else:
self._enqueue_update(plugin_id, current_time)
def _reserve_for_update(
self,
plugin_id: str,
current_time: Optional[float] = None,
interval: Optional[float] = None,
) -> bool:
"""Atomically claim a plugin for update, returning True if we won it.
can_execute() and the RUNNING transition have to happen under one lock.
As two separate calls, two scheduler threads can both see ENABLED and
both go on to run the same plugin's update() concurrently — unsafe for
any plugin that isn't reentrant (shared mutable state, a non-thread-safe
HTTP session or cache).
The due-time check is inside the lock too. Leaving it outside would let
a second thread that had already decided "due" claim the plugin the
instant the first finished, running update() twice in one interval.
Args:
plugin_id: Plugin to claim.
current_time: Now, for the due check. Omit to skip that check.
interval: Seconds between updates. Omit to skip the due check.
Returns:
True if this caller reserved the plugin and must dispatch it,
False if it is ineligible, not yet due, or already claimed.
"""
with self._reservation_lock:
if not self.state_manager.can_execute(plugin_id):
return False
if current_time is not None and interval is not None:
with self._plugin_last_update_lock:
last_update = self.plugin_last_update.get(plugin_id, 0.0)
if last_update != 0.0 and (current_time - last_update) < interval:
return False
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
return True
def _release_reservation(self, plugin_id: str) -> None:
"""Hand a claimed plugin back when it never got dispatched.
Without this a plugin reserved but not queued would sit in RUNNING
forever, and can_execute() would refuse it on every later tick.
"""
self.state_manager.set_state(plugin_id, PluginState.ENABLED)
if last_update == 0.0 or (current_time - last_update) >= interval:
if self._synchronous_updates:
# Kill-switch path: the original inline execution
# (blocks the caller until update() completes/times out)
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
self._execute_update_now(plugin_id, plugin_instance, current_time)
else:
self._enqueue_update(plugin_id, current_time)
def get_plugin_lock(self, plugin_id: str) -> threading.Lock:
"""Per-plugin lock keeping update() and display() mutually exclusive.
@@ -896,40 +843,16 @@ class PluginManager:
return lock
def _enqueue_update(self, plugin_id: str, scheduled_time: float) -> None:
"""Queue an already-reserved update for the background worker.
The caller has reserved the plugin (RUNNING), which is what blocks
re-entry and shows the truthful state in the web UI while the item
waits its turn. The pending set stays as a second line of defence; if
it ever fires the reservation has to be handed back, or the plugin
would sit in RUNNING with nothing queued to release it.
"""
"""Queue a due update for the background worker (dedup while pending)."""
with self._pending_lock:
if plugin_id in self._pending_updates:
self.logger.warning(
"Plugin %s reserved for update but already queued; "
"releasing the reservation", plugin_id)
self._release_reservation(plugin_id)
return
self._pending_updates.add(plugin_id)
try:
self._ensure_update_worker()
self._update_queue.put((plugin_id, scheduled_time))
except Exception as exc: # pylint: disable=broad-except
# Thread.start() raises RuntimeError when the OS refuses a new
# thread — a real condition on a Pi under memory pressure. Nothing
# is queued to release the plugin at that point, so the claim has to
# be undone here, or it sits in RUNNING with nothing to clear it and
# can_execute() refuses it for the rest of the process. Swallowed
# rather than raised so the remaining plugins in this tick still get
# their turn.
self.logger.error(
"Could not queue update for plugin %s (%s: %s); releasing the "
"reservation so the next tick can retry",
plugin_id, type(exc).__name__, exc, exc_info=True)
with self._pending_lock:
self._pending_updates.discard(plugin_id)
self._release_reservation(plugin_id)
# RUNNING is set at enqueue time so can_execute() blocks re-entry and
# the web UI shows the truthful state while the item waits its turn.
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
self._ensure_update_worker()
self._update_queue.put((plugin_id, scheduled_time))
def _ensure_update_worker(self) -> None:
if self._update_worker is not None and self._update_worker.is_alive():
@@ -1014,14 +937,6 @@ class PluginManager:
return
finished['done'] = True
try:
# Drop the queue reservation *before* the state goes back to
# ENABLED. The other order leaves a window where a scheduler
# sees ENABLED, reserves the plugin, then finds it still in
# _pending_updates -- the enqueue is dropped and the plugin
# would sit in RUNNING with nothing left to release it.
if lock is not None:
with self._pending_lock:
self._pending_updates.discard(plugin_id)
if success:
with self._plugin_last_update_lock:
self.plugin_last_update[plugin_id] = scheduled_time
@@ -1034,6 +949,8 @@ class PluginManager:
finally:
if lock is not None:
lock.release()
with self._pending_lock:
self._pending_updates.discard(plugin_id)
if lock is None:
# Synchronous / no-lock path: unchanged behavior.
@@ -1124,12 +1041,13 @@ class PluginManager:
if not hasattr(plugin_instance, "update"):
continue
# Eligibility check and the RUNNING transition together, so a
# concurrent scheduler cannot claim the same plugin (see
# _reserve_for_update).
if not self._reserve_for_update(plugin_id):
# Check if plugin can execute
if not self.state_manager.can_execute(plugin_id):
continue
# Update state to RUNNING
self.state_manager.set_state(plugin_id, PluginState.RUNNING)
try:
success = self.plugin_executor.execute_update(plugin_instance, plugin_id)
if success:
+2 -33
View File
@@ -1143,7 +1143,7 @@ class PluginStoreManager:
"""
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
plugin_info = self._match_registry_entry(plugins, plugin_id)
plugin_info = next((p for p in plugins if p['id'] == plugin_id), None)
if not plugin_info:
return None
@@ -1183,37 +1183,6 @@ class PluginStoreManager:
return plugin_info
@staticmethod
def _match_registry_entry(plugins: List[Dict], plugin_id: str) -> Optional[Dict]:
"""Find a registry entry by its id, or by the directory it installs to.
Four shipped plugins have a registry ``id`` that differs from the ``id``
in their own manifest: ``weather`` installs to ``plugins/ledmatrix-weather``,
and likewise stocks, music and leaderboard. Installation already prefers
the manifest id for the directory name, so on disk, in ``config.json``
and in a backup manifest those plugins are called ``ledmatrix-weather``.
Only the registry calls them ``weather``, and nothing resolved that in
reverse: restoring a backup asked the store for ``ledmatrix-weather``
and got "Plugin not found in registry", silently dropping four enabled
plugins from a restored device.
Matching ``plugin_path`` fixes it without renaming any published id,
which would orphan ``plugin_state.json`` entries keyed on the old ones.
Exact id always wins, so an entry whose *path* happens to collide with
another entry's id cannot shadow it.
"""
if not plugin_id:
return None
exact = next((p for p in plugins if p.get('id') == plugin_id), None)
if exact is not None:
return exact
for entry in plugins:
path = (entry.get('plugin_path') or '').rstrip('/')
if path and path.rsplit('/', 1)[-1] == plugin_id:
return entry
return None
def get_registry_info(self, plugin_id: str) -> Optional[Dict]:
"""
Get plugin information from the registry cache only (no GitHub API calls).
@@ -1229,7 +1198,7 @@ class PluginStoreManager:
"""
registry = self.fetch_registry()
plugins = registry.get('plugins', []) or []
return self._match_registry_entry(plugins, plugin_id)
return next((p for p in plugins if p.get('id') == plugin_id), None)
def install_plugin(self, plugin_id: str, branch: Optional[str] = None) -> bool:
"""Install a plugin, keeping any existing install until the new one is
-48
View File
@@ -3,7 +3,6 @@
from __future__ import annotations
import json
import stat
import zipfile
from pathlib import Path
@@ -42,13 +41,6 @@ def _make_project(root: Path) -> Path:
json.dumps({"ap_mode": {"ssid": "LEDMatrix"}}),
encoding="utf-8",
)
# Device-local auth that lives in config/ like the three above. It was
# omitted from backups, so a restore silently signed the user out of
# YouTube Music and they had to re-authenticate by hand.
(root / "config" / "ytm_auth.json").write_text(
json.dumps({"token": "YTM-TOKEN"}),
encoding="utf-8",
)
fonts = root / "assets" / "fonts"
fonts.mkdir(parents=True)
@@ -248,10 +240,6 @@ def test_restore_roundtrip(project: Path, empty_project: Path, tmp_path: Path) -
restored_secrets = json.loads((empty_project / "config" / "config_secrets.json").read_text())
assert restored_secrets["ledmatrix-weather"]["api_key"] == "SECRET"
assert "ytm_auth" in result.restored
restored_ytm = json.loads((empty_project / "config" / "ytm_auth.json").read_text())
assert restored_ytm["token"] == "YTM-TOKEN"
# User font restored, bundled font untouched.
assert (empty_project / "assets" / "fonts" / "my-custom-font.ttf").read_bytes() == b"\x00\x01USER"
assert (empty_project / "assets" / "fonts" / "5x7.bdf").read_text() == "BUNDLED"
@@ -294,39 +282,3 @@ def test_restore_rejects_malicious_zip(empty_project: Path, tmp_path: Path) -> N
# validate_backup catches it before extraction.
assert not result.success
assert any("unsafe" in e.lower() for e in result.errors)
def test_restore_over_a_file_the_user_cannot_write(
project: Path, empty_project: Path, tmp_path: Path
) -> None:
"""Restore must not need write permission on the destination *file*.
Reproduces what a fresh install leaves behind: config files owned by root
and only group-readable, while the web interface that performs the restore
runs as a non-root user. shutil.copy2 opens the destination for writing and
failed with EACCES; writing alongside and renaming needs only directory
permission, which that account has.
Simulated here by making the destination read-only the owner cannot
open it for writing either, but can still replace it within its directory.
"""
zip_path = create_backup(project, output_dir=tmp_path / "exports")
# Pre-existing, read-only destinations.
(empty_project / "config").mkdir(parents=True, exist_ok=True)
for name in ("config.json", "config_secrets.json", "wifi_config.json", "ytm_auth.json"):
target = empty_project / "config" / name
target.write_text("{}", encoding="utf-8")
target.chmod(0o444)
result = restore_backup(zip_path, empty_project, RestoreOptions())
assert result.success, result.errors
for section in ("config", "secrets", "wifi", "ytm_auth"):
assert section in result.restored, f"{section} not restored: {result.errors}"
restored = json.loads((empty_project / "config" / "config.json").read_text())
assert restored["my-plugin"]["favorites"] == ["A", "B"]
# The destination's mode is preserved rather than widened to the umask.
assert stat.S_IMODE((empty_project / "config" / "config_secrets.json").stat().st_mode) == 0o444
-335
View File
@@ -1,335 +0,0 @@
"""Plugin update scheduling must be atomic (issue #401).
`run_scheduled_updates()` decided whether to update a plugin with a
check-then-act sequence: `can_execute()` and `set_state(RUNNING)` were separate
calls with nothing between them, so two scheduler threads could both observe
ENABLED and both go on to call the same plugin's `update()`.
Two schedulers really do run at once. The render loop calls
`_tick_plugin_updates()`, and Vegas mode fires its own `vegas-plugin-tick`
daemon thread every few seconds; that thread is never joined when
`VegasModeCoordinator.play()` returns, so a slow `update()` still in flight can
overlap the next tick from the main loop.
A plugin whose `update()` runs twice at once is unsafe unless it happens to be
reentrant shared mutable state, a non-thread-safe HTTP session or cache all
break. These tests pin the reservation that closes it, and the state
bookkeeping that has to survive it: a plugin reserved but never dispatched must
not be stranded in RUNNING, because `can_execute()` would then refuse it
forever.
"""
import os
import sys
import threading
import time
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.plugin_system.plugin_manager import PluginManager # noqa: E402
from src.plugin_system.plugin_state import PluginState # noqa: E402
class OverlapDetectingPlugin:
"""Records the high-water mark of concurrent update() calls."""
def __init__(self, update_seconds=0.25):
self.enabled = True
self.update_seconds = update_seconds
self.update_calls = 0
self.max_concurrent = 0
self._active = 0
self._guard = threading.Lock()
def update(self):
with self._guard:
self._active += 1
self.update_calls += 1
self.max_concurrent = max(self.max_concurrent, self._active)
try:
time.sleep(self.update_seconds)
finally:
with self._guard:
self._active -= 1
return True
def display(self, force_clear=False):
return True
@pytest.fixture
def pm(tmp_path):
manager = PluginManager(plugins_dir=str(tmp_path), config_manager=None,
display_manager=None, cache_manager=None)
yield manager
manager.stop_update_worker()
def _install(pm, plugin, plugin_id="racy-plugin", interval=0.01):
pm.plugins[plugin_id] = plugin
pm._update_interval_cache[plugin_id] = interval
pm.state_manager.set_state(plugin_id, PluginState.ENABLED)
return plugin_id
def _widen_check_then_act_window(pm, delay=0.02):
"""Hold every scheduler thread inside the eligibility check at once.
The real gap between can_execute() and the RUNNING transition is a couple
of bytecodes wide, so a plain thread race under the GIL almost never lands
in it an unfixed scheduler looks correct in a test that just hammers it.
Delaying the check reproduces the interleaving that Vegas's tick thread and
the render loop actually produce when a slow update() overlaps the next
tick, and it is what makes these tests fail without the reservation.
Once the check and the transition are under one lock, the delay only
serializes the schedulers: the losers observe RUNNING and back off.
"""
real_can_execute = pm.state_manager.can_execute
def slow_can_execute(plugin_id):
result = real_can_execute(plugin_id)
time.sleep(delay)
return result
pm.state_manager.can_execute = slow_can_execute
def _hammer(target, threads=8, rounds=1):
"""Run `target` on N threads released simultaneously by a barrier."""
errors = []
barrier = threading.Barrier(threads)
def runner():
try:
barrier.wait(timeout=5)
for _ in range(rounds):
target()
except Exception as exc: # noqa: BLE001 - surfaced by the assertion
errors.append(exc)
workers = [threading.Thread(target=runner) for _ in range(threads)]
for worker in workers:
worker.start()
for worker in workers:
worker.join(timeout=30)
assert not errors, f"worker raised: {errors[0]!r}"
class TestReservationAtomicity:
"""The check-and-claim itself, independent of any dispatch path."""
def test_only_one_caller_wins_the_reservation(self, pm):
plugin_id = _install(pm, OverlapDetectingPlugin())
_widen_check_then_act_window(pm)
wins = []
lock = threading.Lock()
def claim():
if pm._reserve_for_update(plugin_id):
with lock:
wins.append(threading.current_thread().name)
_hammer(claim, threads=16)
assert len(wins) == 1, (
f"{len(wins)} threads reserved the same plugin concurrently; "
"the eligibility check and the RUNNING transition are not atomic")
assert pm.state_manager.get_state(plugin_id) == PluginState.RUNNING
def test_reservation_refused_while_running(self, pm):
plugin_id = _install(pm, OverlapDetectingPlugin())
assert pm._reserve_for_update(plugin_id) is True
assert pm._reserve_for_update(plugin_id) is False
def test_reservation_can_be_handed_back(self, pm):
plugin_id = _install(pm, OverlapDetectingPlugin())
assert pm._reserve_for_update(plugin_id) is True
pm._release_reservation(plugin_id)
assert pm.state_manager.get_state(plugin_id) == PluginState.ENABLED
assert pm._reserve_for_update(plugin_id) is True, \
"a released reservation must be claimable again"
def test_due_check_is_inside_the_reservation(self, pm):
"""Two threads that both decided 'due' must not both get a turn.
With the due check outside the lock, the loser of the race could claim
the plugin the moment the winner finished, running update() twice
inside one interval.
"""
plugin_id = _install(pm, OverlapDetectingPlugin(), interval=60.0)
now = time.time()
assert pm._reserve_for_update(plugin_id, now, 60.0) is True
pm.plugin_last_update[plugin_id] = now
pm._release_reservation(plugin_id)
assert pm._reserve_for_update(plugin_id, now, 60.0) is False, \
"plugin updated just now must not be due again"
class TestNoConcurrentUpdate:
"""The end-to-end invariant the issue is actually about."""
def test_synchronous_path_never_overlaps(self, pm):
"""The kill-switch path ran update() inline with no dedup at all."""
pm._synchronous_updates = True
plugin = OverlapDetectingPlugin(update_seconds=0.25)
_install(pm, plugin)
_widen_check_then_act_window(pm)
_hammer(pm.run_scheduled_updates, threads=8)
assert plugin.max_concurrent == 1, (
f"update() ran {plugin.max_concurrent}x concurrently on the "
"synchronous path")
def test_update_all_plugins_never_overlaps(self, pm):
plugin = OverlapDetectingPlugin(update_seconds=0.25)
_install(pm, plugin)
_widen_check_then_act_window(pm)
_hammer(pm.update_all_plugins, threads=8)
assert plugin.max_concurrent == 1, (
f"update() ran {plugin.max_concurrent}x concurrently via "
"update_all_plugins()")
def test_async_path_never_overlaps(self, pm):
plugin = OverlapDetectingPlugin(update_seconds=0.2)
plugin_id = _install(pm, plugin)
_hammer(pm.run_scheduled_updates, threads=8, rounds=3)
# Wait for an update to have both started and finished. Polling only
# `_active` races the worker: before it picks the item up nothing is
# active yet, so the loop would fall straight through and assert on a
# plugin that never ran.
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if plugin.update_calls >= 1 and plugin._active == 0:
break
time.sleep(0.05)
assert plugin.update_calls >= 1, "no update ran on the async path"
assert plugin.max_concurrent == 1, (
f"update() ran {plugin.max_concurrent}x concurrently on the "
"async path")
assert plugin_id in pm.plugins
class TestNoStrandedState:
"""A reservation that is never dispatched must not wedge the plugin."""
def test_plugin_returns_to_enabled_after_async_updates(self, pm):
plugin = OverlapDetectingPlugin(update_seconds=0.1)
plugin_id = _install(pm, plugin)
_hammer(pm.run_scheduled_updates, threads=6, rounds=2)
deadline = time.monotonic() + 15
while time.monotonic() < deadline:
if (plugin.update_calls >= 1
and pm.state_manager.get_state(plugin_id) == PluginState.ENABLED
and not pm._pending_updates):
break
time.sleep(0.05)
# ENABLED is also the starting state, so without this the assertion
# below would pass on a plugin that never got scheduled at all.
assert plugin.update_calls >= 1, "no update ran; the state assertion would be vacuous"
assert pm.state_manager.get_state(plugin_id) == PluginState.ENABLED, \
"plugin stranded in RUNNING; can_execute() would refuse it forever"
assert not pm._pending_updates, "pending set not drained"
def test_pending_cleared_before_state_reset(self, pm):
"""Invariant: never-RUNNING implies never-pending.
_finish() used to clear the pending entry *after* flipping the state
back to ENABLED. In that window a scheduler could reserve the plugin
and then have its enqueue dropped by the pending-dedup.
"""
plugin = OverlapDetectingPlugin(update_seconds=0.05)
plugin_id = _install(pm, plugin)
violations = []
stop = threading.Event()
def watcher():
while not stop.is_set():
state = pm.state_manager.get_state(plugin_id)
if state != PluginState.RUNNING and plugin_id in pm._pending_updates:
violations.append(state)
time.sleep(0.001)
thread = threading.Thread(target=watcher, daemon=True)
thread.start()
try:
for _ in range(15):
pm.run_scheduled_updates()
time.sleep(0.05)
finally:
stop.set()
thread.join(timeout=5)
assert not violations, (
f"{len(violations)} sample(s) saw a non-RUNNING plugin still in "
"_pending_updates")
class TestDispatchFailure:
"""A reservation must survive the dispatch itself failing.
_enqueue_update() claims the plugin, adds it to the pending set, and only
then starts the worker and queues the item. threading.Thread.start() raises
RuntimeError when the OS refuses a new thread not hypothetical on a Pi
under memory or thread pressure. Nothing is queued to release the plugin at
that point, so without an explicit rollback it stays RUNNING with a stale
pending entry and can_execute() refuses it for the rest of the process.
"""
def test_reservation_released_when_the_worker_cannot_start(self, pm):
plugin_id = _install(pm, OverlapDetectingPlugin())
def refuse_to_start():
raise RuntimeError("can't start new thread")
pm._ensure_update_worker = refuse_to_start
assert pm._reserve_for_update(plugin_id) is True
pm._enqueue_update(plugin_id, time.time())
assert pm.state_manager.get_state(plugin_id) == PluginState.ENABLED, \
"plugin left in RUNNING after a failed dispatch; can_execute() " \
"would refuse it forever"
assert plugin_id not in pm._pending_updates, \
"stale pending entry would make the next enqueue hit the dedup"
assert pm._reserve_for_update(plugin_id) is True, \
"plugin must be claimable again on the next tick"
def test_dispatch_failure_does_not_abort_the_rest_of_the_tick(self, pm):
"""One plugin failing to queue must not skip the others in that tick."""
first = OverlapDetectingPlugin()
second = OverlapDetectingPlugin()
_install(pm, first, plugin_id="plugin-a")
_install(pm, second, plugin_id="plugin-b")
calls = []
real_ensure = pm._ensure_update_worker
def fail_first_only():
calls.append(1)
if len(calls) == 1:
raise RuntimeError("can't start new thread")
return real_ensure()
pm._ensure_update_worker = fail_first_only
pm.run_scheduled_updates() # must not propagate the RuntimeError
assert len(calls) == 2, (
"run_scheduled_updates() stopped after the failing plugin; the "
"exception escaped the enqueue")
for plugin_id in ("plugin-a", "plugin-b"):
assert pm.state_manager.get_state(plugin_id) in (
PluginState.ENABLED, PluginState.RUNNING), \
f"{plugin_id} left in an unexpected state"
-107
View File
@@ -1,107 +0,0 @@
"""A plugin must be findable in the registry by the id it calls itself.
Four shipped plugins have a registry ``id`` that differs from the ``id`` in
their own ``manifest.json``:
directory / manifest.json id registry id
ledmatrix-weather weather
ledmatrix-stocks stocks
ledmatrix-music music
ledmatrix-leaderboard leaderboard
The installer already knows about this: it deliberately names the install
directory after the *manifest* id (store_manager, "Use manifest ID for
directory name"), and warns when the two disagree. So on disk, in
``config.json`` and in a backup manifest, these plugins are called
``ledmatrix-weather``. Only the registry calls them ``weather``.
Nothing resolved that in reverse. Asking the store to install
``ledmatrix-weather`` -- which is exactly what restoring a backup does --
failed with "Plugin not found in registry", and four enabled plugins went
missing from a restored device with no error surfaced to the user.
Renaming the registry ids would orphan existing ``plugin_state.json`` entries
keyed on the old ones, so the lookup resolves ``plugin_path`` instead: the
registry already records ``plugins/ledmatrix-weather``, which is unambiguous
and needs no published identity to change.
"""
import os
import sys
from typing import Any, Dict, List, Optional
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
from src.plugin_system.store_manager import PluginStoreManager # noqa: E402
# Shaped like the real registry: id and plugin_path basename disagree for the
# first entry, agree for the second.
REGISTRY: Dict[str, List[Dict[str, Any]]] = {
"plugins": [
{
"id": "weather",
"name": "Weather",
"plugin_path": "plugins/ledmatrix-weather",
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
},
{
"id": "ledmatrix-flights",
"name": "Flights",
"plugin_path": "plugins/ledmatrix-flights",
"repo": "https://github.com/ChuckBuilds/ledmatrix-plugins",
},
{
"id": "third-party",
"name": "Third Party",
"plugin_path": "",
"repo": "https://github.com/someone/thing",
},
]
}
@pytest.fixture
def store(monkeypatch: pytest.MonkeyPatch) -> PluginStoreManager:
manager = PluginStoreManager.__new__(PluginStoreManager)
monkeypatch.setattr(manager, "fetch_registry", lambda *a, **k: REGISTRY, raising=False)
return manager
def _ids(entry: Optional[Dict[str, Any]]) -> Optional[str]:
return entry.get("id") if entry else None
class TestRegistryLookupByManifestId:
def test_exact_registry_id_still_resolves(self, store: PluginStoreManager) -> None:
assert _ids(store.get_registry_info("weather")) == "weather"
def test_manifest_id_resolves_via_plugin_path(self, store: PluginStoreManager) -> None:
"""The case that broke restore: asked by the name on disk."""
assert _ids(store.get_registry_info("ledmatrix-weather")) == "weather", (
"a plugin installed as 'ledmatrix-weather' could not be found in a "
"registry that lists it under plugin_path plugins/ledmatrix-weather")
def test_matching_id_and_path_unaffected(self, store: PluginStoreManager) -> None:
assert _ids(store.get_registry_info("ledmatrix-flights")) == "ledmatrix-flights"
def test_unknown_plugin_still_returns_none(self, store: PluginStoreManager) -> None:
assert store.get_registry_info("no-such-plugin") is None
def test_empty_plugin_path_is_not_a_wildcard(self, store: PluginStoreManager) -> None:
"""Third-party entries carry plugin_path "" — that must not match ""."""
assert store.get_registry_info("") is None
def test_exact_id_wins_over_a_path_match(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""If some other entry's path collides with a real id, id wins."""
registry = {
"plugins": [
{"id": "decoy", "plugin_path": "plugins/weather"},
{"id": "weather", "plugin_path": "plugins/ledmatrix-weather"},
]
}
manager = PluginStoreManager.__new__(PluginStoreManager)
monkeypatch.setattr(manager, "fetch_registry", lambda *a, **k: registry, raising=False)
assert _ids(manager.get_registry_info("weather")) == "weather"
+2 -41
View File
@@ -7843,31 +7843,7 @@ def clear_old_errors():
# Backup / Restore
# ---------------------------------------------------------------------------
def _resolve_backup_export_dir() -> Path:
"""Where exported backups live: beside the install, not inside it.
They used to be written to ``<project>/config/backups/exports``. That is
inside the directory a reinstall deletes, so the documented recovery path
-- export a backup, then reinstall -- destroyed the backup it had just
told the user to make. Anyone who downloaded the ZIP was fine; anyone
relying on the on-device copy was not.
Falls back to the old location when the parent directory is not writable,
so an unusual layout degrades to previous behaviour instead of failing to
export at all.
"""
preferred = PROJECT_ROOT.parent / "ledmatrix-backups"
try:
preferred.mkdir(parents=True, exist_ok=True)
probe = preferred / ".writetest"
probe.write_text("", encoding="utf-8")
probe.unlink()
return preferred
except OSError:
return PROJECT_ROOT / "config" / "backups" / "exports"
_BACKUP_EXPORT_DIR = _resolve_backup_export_dir()
_BACKUP_EXPORT_DIR = PROJECT_ROOT / "config" / "backups" / "exports"
def _safe_backup_path(filename: str) -> Path:
@@ -8021,22 +7997,7 @@ def backup_restore():
data = result.to_dict()
if not result.success:
# Name what failed, and what nonetheless landed. A restore is
# partial far more often than it is total -- a fresh install can
# leave config_secrets.json unwritable by the web service, so
# config restores and secrets do not. "Restore had errors" alone
# left the user unable to tell a wholly failed restore from one
# that quietly dropped their API keys.
failed_plugins = [p.get('plugin_id') for p in (result.plugins_failed or [])]
parts = []
if result.restored:
parts.append(f"restored: {', '.join(result.restored)}")
if result.errors:
parts.append(f"failed: {'; '.join(result.errors)}")
if failed_plugins:
parts.append(f"plugins not reinstalled: {', '.join(failed_plugins)}")
message = 'Restore incomplete — ' + ('. '.join(parts) if parts else 'see logs')
return jsonify({'status': 'error', 'message': message, 'data': data}), 500
return jsonify({'status': 'error', 'message': 'Restore had errors', 'data': data}), 500
return jsonify({'status': 'success', 'data': data})
except Exception as e:
logger.error("backup_restore failed: %s", e, exc_info=True)