Fixes the correctness findings from plan-review-overhead §A (+E4), each
with a regression test:
- A2+A5: core now owns entry state on both router paths. The
ConfigEntryRouter contract changes to raise ConfigEntryError on
failure; core maps it to SETUP_ERROR (setup) or leaves the entry
state untouched (refused unload — previously a refused unload was
wrongly marked NOT_LOADED while the remote entry stayed loaded).
Every SLF001 state poke moves out of router.py into
config_entries.py, which owns ConfigEntry.
- A4: the router setup/unload branches now honor entry.setup_lock, so
a direct async_setup during a reload can no longer run two
entry_setup RPCs concurrently for one entry.
- A3: SandboxBridge.async_teardown sweeps _mirrored_services off
hass.services — a respawned sandbox's re-registration was skipped by
the has_service guard, leaving every call on a dead channel forever.
- A6: the sandbox translation overlay merges inside
_async_get_component_strings before the title fallback, so a provider
dict without "title" gets the catalog/integration.name fill-in;
_TranslationCache._async_load returns to dev-identical.
- A7: proxy init + upsert share one _apply_description with symmetric
clearing — a dropped device_class/device_info no longer sticks.
- A8: the frame-size cap is enforced on write too (both channel
mirrors); an oversize payload fails its own call with
FrameTooLargeError (oversize replies come back as error frames)
instead of making the peer abort the whole channel.
- A1: state-attribute pushes run through json_safe — a datetime
attribute raised ValueError inside the fire-and-forget push task and
left main's proxy permanently stale. Main-side service_data/query
args get the same coercion (_wire_safe) before dict_to_struct.
- E4: FlowRunner.async_stop now stops the private hass (executor +
timer leak per in-process sandbox), and InProcessSandbox.stop runs
the graceful sandbox/shutdown first, like production.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QCotUYum6AoisyrxshoiJJ
Wire the A1 catalog hook into the two display paths a sandbox-only custom
falls through today:
- async_get_integration_descriptions (loader.py): append catalog
descriptors to the custom integration/helper buckets so the add-
integration picker lists them. On-disk customs carry richer metadata,
so the disk scan wins on a domain collision.
- _async_get_component_strings (helpers/translation.py): when a domain
has no on-disk Integration (IntegrationNotFound on main), take its
"title" from the catalog — a localized title_translations[lang] if
present, otherwise degrading to the descriptor name.
Tests: catalog entry appears in descriptions with picker name + defaults
+ helper-bucket routing; on-disk custom wins a collision; title fallback
uses title_translations and degrades to name when absent.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a sandbox-agnostic seam to the translation cache, mirroring the
sandbox.sources source-resolver convention:
- async_register_sandbox_translation_provider(hass, provider): a HassKey-backed
registry with an unregister callback. The provider is awaited inside the
cache load and returns {language: {domain: raw_strings}} for only the domains
it owns.
- _TranslationCache._async_load overlays the provider result onto
translation_by_language_strings after async_get_integrations and before
_build_category_cache, so sandboxed strings flow through the same flatten /
English-fallback / loaded machinery as on-disk strings. A custom sandboxed
domain (IntegrationNotFound on main) thus stops resolving to {}.
- _TranslationCache.async_invalidate + async_invalidate_translations wrapper:
the first eviction API (translations were never unloaded), called by the
sandbox when a custom integration is re-fetched at a new ref.
Core never raises on a provider; degrade-to-empty is the provider's contract.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>