Skip to content

feat(backup): restore extracts the bridge node's storage too (#136) - #169

Merged
simons-plugins merged 7 commits into
mainfrom
feat/136-wire-bridge-restore
Aug 9, 2026
Merged

feat(backup): restore extracts the bridge node's storage too (#136)#169
simons-plugins merged 7 commits into
mainfrom
feat/136-wire-bridge-restore

Conversation

@simons-plugins

@simons-plugins simons-plugins commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Fixes #136. Plugin 2026.9.0 (minor: user-visible feature per workspace versioning rule). Suites 2384 Python (from 2350, +34 across build + review passes), 405 TS untouched. pylint: fabric_backup 9.94 → 9.97, plugin 9.23 held, combined 9.30 → 9.36.

What changes

Since E5, Back up the Matter fabric… has archived the export bridge node's storage (identity.json, endpoint-map.json, the matter.js store) under the reserved bridge-node/ prefix — and restore reported-and-skipped it, because the bridge stop seam didn't exist yet. E7 shipped that seam (LaunchAgent.stop()/.start(), whose docstrings already named fabric restore as their caller). This PR wires it: Restore a fabric backup… now puts the bridge storage back too, stopping and starting the bridge node around the swap.

The sequence, and why it's shaped this way

Stop bridge (only if alive) → stop controller → move BOTH dirs aside (all-or-nothing) → extract both → start controller → start bridge (only if we stopped it, outside the rollback scope):

  • The aliveness gate is load-bearing twice. _bootout() returns False for "no such job" as well as real failure, so without it a never-started bridge would abort every restore; and XG5 forbids leaving a bridge running on a system with nothing exported — a bridge that was down stays down (it returns on the next export).
  • A bridge that won't restart is a loud ERROR, never a rollback. start() returning False for "package never installed" is by design; rolling back a good controller-fabric restore to protect a secondary daemon would destroy the thing the user came for. The gate means that branch is only reachable for a bridge that was demonstrably alive minutes earlier, so a False there is a real regression and is reported as one.
  • The move-aside pair is all-or-nothing. A bridge rename that fails undoes the controller rename before raising, and the caller restarts both daemons best-effort — after the move-aside, either both sides are aside or neither is (found by the review pass, see below).
  • Zip-slip is pre-flighted for both destinations before either daemon is stopped — a hostile archive must never get to take two daemons down before being rejected. The checked path is the written (prefix-stripped) one: bridge-node/../escape.txt resolves inside dest under its raw archive name but escapes once the prefix is stripped — the raw-name check would have passed it.
  • No control available ⇒ warn-and-skip, never refuse (manual recipe included): the controller fabric is the single point of total loss; the bridge storage is recoverable by re-pairing.

The XAC1 latch is untouched in every path — restore uses stop()/start() (plist survives), unlike menuStopBridgeNode's uninstall(); the call-site comment carries the case analysis, and a wiring test pins note_agent_stopped is never called. Restored-content honesty: the restored triple is one internally-consistent snapshot but may be older than what paired ecosystems hold — the success log names the designed signal (endpoint-map drift is REPORTED, renumbered never; #141's restore-before-serve means an older map serves safely).

The /review-pr pass (4 agents), and what it caught

Run post-build per the every-PR review policy. Headline finding, fixed in ee8c624: the move-aside step sat outside the rollback scope and the PR had doubled it to two renames — controller rename succeeding then bridge rename failing (e.g. a leftover same-stamp .pre-restore- dir) stranded the controller fabric aside with both daemons stopped and no rollback, while the dialog claimed the fabric was preserved. Reproduced live by the reviewer; now all-or-nothing with a double-fault escape hatch, mutation-pinned both ways.

Also from the pass (cfa301a): the abort-path bridge restart's result is no longer discarded (a failed restart now logs the consequence); a raising is_alive() probe is debug-logged; _rollback's bridge error text no longer claims a "previous copy preserved at None"; ~10 docstring precision fixes (step-numbering truth, the zip-slip example corrected to the discriminating one-.. case, dangling plan-private section refs removed, the XAC1 comment's third case made conditional, CRITICAL-vs-ERROR grep-honesty); the restore dialog's warning now hedges for a not-running bridge; and 13 new tests closing the coverage review's gaps — including the previously-untested R1 rollback swallow, the bridge-side empty-extract wipe guard, _control_is_alive edges, directory-entry members from hand-zipped archives, and a pin on every commit in the PR.

Tests

34 new total (15 build + 13 review + 6 plugin wiring). 16 mutations verified across build and review passes; the coordinator independently re-ran four (aliveness gate, prefix strip, A1 undo-rename, late-error conversion class). The E5 invariant — nothing named bridge-node ever nested under the controller's storage — keeps its own test.

Docs

INSTALL.md's manual-recipe paragraph replaced with the wired behavior; restore dialog warning + confirm labels updated (ids unchanged); CLAUDE.md module row updated; HANDOVER's open-items row struck + one bracketed annotation on the E5 deferred item.

For Simon

  • Dialog shows success when only the bridge restart failed (controller restored fine; the ERROR is in the log). Say if you'd prefer a failure dialog.
  • E2E on jarvis needs you (menu actions are UI-only): export something → back up → change something → restore → confirm accessories keep their numbers and no ecosystem re-creates them.
  • Cheap follow-up if wanted: mark archives containing bridge members in the restore picker's labels.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Unpt5UPRdLoZkahH6a4gL

simons-plugins and others added 4 commits August 9, 2026 14:32
restore_backup() now accepts optional bridge_storage_path/bridge_control
kwargs and, when the archive carries bridge-node/ members and both are
given, stops the bridge node before the controller, extracts its half
of the archive (prefix stripped) alongside the controller fabric, and
restarts it afterwards if it was running. A bridge-restart failure
never rolls back an otherwise-good controller restore (XG5); a bridge
that was not running before the restore is left stopped.

Falls back to the pre-#136 controller-only behaviour (loud WARNING
naming the manual recipe) whenever there's no bridge control, no
usable bridge storage path, or the bridge path overlaps the
controller's. An old, controller-only archive restores exactly as it
always did, silently.

Rollback is now symmetric: a failure during either extract undoes both
sides (bridge first, controller second, controller restarted first on
the way back), and the zip-slip pre-flight now checks both sides
before either process is stopped.

Adds 15 tests in tests/test_fabric_backup.py covering the two-control
stop/start ordering, the bridge extraction/prefix-strip, every
controller-only fallback path, and rollback symmetry.

Refs #136

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Unpt5UPRdLoZkahH6a4gL
Adds _bridge_restore_control(), the stop()/start() twin of
_bridge_storage_path(): reuses self.bridge_process if one already
exists, otherwise builds one from current prefs exactly as
menuStopBridgeNode does, and falls back to None (bridge files reported
and skipped, controller restores normally) if construction raises.

menuRestoreFabricBackup now passes both to fabric_backup.restore_backup
and reports the bridge outcome from its result: an ERROR naming the
bridge diagnosis when the controller restored but the bridge failed to
come back, an INFO with the drift-honesty note when it restored and
started successfully. The XAC1 latch (export_bridge.note_agent_stopped)
is deliberately never touched from this path in either direction — see
the call-site comment for both branches' reasoning.

plug fixture in tests/test_plugin_behaviour.py gains bridge_process=None
(was previously unset, which the new code path needs). Adds 6 tests
covering the control being passed/reused/rebuilt/falling back, the
untouched XAC1 latch on both success and failure, and the honest
bridge-outcome reporting.

Refs #136

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Unpt5UPRdLoZkahH6a4gL
INSTALL.md's fabric-backup section, MenuItems.xml's restore dialog
warning/checkbox labels, and CLAUDE.md's fabric_backup.py row now
describe restore extracting the bridge node's storage too (stop, then
extract, then restart), instead of the pre-#136 report-and-skip-only
behaviour. Strikes the #136 row in HANDOVER.md's open-issues table.

Bumps PluginVersion 2026.8.24 -> 2026.8.25 (Info.plist).

Refs #136

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Unpt5UPRdLoZkahH6a4gL
…r predated the restore (#136)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Unpt5UPRdLoZkahH6a4gL
@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The restore flow now handles controller and Matter bridge storage. It validates archive contents, coordinates bridge and controller lifecycle operations, preserves existing directories for rollback, reports restart outcomes, and integrates bridge controls into the plugin.

Changes

Bridge storage restore

Layer / File(s) Summary
Archive validation and bridge extraction
indigo-matter.indigoPlugin/Contents/Server Plugin/fabric_backup.py, tests/test_fabric_backup.py
Archives now support bridge-node/ members. Validation rejects unsafe paths and empty extraction results before process changes.
Coordinated restore and rollback
indigo-matter.indigoPlugin/Contents/Server Plugin/fabric_backup.py, tests/test_fabric_backup.py
Restore stops processes, moves existing storage aside, extracts both storage areas, restarts previously active bridges, and restores state after failures.
Plugin wiring and restore reporting
indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py, tests/test_plugin_behaviour.py
The plugin supplies bridge controls and storage paths and logs bridge restoration outcomes.
Documentation and release updates
CLAUDE.md, docs/HANDOVER.md, docs/INSTALL.md, indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml, indigo-matter.indigoPlugin/Contents/Info.plist
Documentation describes bridge restoration behavior, and the plugin version changes to 2026.9.0.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant Plugin
  participant restore_backup
  participant BridgeProcess
  participant ControllerStorage
  participant BridgeStorage
  User->>Plugin: Confirm fabric restore
  Plugin->>restore_backup: Pass storage paths and bridge control
  restore_backup->>BridgeProcess: Stop bridge
  restore_backup->>ControllerStorage: Move aside and restore controller files
  restore_backup->>BridgeStorage: Move aside and restore bridge files
  restore_backup->>BridgeProcess: Restart previously active bridge
  restore_backup-->>Plugin: Return restoration and restart status
  Plugin-->>User: Log restore outcome
Loading

Possibly related issues

  • Issue 136 — This PR implements bridge-storage restoration and its bridge stop/start lifecycle.

Possibly related PRs

Suggested reviewers: claude

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.86% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: restoring the bridge node's storage during backup restoration.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/136-wire-bridge-restore

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

simons-plugins and others added 2 commits August 9, 2026 16:43
…136)

F1 (HIGH, confirmed by 2 review agents + live repro): _move_aside_for_restore
performed two renames outside restore_backup's try block. A controller
rename that succeeded followed by a bridge rename that raised (e.g. a
leftover same-stamp .pre-restore- dir) let a raw OSError escape: no
rollback, the controller fabric stranded aside, both daemons left stopped,
and the UI dialog claiming "your existing fabric was preserved" while it
sat outside the live path.

_move_aside_for_restore is now all-or-nothing: a failing bridge rename
undoes the controller rename before re-raising, so after the function
returns or raises, either both sides are aside or neither is. If the undo
itself also fails (a double fault), both paths are named explicitly for
manual recovery.

restore_backup wraps the move-aside call in its own except OSError: it
best-effort restarts matter-server and (if the bridge was alive) the
bridge node, then raises a wrapped RuntimeError — the disk is unchanged in
the single-fault case, and this failure never reaches the existing
_rollback path (whose contract is unchanged: it can now assume both sides
moved, or the call never returned).

Tests (TDD — written first, watched fail against the raw OSError, then
fixed):
- test_a_failing_bridge_move_aside_undoes_the_controller_move_and_restarts_both
- test_a_failing_bridge_move_aside_restart_failures_are_logged

Mutation-verified: removing the undo rename, or removing either restart,
independently fails these tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Unpt5UPRdLoZkahH6a4gL
… coverage (#136)

Remaining review-fix batch on top of the F1 move-aside fix:

A2 (F2, MED): _stop_for_restore's abort path discarded the bridge restart
result when the controller failed to stop. Now logs an ERROR naming the
bridge (matching the wording R4/A1 already use) instead of swallowing it.

A3 (F3, LOW): _control_is_alive silently treated a raising is_alive() as
alive with no trace. Takes a log param and logs a debug line, so a
persistently-raising probe is distinguishable from one never called.

A4 (F4, LOW): _rollback's bridge-rollback-mechanics failure message always
said "previous storage is PRESERVED at %s" even when moved_aside_to was
None (no previous bridge dir to preserve). Now two message shapes for the
same failure, keyed on whether a previous copy actually exists
(_bridge_rollback_failure_message, extracted to keep _rollback's branch
count under the pylint gate).

A5: PluginVersion 2026.8.25 -> 2026.9.0 (minor: user-visible restore
behaviour + dialog text + doc changes).

Docstring/comment precision (comment-analyzer findings):
- Zip-slip docstring example changed from a two-dot escape (which also
  escapes under the raw archive-relative name, so it doesn't demonstrate
  the point) to the one-dot discriminating case.
- Dropped three dangling "§3.3" references to a section that doesn't exist
  in this repo.
- "escalated at CRITICAL" reworded to "at ERROR with a CRITICAL: message
  prefix" throughout (logging.CRITICAL is never actually used).
- Plan-private labels (D, R1-R4) replaced with descriptive phrases.
- _restart_bridge's docstring now names all four call sites (a fourth,
  the A1 move-aside path, exists since the previous commit).
- plugin.py's XAC1 latch comment: case 3 now states the latch is left
  EXACTLY as it was, not unconditionally "stays SET"; case 2's "an agent
  this session never started" (false once restore's start() succeeds)
  reworded to "an agent whose lifecycle this session does not own".
- MenuItems.xml restore warning hedges "stopped first and restarted
  afterwards" to cover the not-currently-running bridge case.
- CLAUDE.md fabric_backup row: names all three bridge-skip reasons, not
  just "no control".
- HANDOVER.md: single bracketed annotation on the now-wired deferred item.

Test additions (test-analyzer, all rated >= 3):
- FakeLogger gains a debug() recorder.
- R1 bridge-rollback-mechanics failure (rename-back raises) no longer
  aborts the controller's own rollback; message shape asserted.
- _restart_bridge's raise path after an otherwise-successful restore is
  reported (ERROR + debug), not swallowed.
- Bridge control given but no storage path -> warns, controller-only.
- Bridge-side H4 (emptied extraction) refuses and rolls both dirs back.
- Both remaining bridge/controller storage-path overlap clauses.
- Directory entries (not just files) in the archive extract cleanly on the
  prefix-stripping bridge path.
- First-ever test of the pre-existing controller rollback-mechanics
  CRITICAL branch, with the bridge_note appended.
- _control_is_alive edge cases: no is_alive() method, and a raising probe.
- Zip-slip archive strengthened with the one-dot variant alongside the
  two-dot one; both existing zip-slip tests assert the control was never
  touched.
- _SKIP_MSG reason-clause text pinned for the no-control and overlap
  cases.
- Plugin-side: bridge_started=False leg asserts the diagnosis-fallback
  text; new bridge_moved_aside_to=None/bridge_started=None leg asserts no
  "None"/"preserved"/"restarted" claims (cb395f8 pin, tightened).
- A2's abort-path restart-failure ERROR test.

Mutation-verified (in addition to the F1 pair already verified): letting
the R1 bridge-rollback exception propagate, removing _restart_bridge's
try/except, and deleting the bridge H4 empty-check each independently
fail their new test. Re-confirmed two of the original PR's mutations
still kill (the _safe_extract prefix strip, the aliveness gate).

Suite: 2384 passed (baseline 2371 + 13). pylint: fabric_backup 9.97/10
(baseline 9.96), plugin.py 9.23/10 (unchanged), combined 9.36/10
(baseline 9.35).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Unpt5UPRdLoZkahH6a4gL

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
tests/test_fabric_backup.py (1)

18-19: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use these constants in _make_bridge_storage so the fixture and the assertions cannot diverge.

_make_bridge_storage at Lines 550-553 repeats the same two JSON literals. The round-trip tests at Lines 754-757 compare against these constants. If someone edits one copy, the tests fail with a value mismatch rather than pointing at the fixture.

♻️ Proposed change
     files = files or {
-        "identity.json": '{"installId": "abc", "passcode": 20202021, "discriminator": 3840}',
-        "endpoint-map.json": '{"version": 1, "endpoints": {"indigo-101": 2}}',
+        "identity.json": _IDENTITY_JSON,
+        "endpoint-map.json": _ENDPOINT_MAP_JSON,
         "node-indigo-matter-bridge/state.json": "{}",
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_fabric_backup.py` around lines 18 - 19, Update
_make_bridge_storage to use the existing _IDENTITY_JSON and _ENDPOINT_MAP_JSON
constants instead of duplicating their JSON literals, keeping the fixture data
aligned with the round-trip assertions.
indigo-matter.indigoPlugin/Contents/Server Plugin/fabric_backup.py (1)

395-395: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Add # noqa: BLE001 to match the module's existing suppression style.

Ruff reports BLE001 on both new broad excepts. Elsewhere in this module (lines 698 and 821) the same construct carries # noqa: BLE001. These two sites carry only the pylint pragma, so Ruff still flags them.

♻️ Proposed change
-    except Exception as exc:  # pylint: disable=broad-except
+    except Exception as exc:  # noqa: BLE001  # pylint: disable=broad-except

Also applies to: 420-420

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/fabric_backup.py at line
395, Update both broad exception handlers in the relevant error-handling blocks,
including the handlers around `except Exception as exc`, to add the module’s
existing `# noqa: BLE001` suppression alongside the current pylint pragma. Keep
the exception behavior unchanged and apply the same annotation consistently at
both sites.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/HANDOVER.md`:
- Line 1503: Update the deferred-list entry around “skips” in the handover
documentation to remove the stale “reports and skips” status. Reflect that
fabric_backup.restore_backup extracts bridge members when bridge control and
storage are usable, or remove the item entirely from the deferred list.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/fabric_backup.py:
- Around line 664-690: Wrap server_control.start() in the move-aside failure
handler with the same exception handling used by _rollback, preserving the
existing CRITICAL recovery log and allowing the bridge restart logic to execute
when start() raises. Ensure the handler still raises the contextual RuntimeError
from the original OSError, including the start failure as appropriate, rather
than letting the start exception replace it.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/MenuItems.xml:
- Around line 328-335: Qualify the bridge-restore confirmation in MenuItems.xml
so bridge storage replacement is conditional on controller availability, a
usable non-overlapping storage path, and archive presence. Update
docs/INSTALL.md to document every fallback condition and the manual-copy
procedure; update docs/HANDOVER.md to remove or correct the stale deferred
“reports and skips” item.

---

Nitpick comments:
In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/fabric_backup.py:
- Line 395: Update both broad exception handlers in the relevant error-handling
blocks, including the handlers around `except Exception as exc`, to add the
module’s existing `# noqa: BLE001` suppression alongside the current pylint
pragma. Keep the exception behavior unchanged and apply the same annotation
consistently at both sites.

In `@tests/test_fabric_backup.py`:
- Around line 18-19: Update _make_bridge_storage to use the existing
_IDENTITY_JSON and _ENDPOINT_MAP_JSON constants instead of duplicating their
JSON literals, keeping the fixture data aligned with the round-trip assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1d8644e3-6e7d-4456-886c-b0a25886a177

📥 Commits

Reviewing files that changed from the base of the PR and between 0f797d6 and cfa301a.

📒 Files selected for processing (9)
  • CLAUDE.md
  • docs/HANDOVER.md
  • docs/INSTALL.md
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml
  • indigo-matter.indigoPlugin/Contents/Server Plugin/fabric_backup.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/plugin.py
  • tests/test_fabric_backup.py
  • tests/test_plugin_behaviour.py

Comment thread docs/HANDOVER.md
3. **Restoring the bridge-node storage from a backup** needs a bridge `stop()`/
`start()` control — E7's launchd agent. Backup is done; restore reports and
skips (above).
skips (above). [wired by #136, v2026.9.0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the stale “reports and skips” status.

Line 1503 still describes the pre-#136 behavior. fabric_backup.restore_backup now extracts bridge members when bridge control and storage are usable. Replace the stale sentence with the conditional behavior, or remove this item from the deferred list.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/HANDOVER.md` at line 1503, Update the deferred-list entry around “skips”
in the handover documentation to remove the stale “reports and skips” status.
Reflect that fabric_backup.restore_backup extracts bridge members when bridge
control and storage are usable, or remove the item entirely from the deferred
list.

Comment on lines 664 to +690
try:
os.makedirs(storage_path, exist_ok=True)
_safe_extract(archive_path, storage_path)
# H4: a validly-zipped but empty restore is a silent wipe. Refuse it.
if not _is_nonempty_dir(storage_path):
raise RuntimeError(
f"Restored storage dir is empty after extracting {archive_path}; "
"refusing to leave the fabric wiped"
moved_aside_to = _move_aside_for_restore(storage_path, plan, now, log)
except OSError as exc:
# Best-effort: put both daemons back the way we found them. This is
# NOT routed through _rollback — there is nothing for it to undo, the
# move-aside itself already did (or explicitly failed to, and logged
# why) — this only needs to bring the daemons back up.
if not server_control.start():
log.error(
"CRITICAL: matter-server is DOWN after a failed move-aside during restore. "
"To recover manually: check ~/Library/Logs/indigo-matter/matter-server.err.log "
"and start matter-server (it should pick up the original fabric at %s).",
storage_path,
)
# C1: start() returns a bool; False (launchctl failed) is a failure that
# must trigger rollback exactly like an exception would.
if plan is not None and plan.was_alive:
if not _restart_bridge(plan, log):
log.error(
"The Matter bridge node did not restart after a failed move-aside during "
"restore. Its pairings are intact; exported accessories are unavailable "
"until it starts — check the bridge node's error log, then reload the "
"plugin."
)
raise RuntimeError(
f"Fabric restore from {archive_path} failed while moving the existing storage "
"aside; the disk is unchanged unless the error above says otherwise — both "
"daemons were restarted"
) from exc

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Guard server_control.start() in the move-aside failure handler.

server_control.start() at Line 671 can raise. _rollback wraps the same call in try/except at Lines 819-825 for that reason; this handler does not. If start() raises here, three things follow:

  • The bridge that this restore stopped is never restarted, because Lines 678-685 are skipped.
  • The raised exception replaces the wrapped RuntimeError, so the caller loses the "moving the existing storage aside" context that menuRestoreFabricBackup relies on.
  • The CRITICAL: recovery guidance for a down controller is never logged.

The existing tests exercise a start() that returns False on this path, not one that raises.

🛡️ Proposed fix
-        if not server_control.start():
+        try:
+            started = server_control.start()
+        except Exception as start_exc:  # noqa: BLE001  # pylint: disable=broad-except
+            started = False
+            log.error("matter-server restart raised after a failed move-aside: %s", start_exc)
+        if not started:
             log.error(
                 "CRITICAL: matter-server is DOWN after a failed move-aside during restore. "
                 "To recover manually: check ~/Library/Logs/indigo-matter/matter-server.err.log "
                 "and start matter-server (it should pick up the original fabric at %s).",
                 storage_path,
             )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
try:
os.makedirs(storage_path, exist_ok=True)
_safe_extract(archive_path, storage_path)
# H4: a validly-zipped but empty restore is a silent wipe. Refuse it.
if not _is_nonempty_dir(storage_path):
raise RuntimeError(
f"Restored storage dir is empty after extracting {archive_path}; "
"refusing to leave the fabric wiped"
moved_aside_to = _move_aside_for_restore(storage_path, plan, now, log)
except OSError as exc:
# Best-effort: put both daemons back the way we found them. This is
# NOT routed through _rollback — there is nothing for it to undo, the
# move-aside itself already did (or explicitly failed to, and logged
# why) — this only needs to bring the daemons back up.
if not server_control.start():
log.error(
"CRITICAL: matter-server is DOWN after a failed move-aside during restore. "
"To recover manually: check ~/Library/Logs/indigo-matter/matter-server.err.log "
"and start matter-server (it should pick up the original fabric at %s).",
storage_path,
)
# C1: start() returns a bool; False (launchctl failed) is a failure that
# must trigger rollback exactly like an exception would.
if plan is not None and plan.was_alive:
if not _restart_bridge(plan, log):
log.error(
"The Matter bridge node did not restart after a failed move-aside during "
"restore. Its pairings are intact; exported accessories are unavailable "
"until it starts — check the bridge node's error log, then reload the "
"plugin."
)
raise RuntimeError(
f"Fabric restore from {archive_path} failed while moving the existing storage "
"aside; the disk is unchanged unless the error above says otherwise — both "
"daemons were restarted"
) from exc
try:
moved_aside_to = _move_aside_for_restore(storage_path, plan, now, log)
except OSError as exc:
# Best-effort: put both daemons back the way we found them. This is
# NOT routed through _rollback — there is nothing for it to undo, the
# move-aside itself already did (or explicitly failed to, and logged
# why) — this only needs to bring the daemons back up.
try:
started = server_control.start()
except Exception as start_exc: # noqa: BLE001 # pylint: disable=broad-except
started = False
log.error("matter-server restart raised after a failed move-aside: %s", start_exc)
if not started:
log.error(
"CRITICAL: matter-server is DOWN after a failed move-aside during restore. "
"To recover manually: check ~/Library/Logs/indigo-matter/matter-server.err.log "
"and start matter-server (it should pick up the original fabric at %s).",
storage_path,
)
if plan is not None and plan.was_alive:
if not _restart_bridge(plan, log):
log.error(
"The Matter bridge node did not restart after a failed move-aside during "
"restore. Its pairings are intact; exported accessories are unavailable "
"until it starts — check the bridge node's error log, then reload the "
"plugin."
)
raise RuntimeError(
f"Fabric restore from {archive_path} failed while moving the existing storage "
"aside; the disk is unchanged unless the error above says otherwise — both "
"daemons were restarted"
) from exc
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/fabric_backup.py around
lines 664 - 690, Wrap server_control.start() in the move-aside failure handler
with the same exception handling used by _rollback, preserving the existing
CRITICAL recovery log and allowing the bridge restart logic to execute when
start() raises. Ensure the handler still raises the contextual RuntimeError from
the original OSError, including the start failure as appropriate, rather than
letting the start exception replace it.

Comment on lines +328 to +335
<Label>Restoring REPLACES the current fabric and restarts matter-server. If the backup includes the Matter bridge node's storage, that is restored too — if the bridge node is running it is stopped and started around the swap; a stopped bridge stays stopped. The current fabric (and bridge storage, if restored) is moved aside, not deleted, so a bad restore is reversible. Requires 'Manage LaunchAgent' to be on.</Label>
</Field>
<Field id="backup" type="menu">
<Label>Backup to restore:</Label>
<List class="self" method="getFabricBackups"/>
</Field>
<Field id="confirm" type="checkbox" defaultValue="false">
<Label>I understand this replaces the current fabric and will restart matter-server.</Label>
<Label>I understand this replaces the current fabric (and the Matter bridge node's storage, if the backup has it) and will restart matter-server.</Label>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Keep bridge-restore documentation consistent with the runtime contract.

The implementation can restore the controller while skipping bridge storage when bridge control is unavailable, the bridge storage path is unusable, or the paths overlap. The confirmation and documentation currently do not state this complete fallback behavior.

  • indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml#L328-L335: qualify the confirmation text so archive presence does not imply unconditional bridge replacement.
  • docs/INSTALL.md#L637-L648: document all fallback conditions and the manual-copy procedure.
  • docs/HANDOVER.md#L1503-L1503: replace the stale “reports and skips” status or remove the item from the deferred list.
Proposed wording
- If the backup includes the Matter bridge node's storage, that is restored too.
+ If the backup includes the Matter bridge node's storage, it is restored when
+ bridge control and a usable, non-overlapping storage path are available.
+ Otherwise, the bridge files are reported and skipped for manual recovery.
📍 Affects 3 files
  • indigo-matter.indigoPlugin/Contents/Server Plugin/MenuItems.xml#L328-L335 (this comment)
  • docs/INSTALL.md#L637-L648
  • docs/HANDOVER.md#L1503-L1503
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/MenuItems.xml around lines
328 - 335, Qualify the bridge-restore confirmation in MenuItems.xml so bridge
storage replacement is conditional on controller availability, a usable
non-overlapping storage path, and archive presence. Update docs/INSTALL.md to
document every fallback condition and the manual-copy procedure; update
docs/HANDOVER.md to remove or correct the stale deferred “reports and skips”
item.

…e fault (#136)

Closing repair pass from five final-review agents:

- F1: restore_backup's move-aside except handler had the ONE unguarded
  server_control.start() in the module — a raise there skipped the
  CRITICAL log, the bridge restart, and the wrapped RuntimeError.
  Wrapped it like _rollback's start() already is. Also hardened
  _stop_for_restore: a RAISING server_control.stop() used to leave a
  just-stopped bridge down with no restart attempt and no log line.
- F2: the double-fault path (bridge rename fails, then undoing the
  controller rename ALSO fails) used to re-raise the same bare OSError
  as the single fault, so restore_backup couldn't tell them apart and
  unconditionally restarted matter-server — recreating the missing
  storage_path as a fresh empty fabric that then blocks the manual `mv`
  the error message prescribes. _move_aside_for_restore now signals the
  double fault via a private _MoveAsideDoubleFault(OSError); the handler
  skips the controller start on that path (nothing to come up on) but
  still best-effort restarts the bridge (its dir was never touched), and
  names the stranded aside path in the wrapped RuntimeError.
- F3: the single-fault message's "both daemons were restarted" overclaimed
  (either restart may have failed) — reworded to say a restart was
  attempted and to point at the log.
- F4: added the missing test for _bridge_rollback_failure_message's
  no-previous-copy shape — deleting that branch left the suite green
  before this.
- F5: :func: -> :meth: for LaunchAgent.is_alive; stripped the leftover
  plan-private labels (F4/A1/A2) from prose, replaced with descriptive
  wording.

63 fabric_backup tests (was 60), full suite 2387 (was 2384). pylint
fabric_backup.py 9.97/10.
@simons-plugins
simons-plugins merged commit c707a8b into main Aug 9, 2026
2 checks passed
@simons-plugins
simons-plugins deleted the feat/136-wire-bridge-restore branch August 9, 2026 19:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wire bridge-storage restore (fabric_backup currently reports-and-skips)

1 participant