Skip to content

fix(export): restore endpoints at startup so the bridge is never online-and-empty (#141) [no-release] - #142

Merged
simons-plugins merged 4 commits into
mainfrom
fix/141-restore-endpoints-at-startup
Aug 6, 2026
Merged

fix(export): restore endpoints at startup so the bridge is never online-and-empty (#141) [no-release]#142
simons-plugins merged 4 commits into
mainfrom
fix/141-restore-endpoints-at-startup

Conversation

@simons-plugins

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

Copy link
Copy Markdown
Owner

Fixes #141 — the root cause of Apple Home resetting exported accessories' rooms on every bridge-node restart.

The defect

The node called server.start() with an aggregator holding zero children and stayed empty until the plugin attached — 23 seconds on the reference server after a reboot. Apple reconnects inside that window, reads an empty PartsList, concludes every accessory is gone, and treats them as new arrivals when they reappear: dumped in the bridge's room with metadata the user can no longer edit.

The fix

  • endpoint-map.jsonschema v2: entries carry role and label beside the number. v1 files load and migrate in place; a bare number keeps its number and is simply not restorable until the next attach fills in the rest. A malformed role/label never costs the entry its number.
  • restoreEndpoints() runs before server.start(), rebuilding each restorable entry through the ordinary registry path with reachable: false and role defaults — same Endpoint.id, same persisted number.
  • attach stays authoritative and reconciles against the restored set.

⊗ Evidence

New restore.test.ts (11 tests, real ServerNodes). The headline patches ServerNode.prototype.start and snapshots the aggregator's children at the instant start is invoked — not after BridgeNode.start() returns, which would pass with the restore in the wrong place.

  • Move the restore below server.start()exactly 1 failure, the headline. That mutation is the before-state of the bug.
  • Delete the restore entirely → 9 of 11 fail.

Also covered: persisted numbers not fresh allocations; unreachable until attach; 0 created, N updated on reconcile; v1 migration across three boots; the §3.1 mass-removal guard still armed against a restored set; no spurious drift; both refusal routes restore nothing.

Verification

TS 366 (from 347), Python 2243, pylint 9.42. bridge-node 0.6.0, PluginVersion 2026.8.4.

Needs Simon

An npm publish of indigo-matter-bridge@0.6.0DEFAULT_INSTALL_SPEC now pins it and only 0.5.0 is on the registry.

Behaviour change worth a decision

With the guard armed at first attach, one pre-existing scenario fires a step earlier: a non-empty allow-list whose entries are all skipped by the classifier sends [] without intent → mass_removal_refused → halt. Previously the first attach after a restart slipped through as a silent no-op and halted on the next one. Fix, if wanted, is plugin-side.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S

Summary by CodeRabbit

  • New Features

    • Restores accessories before the bridge becomes available, preserving endpoint numbers and visibility across restarts.
    • Supports endpoint roles and labels for more accurate restoration and updates.
    • Migrates legacy endpoint maps automatically while safely handling invalid metadata.
    • Improves replacement handling when configured exports cannot be bridged.
  • Bug Fixes

    • Prevents accessories from reappearing as new items or losing room assignments after restarts.
    • Safely skips unsupported or refused restorations without blocking startup.
    • Removes restoration records for endpoints that are intentionally removed.
  • Documentation

    • Added restoration, migration, troubleshooting, and installation guidance.
  • Chores

    • Updated the bridge to 0.6.0 and plugin to 2026.8.4.

…ne-and-empty (#141)

The node called `server.start()` with a childless aggregator and stayed empty
until the plugin attached — 23 seconds on jarvis after a reboot. Apple
reconnects inside that window, reads an empty PartsList, concludes every
accessory has gone, and re-adds them as NEW accessories in the bridge's own
room with metadata the user can no longer edit. Every restart destroyed the
user's room assignments for every exported device.

`endpoint-map.json` gains schema version 2: each entry now carries `role` and
`label` alongside `number` — exactly what `createEndpoint` needs. `BridgeNode.
start()` rebuilds every restorable entry through the ordinary create path (same
`Endpoint.id`, same persisted number, `reachable: false`, role-default state)
BEFORE `server.start()`. `attach` stays authoritative and reconciles against
the restored set as before.

Version 1 files are migrated in place, never discarded: their bare numbers are
the one thing that cannot be re-derived and the thing every paired ecosystem is
keyed on. They are simply not restorable until the next attach records a role
and label.

A node in a refuse-to-start state restores nothing: `identityUnreadable` is
known before the stack starts and skips the restore; `fabricStorageLost` can
only be decided after `server.start()`, so the restored set is withdrawn there
instead — safe because that state means no fabrics.

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

coderabbitai Bot commented Aug 6, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@simons-plugins, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 47 seconds

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 86fefafb-bb8d-4374-9f7a-7829a8ffa3d3

📥 Commits

Reviewing files that changed from the base of the PR and between 31b054e and e22e861.

📒 Files selected for processing (3)
  • bridge-node/package.json
  • bridge-node/test/restore.test.ts
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py
📝 Walkthrough

Walkthrough

The bridge now persists endpoint numbers with role and label metadata, migrates legacy maps, restores endpoints before Matter startup, and reconciles them after plugin attachment. The plugin also handles fully unbridgeable allow-lists with explicit replacement intent.

Changes

Endpoint restoration

Layer / File(s) Summary
Endpoint map schema and persistence
CLAUDE.md, bridge-node/src/endpoint-map.ts, bridge-node/src/endpoints.ts, bridge-node/test/endpoint-map.test.ts, bridge-node/test/persistence.test.ts
Schema v2 stores endpoint numbers, roles, and labels. The loader accepts v1 files and preserves valid numbers when metadata is invalid.
Registry and startup restoration
bridge-node/src/registry.ts, bridge-node/src/node.ts, bridge-node/test/restore.test.ts, bridge-node/test/units.test.ts
The registry exposes endpoint identities and restores endpoints as unreachable. BridgeNode restores valid records before Matter startup and withdraws them on refusal.
Allow-list replacement handling
indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py, indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py, tests/test_bridge_client.py, tests/test_export_bridge.py
The plugin distinguishes empty allow-lists from non-empty lists with no bridgeable endpoints. It sends replace_all when required and latches aggregate warnings.
Protocol, release, and deployment updates
docs/BRIDGE_PROTOCOL.md, docs/HANDOVER.md, docs/INSTALL.md, bridge-node/package.json, indigo-matter.indigoPlugin/Contents/Info.plist, indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py
Documentation describes restoration and schema v2. Package metadata references bridge 0.6.0 and plugin 2026.8.4.

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

Sequence Diagram(s)

sequenceDiagram
  participant EndpointMapStore
  participant BridgeNode
  participant EndpointRegistry
  participant MatterServer
  EndpointMapStore->>BridgeNode: provide restorable endpoint records
  BridgeNode->>EndpointRegistry: restore valid endpoint specifications
  EndpointRegistry-->>BridgeNode: return restored endpoint count
  BridgeNode->>MatterServer: start with restored endpoints
  BridgeNode->>EndpointRegistry: withdraw restored endpoints if startup refuses
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes unrelated test-runner mDNS port changes and release-version updates that are not required to restore endpoints at startup. Move the mDNS test changes and release or distribution updates to separate pull requests unless they are required for the #141 fix.
Docstring Coverage ⚠️ Warning Docstring coverage is 60.42% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly identifies the primary change: restoring endpoints at startup to prevent an online empty bridge.
Linked Issues check ✅ Passed The changes satisfy [#141] by restoring persisted endpoints before startup, preserving identity and numbers, marking them unreachable, and retaining authoritative reconciliation.
✨ 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 fix/141-restore-endpoints-at-startup

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.

@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: 2

🤖 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 `@bridge-node/src/endpoints.ts`:
- Around line 98-103: Update the UniqueID parsing logic around the visible
digits validation and indigoDeviceId conversion to accept only canonical
representations: after parsing, require uniqueIdFor(indigoDeviceId) to exactly
equal the original uniqueId, otherwise return undefined. Add regression coverage
for indigo-01 and indigo--0, while preserving valid canonical IDs and
safe-integer validation.

In `@docs/HANDOVER.md`:
- Around line 1573-1575: Update the later “The room mystery — SOLVED” section in
docs/HANDOVER.md to remove the superseded claim that stale Apple records were
not a bridge fault. Mark the section obsolete or replace it with the
restart-time empty-aggregator diagnosis consistent with issue `#141`, including
the required upgrade guidance.
🪄 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: 10fb5c5b-32db-46bd-876b-30f82d8edcad

📥 Commits

Reviewing files that changed from the base of the PR and between 742a907 and fd34fb0.

📒 Files selected for processing (15)
  • CLAUDE.md
  • bridge-node/package.json
  • bridge-node/src/endpoint-map.ts
  • bridge-node/src/endpoints.ts
  • bridge-node/src/node.ts
  • bridge-node/src/registry.ts
  • bridge-node/test/endpoint-map.test.ts
  • bridge-node/test/persistence.test.ts
  • bridge-node/test/restore.test.ts
  • bridge-node/test/units.test.ts
  • docs/BRIDGE_PROTOCOL.md
  • docs/HANDOVER.md
  • docs/INSTALL.md
  • indigo-matter.indigoPlugin/Contents/Info.plist
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py

Comment on lines +98 to +103
const digits = uniqueId.slice(UNIQUE_ID_PREFIX.length);
if (!/^-?\d+$/.test(digits)) {
return undefined;
}
const indigoDeviceId = Number(digits);
return Number.isSafeInteger(indigoDeviceId) ? indigoDeviceId : undefined;

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

Reject non-canonical UniqueID values.

indigo-01 passes the regular expression and becomes device ID 1, but uniqueIdFor(1) produces indigo-1. A hand-edited map can then restore one identity while persisting another identity key.

Require the parsed value to round-trip through uniqueIdFor before restoring it. Add regression cases for indigo-01 and indigo--0.

Proposed fix
     const indigoDeviceId = Number(digits);
-    return Number.isSafeInteger(indigoDeviceId) ? indigoDeviceId : undefined;
+    return Number.isSafeInteger(indigoDeviceId) && uniqueIdFor(indigoDeviceId) === uniqueId
+        ? indigoDeviceId
+        : undefined;
📝 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
const digits = uniqueId.slice(UNIQUE_ID_PREFIX.length);
if (!/^-?\d+$/.test(digits)) {
return undefined;
}
const indigoDeviceId = Number(digits);
return Number.isSafeInteger(indigoDeviceId) ? indigoDeviceId : undefined;
const digits = uniqueId.slice(UNIQUE_ID_PREFIX.length);
if (!/^-?\d+$/.test(digits)) {
return undefined;
}
const indigoDeviceId = Number(digits);
return Number.isSafeInteger(indigoDeviceId) && uniqueIdFor(indigoDeviceId) === uniqueId
? indigoDeviceId
: undefined;
🤖 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 `@bridge-node/src/endpoints.ts` around lines 98 - 103, Update the UniqueID
parsing logic around the visible digits validation and indigoDeviceId conversion
to accept only canonical representations: after parsing, require
uniqueIdFor(indigoDeviceId) to exactly equal the original uniqueId, otherwise
return undefined. Add regression coverage for indigo-01 and indigo--0, while
preserving valid canonical IDs and safe-integer validation.

Comment thread docs/HANDOVER.md
Comment on lines +1573 to +1575
is 2026.8.3 with the bridge running under launchd. (Superseded later the same
day by issue #141 — see the top of this file: plugin `2026.8.4`, bridge `0.6.0`,
which still needs publishing.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Remove the superseded room-diagnosis conclusion.

The later “The room mystery — SOLVED” section still says that stale Apple records were not a bridge fault. That conflicts with the issue #141 diagnosis at Lines 539-548.

Mark that later section as obsolete or replace it with the restart-time empty-aggregator diagnosis. Otherwise, an operator can follow the obsolete conclusion and omit the required upgrade.

🤖 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` around lines 1573 - 1575, Update the later “The room
mystery — SOLVED” section in docs/HANDOVER.md to remove the superseded claim
that stale Apple records were not a bridge fault. Mark the section obsolete or
replace it with the restart-time empty-aggregator diagnosis consistent with
issue `#141`, including the required upgrade guidance.

Two defects the restore itself introduced, plus the mutation survivors that let
them through.

**Ghost accessories.** `check()` only ever added and refreshed, so once a v2
entry carried `role`/`label` it carried them for ever — including for a device
the user deliberately UN-EXPORTED. It stayed restorable, was rebuilt as a child
endpoint before every `server.start()`, and was removed again by the plugin's
next attach seconds later: the exact appear-then-vanish churn #141 exists to
eliminate, aimed at the devices the user had already removed, and a regression
of XAC7. Each ghost also spent a `REMOVAL_PACING_MS` slot on every attach while
counting towards neither the desired set nor the un-export debt, so an
accumulation of them ate the 8s attach floor for accessories nobody wanted.

On a removal the entry now loses `role`/`label` and keeps `number`:
`restorable()` filters on role AND label, so it goes non-restorable while §3.3's
allocation survives, and one re-export refills it at the same number — the same
accessory in every paired ecosystem, not a new one. The removals are MEASURED,
by diffing the live set across one mutation, never inferred from absence: an
empty live set proves nothing (a node that never attached has one, `seed([])`
knows nothing by design), so a factory reset, a seed, and an entry this build
cannot rebuild all leave the map alone.

**A permanent halt this PR made reachable — and the PR body has the before-state
backwards.** When the classifier empties a non-empty allow-list (device deleted,
no longer exportable, role lost, `states_for` raising) the attach goes out as
`[]` with no intent; with a restored live set §3.1's guard now fires, and
`mass_removal_refused` is in `HALTING_ATTACH_ERRORS`, so the client halts
permanently and does it again on every reload. The PR body says this "previously
slipped through as a silent no-op and halted on the next one". It did not halt
on the next one, or ever: with nothing created before the plugin attached, the
node's live set stayed empty on every attach, the guard was never armed, and the
state was UNREACHABLE. The restore is what reaches it — on a routine restart,
from a plausible user state (a one-device allow-list whose device was deleted).

`bridge_client._replace_all` now carries `intent: replace_all` there too, gated
on a new `export_count_provider` so a genuinely empty allow-list still carries
nothing and §3.1 keeps its teeth against the stale client it was written for; a
provider that raises fails towards the guard, not past it. Announced at WARNING
on both sides naming every skipped device, latched per cause because the
provider runs per reconnect. The declared count joins the attach-deadline
`max()`, being the only proxy for how much the node is about to remove. Chosen
over "do not attach at all": that leaves the node serving accessories the plugin
can neither drive nor push state to, with nothing to clear it, and §3.3's
retained numbers make the removal recoverable.

**Mutation survivors closed**, each verified fail-before: the restored
accessory's `label` (it really did come up as "Restored Accessory" with the
suite green), `registry.restore`'s per-spec try/catch (one corrupt entry aborted
the whole restore and the bridge booted empty), the
`indigoDeviceIdFrom`/`isRole`/`isSupportedRole` skip, the `label` half of the
`restorable()` predicate, `check`'s refresh-when-the-number-drifted branch, and
`states: {}`.

Also: `bridge_agent.py` no longer claims the package is unpublished (0.5.0 and
0.6.0 are both on the registry); BRIDGE_PROTOCOL §4.3 now says that only two of
the three refusal reasons restore literally nothing, because `fabricStorageLost`
restores then withdraws and that is wire-observable; `seed()` asserts it is a
full replace; `main.test.ts` pins its children to loopback and documents the
residual UDP 5353 contention it cannot fix.

The live-validated behaviour is untouched: both changes fire only on a removal
or an empty desired set, so a restart that restores N and attaches the same N
still reports `0 created, N updated`.

Suites 380 TS (from 366) and 2252 Python (from 2243), pylint 9.42 unchanged.
One review reported 353 TS; not reproducible — `node --test` without a prior
build skips `main.test.ts`'s 8 and yields 358, and `npm test` always builds.

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

@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: 4

🧹 Nitpick comments (3)
indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py (1)

763-763: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Use len(self._store) instead of len(self._store.all()).

ExportStore.all() builds a sorted tuple of every entry under the lock. This method needs only the count, and the rest of this file already uses len(self._store) for that (Lines 202, 311, 1293). The client calls this provider on every attach and again when sizing the attach deadline.

♻️ Proposed change
-        return len(self._store.all())
+        return len(self._store)
🤖 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/export_bridge.py at line
763, Update the affected count-returning method to use len(self._store) instead
of len(self._store.all()), avoiding materialization and sorting while preserving
the returned entry count.
docs/HANDOVER.md (1)

153-157: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Publishing 0.6.0 twice with different content is a silent-failure risk.

Line 68 records that 0.6.0 is on the registry. Lines 153-157 record that this branch changes node source after that publish and keeps package.json at 0.6.0. Two different builds then answer to the same version string.

bridge_agent.DEFAULT_INSTALL_SPEC pins 0.6.0 and test_bridge_agent.py asserts that the two agree, so the test stays green while a registry install delivers a bridge without the ghost-endpoint fix. The failure surfaces only in the field, as ghost accessories on a machine whose versions all report correct.

Bump to 0.6.1 and publish before merge, or add an explicit blocker line naming 0.6.1 as required so nobody reads the current state as releasable.

[operational]

🤖 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` around lines 153 - 157, The publishing handover must not
describe the unreleased post-0.6.0 changes as releasable with package.json still
at 0.6.0. Update the Publishing section in docs/HANDOVER.md to explicitly block
release until version 0.6.1 is required, or reflect that 0.6.1 has been bumped
and published before merge; keep bridge_agent.DEFAULT_INSTALL_SPEC and its
matching test aligned with the released version.
bridge-node/test/restore.test.ts (1)

629-654: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert a non-default state so the test distinguishes "wrote nothing" from "wrote false".

The test sets onOff: false and then asserts false after the restart. false is also the OnOff cluster's own default. A restore that explicitly wrote onOff: false would pass this test unchanged. Setting the lamp ON before the restart makes the assertion depend on the restore writing nothing.

♻️ Proposed change
-        const off = { ...KITCHEN_SPEC, states: { onOff: false } };
+        const on = { ...KITCHEN_SPEC, states: { onOff: true } };
         const first = await boot(storagePath);
         try {
-            await attach(first.client, "s0", [off]);
-            assert.equal(onOffOf(first.bridge.server, KITCHEN), false);
+            await attach(first.client, "s0", [on]);
+            assert.equal(onOffOf(first.bridge.server, KITCHEN), true);
         } finally {
             await first.close();
         }
 
         const second = await boot(storagePath);
         try {
             assert.equal(
                 onOffOf(second.bridge.server, KITCHEN),
-                false,
+                true,
                 "the restore wrote nothing, so nothing changed",
             );

This assumes matter.js persists the cluster value across the restart, which the current test already relies on.

🤖 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 `@bridge-node/test/restore.test.ts` around lines 629 - 654, Update the restore
test around the off fixture and restart assertion so it uses a non-default
on/off state before shutdown, such as turning the lamp on, then verifies the
restored value remains the cluster default after restart. Keep the test’s
purpose and existing boot/close flow unchanged, ensuring a restore that writes
the persisted value would fail the assertion.
🤖 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 `@bridge-node/test/main.test.ts`:
- Around line 17-18: Update the parallel-safety documentation near the ports
reference to avoid claiming PID-derived ports cannot collide; replace that
wording with “usually reduce collisions.” Do not change the port allocation or
process-spawning behavior.
- Around line 117-118: Update the test bridge setup around the LOOPBACK-derived
mDNS arguments to fail closed when LOOPBACK is undefined: throw or skip before
spawning the child rather than omitting --mdns-interface. Preserve
constrained-interface behavior for valid LOOPBACK values and ensure the child is
never started without that restriction.
- Around line 21-30: Make mDNS ownership deterministic for all tests creating
real ServerNode instances, rather than documenting the UDP 5353 race in
main.test.ts. Serialize the relevant test files or implement a cross-process
lock acquired before ServerNode startup and released during teardown, including
the registry, restore, persistence, integration, and main test paths.

In `@docs/BRIDGE_PROTOCOL.md`:
- Around line 136-146: In the documentation text around the restore guard
behavior, replace the unclear phrase “un-export debt” with the repository’s
established spelling, preferably “unexported endpoint debt,” while preserving
the existing meaning and surrounding explanation.

---

Nitpick comments:
In `@bridge-node/test/restore.test.ts`:
- Around line 629-654: Update the restore test around the off fixture and
restart assertion so it uses a non-default on/off state before shutdown, such as
turning the lamp on, then verifies the restored value remains the cluster
default after restart. Keep the test’s purpose and existing boot/close flow
unchanged, ensuring a restore that writes the persisted value would fail the
assertion.

In `@docs/HANDOVER.md`:
- Around line 153-157: The publishing handover must not describe the unreleased
post-0.6.0 changes as releasable with package.json still at 0.6.0. Update the
Publishing section in docs/HANDOVER.md to explicitly block release until version
0.6.1 is required, or reflect that 0.6.1 has been bumped and published before
merge; keep bridge_agent.DEFAULT_INSTALL_SPEC and its matching test aligned with
the released version.

In `@indigo-matter.indigoPlugin/Contents/Server` Plugin/export_bridge.py:
- Line 763: Update the affected count-returning method to use len(self._store)
instead of len(self._store.all()), avoiding materialization and sorting while
preserving the returned entry count.
🪄 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: 7834f64c-af16-40cb-b588-1d65677c80e7

📥 Commits

Reviewing files that changed from the base of the PR and between fd34fb0 and 31b054e.

📒 Files selected for processing (12)
  • bridge-node/src/endpoint-map.ts
  • bridge-node/src/node.ts
  • bridge-node/test/endpoint-map.test.ts
  • bridge-node/test/main.test.ts
  • bridge-node/test/restore.test.ts
  • docs/BRIDGE_PROTOCOL.md
  • docs/HANDOVER.md
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_client.py
  • indigo-matter.indigoPlugin/Contents/Server Plugin/export_bridge.py
  • tests/test_bridge_client.py
  • tests/test_export_bridge.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • indigo-matter.indigoPlugin/Contents/Server Plugin/bridge_agent.py
  • bridge-node/src/node.ts

Comment on lines +17 to +18
* **Parallel safety, and its limit.** The Matter and protocol ports are derived
* from the PID ({@link ports}) so concurrent runs cannot collide on them, and

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 | 🟡 Minor | ⚡ Quick win

Do not claim that PID-derived ports cannot collide.

ports reduces process.pid modulo 4,000 at Line 131. Two concurrent processes whose PIDs differ by 4,000 can produce the same ports. Replace “cannot collide” with “usually reduce collisions”, or reserve the ports before spawning.

🤖 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 `@bridge-node/test/main.test.ts` around lines 17 - 18, Update the
parallel-safety documentation near the ports reference to avoid claiming
PID-derived ports cannot collide; replace that wording with “usually reduce
collisions.” Do not change the port allocation or process-spawning behavior.

Comment on lines +21 to +30
* hosts for it. What that does NOT make ephemeral is **mDNS itself**: the
* responder binds UDP 5353, the port number is fixed by the protocol, and
* matter.js offers no knob to move it. Every other file that stands up a real
* `ServerNode` (`registry`, `restore`, `persistence`, `integration`) binds it
* too, and node's runner forks test files concurrently — so a run that
* interleaves them can still see a bind conflict on 5353. This test file is the
* one that surfaces it, because its nodes are separate PROCESSES that fail to
* start rather than in-process nodes that share a responder. If it flakes with
* an address-in-use or a start timeout, that is what happened; re-run it on its
* own (`node --test .test-build/test/main.test.js`) to confirm.

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 | 🏗️ Heavy lift

Make mDNS ownership deterministic instead of documenting a known race.

The comments confirm that concurrent tests can bind UDP 5353 and fail to start. Re-running one file after failure does not make npm test deterministic. Serialize tests that create real ServerNode instances, or add a cross-process lock around mDNS ownership.

The changed documentation identifies UDP 5353 as a shared resource across the test suite.

🤖 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 `@bridge-node/test/main.test.ts` around lines 21 - 30, Make mDNS ownership
deterministic for all tests creating real ServerNode instances, rather than
documenting the UDP 5353 race in main.test.ts. Serialize the relevant test files
or implement a cross-process lock acquired before ServerNode startup and
released during teardown, including the registry, restore, persistence,
integration, and main test paths.

Comment on lines +117 to +118
* outright. `undefined` if there is somehow no internal interface, in which case
* the flag is simply omitted and the child behaves exactly as it did before.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fail closed when no internal interface is available.

When LOOPBACK is undefined, Line 135 omits --mdns-interface. The child then uses unconstrained mDNS behavior and can advertise the test bridge on a real interface. Throw or skip before spawning instead of silently restoring the old behavior.

The PR objective requires the test bridge to remain constrained to an internal interface.

Proposed fail-closed change
 function ports(offset: number): string[] {
+    if (LOOPBACK === undefined) {
+        throw new Error("No internal network interface available for isolated Matter tests");
+    }
     const base = 41_000 + ((process.pid + offset * 37) % 4_000);
     return [
         "--matter-port", String(base),
         "--ws-port", String(base + 1),
-        ...(LOOPBACK === undefined ? [] : ["--mdns-interface", LOOPBACK]),
+        "--mdns-interface", LOOPBACK,
     ];
 }

Also applies to: 130-136

🤖 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 `@bridge-node/test/main.test.ts` around lines 117 - 118, Update the test bridge
setup around the LOOPBACK-derived mDNS arguments to fail closed when LOOPBACK is
undefined: throw or skip before spawning the child rather than omitting
--mdns-interface. Preserve constrained-interface behavior for valid LOOPBACK
values and ensure the child is never started without that restriction.

Comment thread docs/BRIDGE_PROTOCOL.md
Comment on lines +136 to +146
Because restore arms that guard from the first attach, the plugin now carries
the intent in **two** cases, not one: the un-export debt it already tracked
(XAC7), and an allow-list that is non-empty but whose every entry the classifier
skipped — a deleted Indigo device, a device that stopped being exportable, a
role this build cannot bridge, a `states_for` that raised. That second case sends
`[]` and is genuinely asking for an empty set; without the intent it would meet
an armed guard on an ordinary restart and be refused, and `mass_removal_refused`
halts the client permanently. It is announced in the Indigo log with every
skipped device named. A *genuinely* empty allow-list still carries no intent —
the guard must keep its teeth against exactly the stale client it was written
for.

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

Use a clear spelling for “unexported”.

Replace un-export debt with unexported endpoint debt or the repository’s established term. The current phrase is unclear.

🧰 Tools
🪛 LanguageTool

[grammar] ~136-~136: Ensure spelling is correct
Context: ... restore arms that guard from the first attach, the plugin now carries the intent in *...

(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)

🤖 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/BRIDGE_PROTOCOL.md` around lines 136 - 146, In the documentation text
around the restore guard behavior, replace the unclear phrase “un-export debt”
with the repository’s established spelling, preferably “unexported endpoint
debt,” while preserving the existing meaning and surrounding explanation.

Source: Linters/SAST tools

simons-plugins and others added 2 commits August 6, 2026 13:31
The startup-restore hardening (ghost accessories, the newly-reachable
halt, six mutation-survivor test gaps) landed after 0.6.0 was published,
so a registry install does not have it.

package.json and DEFAULT_INSTALL_SPEC move together because a test pins
them to each other. An older exact pin resolves fine against a registry
carrying newer versions — the pin is what decides which version users
actually get, which is why it has to move for the fix to ship.

Publish 0.7.0 to npm BEFORE this merges, or the install menu 404s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JxqGhP3DcENf68AZK21U4S
Three characterisation tests for the create path, written while diagnosing
issue #143 (exported accessories showing the wrong on/off state in Alexa and
Apple Home). All three pass, and that is the finding: the node is not the
fault.

They exist because the create path had no coverage of the case that matters
— an `Endpoint.id` matter.js has ALREADY persisted attribute values for.
`update()` has always followed up with `applyStates`; `create()` relies on
`createEndpoint` baking the spec's states into `initialState`, and whether a
persisted value outranks that was untested and load-bearing. It does not:

* in-process, after an un-export and re-export;
* across a node RESTART, so matter.js loads the value from disk rather than
  carrying a closed endpoint in memory — the exact field sequence from
  jarvis, and the one the in-process test cannot reach;
* through `upsert_endpoint` rather than `attach`, because that is the route
  the export dialog actually takes and it parses its spec separately.

Also worth recording for the #143 follow-up: matter.js does NOT flush a
constructor-supplied `initialState` to its store, so the on-disk
`…parts.<id>.onOff.onOff` files are not a reading of the live attribute.
Two of them said `false` on jarvis for accessories that were on, which sent
the diagnosis down a blind alley for a while.

Refs #143
@simons-plugins
simons-plugins merged commit 3e1229d into main Aug 6, 2026
3 checks passed
@simons-plugins
simons-plugins deleted the fix/141-restore-endpoints-at-startup branch August 6, 2026 13:26
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.

Bridge serves an EMPTY aggregator after restart — Apple resets rooms on every reboot

1 participant