Skip to content

fix(config): surface a user notice when a corrupt config.toml is reset (#5167) - #5340

Merged
senamakel merged 3 commits into
tinyhumansai:mainfrom
YellowSnnowmann:wt-5167
Aug 4, 2026
Merged

fix(config): surface a user notice when a corrupt config.toml is reset (#5167)#5340
senamakel merged 3 commits into
tinyhumansai:mainfrom
YellowSnnowmann:wt-5167

Conversation

@YellowSnnowmann

@YellowSnnowmann YellowSnnowmann commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Problem

Two Windows users' config.toml files failed UTF-8 validation and generated ~946 Sentry events. #5171 already fixed the flood: the loader rate-limits the error, renames the corrupt file to .corrupted.<ts>, and resets to defaults / a .bak backup. But that recovery was silent — the user was never told their settings had been reset, which was requirement (3) of the issue.

Delivering the notice is non-trivial because recovery happens at core boot, before the frontend socket connects, so a live core_notification publish would be lost. The persisted-notification path (#3805) is also not wired to a frontend sync-down, so persistence alone wouldn't surface it either.

Solution

A pull-based, race-free signal rather than a push:

  • Config::recovered_from_corruption (#[serde(skip)], runtime-only) is set in the loader's recovery branches. Per-load and pollution-free, so both the "recovered" and "clean" cases are unit-tested directly.
  • desktop::app_state::recovery_signal holds a process latch. bootstrap_core_runtime calls latch_from_config(&cfg) once at boot from the authoritative boot config; the latch bridges the gap that the per-load flag reads false on subsequent (now-healed) loads.
  • app_state_snapshot — which BootCheckGate/CoreStateProvider already fetch on boot — reports configRecovered. No new poller, no new RPC.
  • CoreStateProvider.refreshCore calls a one-shot maybeSurfaceConfigRecovery, dispatching one System notification into the existing notification center (respects category prefs; deduped by stable id; guarded so repeated polls don't re-fire).

Design note: a notification-bus approach was considered but rejected — it would have required activating the dormant #3805 sync-down, which on first boot would dump the entire backlog of previously-persisted-but-unread core notifications. The snapshot-flag approach is strictly scoped to this issue.

Submission Checklist

  • Tests added or updated (happy path + failure/edge case): loader sets the flag on non-UTF-8 recovery via both load_or_init and load_from_config_path, and does not set it for a valid config; recovery_signal latch unit tests; frontend one-shot dispatch test (surfaces once, no-op on repeat / when false).
  • Diff coverage ≥ 80% — passed CI (Rust Core Coverage cargo-llvm-cov + Frontend Checks green). Changed lines are covered by the added unit tests plus existing e2e coverage of the app_state_snapshot builder (app_state_credentials_raw_coverage_e2e.rs, json_rpc_e2e.rs) and the core-runtime bootstrap path.
  • Coverage matrix updated — N/A: behaviour-only change to an existing surface; no new feature ID.
  • All affected feature IDs listed under ## RelatedN/A: none.
  • No new external network dependencies introduced.
  • Manual smoke checklist updated — N/A: no new release-cut surface (reuses the existing app-state snapshot + notification center).
  • Linked issue closed via Closes #NNN in ## Related.

Impact

  • Desktop only in practice (the reported corruption is Windows config.toml), but the mechanism is platform-agnostic. No migration, no config-format change.
  • Wire change: one additive optional field configRecovered on app_state_snapshot; older/newer clients degrade to "no recovery". The Config field is #[serde(skip)] — never persisted.
  • No new network calls; the notice rides the existing boot snapshot poll.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: wt-5167
  • Commit SHA: see PR head

Validation Run

  • pnpm --filter openhuman-app format:check
  • pnpm typecheck (pnpm compile / tsc --noEmit)
  • Focused tests: cargo test --lib (recover / latch / non_utf8 groups) + vitest run src/lib/configRecoveryNotice.test.ts
  • Rust fmt/check (if changed): cargo fmt --check + cargo clippy -p openhuman -- -D warnings
  • Tauri fmt/check (if changed): cargo clippy --manifest-path app/src-tauri/Cargo.toml -- -D warnings (no Tauri Rust changed)

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: raise a one-shot user notice when a corrupt config.toml is auto-reset.
  • User-visible effect: a "Settings were reset" System notification in the notification center after recovery.

Parity Contract

Duplicate / Superseded PR Handling

  • Duplicate PR(s): N/A
  • Canonical PR: N/A
  • Resolution (closed/superseded/updated): N/A

Summary by CodeRabbit

  • New Features

    • Added a localized notification when the app recovers unreadable configuration settings.
    • Notifications explain whether settings were restored or reset and link to Settings.
    • Preserves the original unreadable configuration file with a .corrupted suffix when applicable.
    • Notification appears only once per session and remains marked as unread.
  • Documentation

    • Added translations for Arabic, Bengali, Chinese, English, French, German, Hindi, Indonesian, Italian, Korean, Polish, Portuguese, Russian, and Spanish.
  • Tests

    • Added coverage for recovery detection, notification behavior, localization, and repeated refreshes.

tinyhumansai#5167)

Two Windows users' config.toml files failed UTF-8 validation and flooded
Sentry. The loader already rate-limits the error, renames the corrupt file to
.corrupted.<ts>, and resets to defaults/backup (the flood was fixed in tinyhumansai#5171) —
but that recovery was silent to the user, leaving requirement 3 of the issue
(a user-visible warning) unmet.

Surface a one-shot, user-visible notice:

- Config carries a runtime-only `recovered_from_corruption` flag, set by the
  loader on the corruption-recovery path (both `load_or_init` and
  `load_from_config_path`).
- A process latch (`app_state::recovery_signal`), set once at boot from that
  flag, keeps the signal reported after the loader heals the file on the same
  boot (the per-load flag reads false on subsequent, now-clean loads).
- `app_state_snapshot` exposes it as `configRecovered`; the frontend raises a
  single "Settings were reset" System notice in the notification center,
  guarded so the latched flag does not re-fire on every snapshot poll.

Closes tinyhumansai#5167

Co-authored-by: Medulla <medulla@tinyhumans.ai>
@YellowSnnowmann
YellowSnnowmann requested a review from a team August 3, 2026 13:30
@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3faf30d4-e515-4810-8012-b317262d16ba

📥 Commits

Reviewing files that changed from the base of the PR and between bf60fc5 and d2b786f.

📒 Files selected for processing (2)
  • app/src/providers/CoreStateProvider.tsx
  • src/openhuman/desktop/app_state/recovery_signal.rs
🚧 Files skipped from review as they are similar to previous changes (2)
  • app/src/providers/CoreStateProvider.tsx
  • src/openhuman/desktop/app_state/recovery_signal.rs

📝 Walkthrough

Walkthrough

Configuration loading now records corruption recovery, propagates a session-latched signal through app-state snapshots, and surfaces a one-time localized settings notification. Rust and Vitest tests cover clean, recovered, repeated, false, and absent recovery states.

Changes

Configuration recovery signaling

Layer / File(s) Summary
Record recovery during configuration loading
src/openhuman/config/schema/types.rs, src/openhuman/config/schema/load/impl_load.rs, src/openhuman/config/schema/load_tests.rs
Config stores a non-persisted recovery flag. Startup loading and snapshot reload set the flag before environment overrides. Tests cover valid and non-UTF-8 configurations.
Latch recovery in app state
src/openhuman/desktop/app_state/recovery_signal.rs, src/openhuman/desktop/app_state/mod.rs, src/core/jsonrpc.rs, src/openhuman/desktop/app_state/ops.rs, app/src/services/coreStateApi.ts
The runtime latches recovery state for the process lifetime. App-state snapshots expose it as configRecovered.
Surface the recovery notice
app/src/providers/CoreStateProvider.tsx, app/src/lib/configRecoveryNotice.ts, app/src/lib/configRecoveryNotice.test.ts, app/src/providers/__tests__/CoreStateProvider.test.tsx, app/src/lib/i18n/*
Core refresh passes the recovery flag and translator to a deduplicated notification helper. Translations cover the supported locales. Tests cover notification fields, one-shot behavior, and no-op inputs.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ConfigLoader
  participant AppStateSnapshot
  participant CoreStateProvider
  participant NotificationStore
  ConfigLoader->>AppStateSnapshot: record configRecovered
  CoreStateProvider->>AppStateSnapshot: refresh core state
  AppStateSnapshot-->>CoreStateProvider: return configRecovered
  CoreStateProvider->>NotificationStore: dispatch localized notice once
Loading

Possibly related PRs

Suggested labels: rust-core, bug

Poem

A rabbit found a broken file,
Recovered settings with a smile.
The signal hopped from core to screen,
One notice showed what had been seen.
The settings path stayed close and clean. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: notifying users when a corrupt config.toml file is reset.
Linked Issues check ✅ Passed The PR implements the remaining linked requirement by surfacing a localized warning after configuration recovery [#5167].
Out of Scope Changes check ✅ Passed All changes support configuration recovery signaling, notification display, localization, or related test coverage.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

@coderabbitai coderabbitai Bot added bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure. labels Aug 3, 2026

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 02d89756f0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread src/openhuman/desktop/app_state/ops.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🤖 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 `@app/src/lib/configRecoveryNotice.ts`:
- Around line 41-42: Move the “Settings were reset” title and body out of
configRecoveryNotice and into the i18n system: add dedicated keys with real
translations to en.ts and every locale, preserving any interpolation
placeholders. Update the hook-aware caller or dispatch flow around the notice so
it obtains both strings through useT() from I18nContext before creating the
notice.

In `@app/src/providers/CoreStateProvider.tsx`:
- Around line 292-294: Add a CoreStateProvider behavior test covering
refreshCore with a snapshot where configRecovered is true, and assert that
exactly one system recovery notification is emitted. Exercise the provider-level
path through maybeSurfaceConfigRecovery rather than only testing the helper,
while preserving the existing one-shot behavior across repeated refreshes.

In `@src/openhuman/config/schema/types.rs`:
- Around line 97-104: Replace the boolean recovery state with a runtime outcome
distinguishing none, backup restoration, and defaults reset. In
src/openhuman/config/schema/types.rs:97-104 and
src/openhuman/config/schema/load/impl_load.rs:405-408,600, set and propagate the
specific outcome; add assertions for both recovery paths in
src/openhuman/config/schema/load_tests.rs:1900-1906. Update
recovery_signal.rs:40-49 and ops.rs:176-181,1196 to latch and expose the
outcome, then update app/src/services/coreStateApi.ts:68-75,
CoreStateProvider.tsx:292-294, and configRecoveryNotice.ts:33-46 to preserve
older-core compatibility and show matching wording; extend
configRecoveryNotice.test.ts:12-34 to verify each outcome dispatches once.

In `@src/openhuman/desktop/app_state/ops.rs`:
- Line 1196: Add snapshot-level tests around app_state_snapshot to cover both
recovered and clean states, asserting the serialized camelCase configRecovered
field consumed by the frontend. Reuse the existing snapshot test setup and
verify the field’s value in each state, rather than testing only the
config_recovered latch directly.

In `@src/openhuman/desktop/app_state/recovery_signal.rs`:
- Around line 64-106: Protect both tests, latch_defaults_false_and_marks_true
and latch_from_config_only_marks_when_recovered, with a shared test-only mutex
guard held for each entire test body. Acquire the guard before reset_for_tests
and retain it through the final reset_for_tests so concurrent tests cannot
mutate CONFIG_RECOVERED during assertions.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b70952d8-2672-4d14-b5b8-2b262435df14

📥 Commits

Reviewing files that changed from the base of the PR and between 2e98f39 and 02d8975.

📒 Files selected for processing (11)
  • app/src/lib/configRecoveryNotice.test.ts
  • app/src/lib/configRecoveryNotice.ts
  • app/src/providers/CoreStateProvider.tsx
  • app/src/services/coreStateApi.ts
  • src/core/jsonrpc.rs
  • src/openhuman/config/schema/load/impl_load.rs
  • src/openhuman/config/schema/load_tests.rs
  • src/openhuman/config/schema/types.rs
  • src/openhuman/desktop/app_state/mod.rs
  • src/openhuman/desktop/app_state/ops.rs
  • src/openhuman/desktop/app_state/recovery_signal.rs

Comment thread app/src/lib/configRecoveryNotice.ts Outdated
Comment thread app/src/providers/CoreStateProvider.tsx Outdated
Comment thread src/openhuman/config/schema/types.rs
Comment thread src/openhuman/desktop/app_state/ops.rs
Comment thread src/openhuman/desktop/app_state/recovery_signal.rs
@greptile-apps

greptile-apps Bot commented Aug 3, 2026

Copy link
Copy Markdown

Greptile Summary

Surfaces a one-shot "Settings were reset" notification to users when config.toml is auto-recovered from corruption, closing the last requirement of #5167. The solution uses a pull-based design: a process-lifetime AtomicBool latch in Rust bridges the gap between the per-load recovered_from_corruption flag and the frontend's snapshot polling cycle, and a module-level surfaced guard on the TypeScript side ensures exactly one Redux dispatch regardless of how many polls report the latched signal.

  • Rust core: Config::recovered_from_corruption (#[serde(skip)]) is set in both load_or_init and load_from_config_path recovery branches; recovery_signal latches it process-wide; AppStateSnapshot.config_recovered propagates it to the frontend via the existing snapshot RPC with #[serde(rename_all = "camelCase")].
  • Frontend: configRecoveryNotice.ts dispatches one system notification with a stable id for deduplication; CoreStateProvider.refreshCore calls it after the mount guard with t from useT() in the callback's dependency array for correct locale handling.
  • i18n + tests: Translations added for 14 locales; unit and integration tests cover the recovery flag, the latch, and the one-shot dispatch guard.

Confidence Score: 5/5

Safe to merge — the change is strictly additive (a new optional field on the snapshot response, a never-persisted #[serde(skip)] flag, and a one-shot frontend dispatch) with no mutation of existing config-loading or serialization behavior.

Both the Rust latch and the TypeScript one-shot guard degrade gracefully: older frontends ignore the new field, older cores omit it (TypeScript treats undefined as falsy). The #[serde(skip)] attribute guarantees the runtime flag is never written to disk. Tests cover the full path — recovery flag on both loader entry points, the process-latch transitions, and the one-shot frontend dispatch including repeated-refresh deduplication. No existing behavior is changed; the recovery/heal/rate-limit logic from #5171 is untouched.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/openhuman/desktop/app_state/recovery_signal.rs New process-lifetime AtomicBool latch bridging the config-loader's per-load flag to the snapshot poll cycle; includes serialized unit tests using a shared Mutex guard to prevent parallel-test interference.
app/src/lib/configRecoveryNotice.ts New module-level one-shot dispatcher; the surfaced guard and stable NOTICE_ID together ensure exactly one Redux dispatch per app run.
app/src/providers/CoreStateProvider.tsx Wires maybeSurfaceConfigRecovery into refreshCore after the mount guard, with t from useT() in the callback dependency array for correct locale handling.
src/openhuman/desktop/app_state/ops.rs Adds config_recovered: bool to AppStateSnapshot and calls latch_from_config on every snapshot poll, correctly catching mid-session corruption in addition to the boot-time latch.
src/openhuman/config/schema/types.rs Adds recovered_from_corruption: bool with #[serde(skip)] — correctly never persisted, defaults to false.
src/openhuman/config/schema/load/impl_load.rs Sets recovered_from_corruption before env overrides in both recovery branches.
app/src/lib/configRecoveryNotice.test.ts Four focused unit tests cover happy path, i18n wiring, one-shot deduplication, and false/absent no-op.
app/src/services/coreStateApi.ts Adds optional configRecovered?: boolean to AppStateSnapshotResult for backward compat.
src/core/jsonrpc.rs Adds boot-time latch call after config load in bootstrap_core_runtime.
src/openhuman/config/schema/load_tests.rs New async test for load_from_config_path with binary-invalid config and updated assertions for the recovery flag.
app/src/providers/tests/CoreStateProvider.test.tsx Three integration tests verify full notice dispatch path including one-shot guard across repeated refreshes.

Sequence Diagram

sequenceDiagram
    participant Loader as Config Loader
    participant Bootstrap as bootstrap_core_runtime
    participant Signal as recovery_signal AtomicBool
    participant Snapshot as app_state_snapshot
    participant Frontend as CoreStateProvider refreshCore
    participant Notice as configRecoveryNotice surfaced guard
    participant Store as Redux Store

    Loader->>Bootstrap: "Config with recovered_from_corruption=true"
    Bootstrap->>Signal: "latch_from_config sets CONFIG_RECOVERED=true"
    Note over Bootstrap,Signal: Boot-time latch

    loop Every snapshot poll
        Snapshot->>Loader: load_config_with_timeout
        Loader-->>Snapshot: Config recovered_from_corruption true or false
        Snapshot->>Signal: latch_from_config idempotent mid-session catch
        Snapshot->>Signal: config_recovered_this_session
        Signal-->>Snapshot: true latched
        Snapshot-->>Frontend: AppStateSnapshot configRecovered true
    end

    Frontend->>Notice: maybeSurfaceConfigRecovery true t after mount guard
    alt surfaced is false first call
        Notice->>Store: dispatch notificationReceived id config-recovered
        Notice->>Notice: "surfaced = true"
    else surfaced is true subsequent calls
        Notice-->>Frontend: no-op
    end
Loading

Reviews (3): Last reviewed commit: "fix(config): log config-recovery once pe..." | Re-trigger Greptile

Comment thread src/openhuman/desktop/app_state/recovery_signal.rs
Comment thread src/openhuman/config/schema/load_tests.rs
…sts (tinyhumansai#5167)

Review-cycle fixes for tinyhumansai#5340:

- Latch config-corruption recovery from every app_state_snapshot poll, not only
  at boot. load_config_with_timeout re-reads config.toml on each snapshot, so a
  config that becomes corrupt after startup is healed there and carries a fresh
  recovered_from_corruption; the boot-only latch dropped it and the notice never
  surfaced. Now snapshot() calls latch_from_config on each poll's fresh config
  (Codex P2, Greptile).

- Move the notice copy into i18n: add notifications.configRecovered.title/body
  to en.ts and all 13 other locales, and thread `t` (useT) from CoreStateProvider
  into maybeSurfaceConfigRecovery so the notice honors the active locale instead
  of hardcoded English (CodeRabbit).

- Make the copy accurate for both recovery outcomes. The core sets one
  recovered_from_corruption flag whether it restored the previous settings from a
  .bak backup or reset to defaults, so the notice no longer hard-claims "reset to
  defaults" (which is wrong in the backup case) and instead states it was
  restored from a backup or reset to defaults (CodeRabbit).

- Add a provider-level test: refreshCore forwards configRecovered to a single
  system notice (id=config-recovered, deep-links /settings) and stays one-shot
  across repeated refreshes, asserted via a dispatch spy (CodeRabbit).

- Serialize the recovery_signal latch tests behind a shared Mutex so the parallel
  test runner cannot interleave reset/assert on the process-global CONFIG_RECOVERED
  atomic; the guard recovers from poisoning (CodeRabbit, Greptile).

- Reword the load_from_config_path recovery-flag test comment to state what it
  actually asserts (the per-load flag) and where surfacing is wired
  (app_state_snapshot latching), so it no longer overstates coverage (Greptile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@YellowSnnowmann

Copy link
Copy Markdown
Contributor Author

Review feedback addressed in bf60fc5dd

Thanks for the thorough passes (CodeRabbit, Codex, Greptile). All actionable findings are in; each inline thread has a specific reply. Summary:

Fixed

  • Post-boot recovery gap (Codex P2 / Greptile): app_state_snapshot now latches recovery from each poll's freshly-loaded config, not just at boot — so corruption discovered mid-session (a since-corrupted config.toml reloaded by a long-lived runtime) also surfaces the notice.
  • i18n (CodeRabbit): notice title/body moved to notifications.configRecovered.* in en.ts + all 13 other locales (real translations; i18n:check = 0 missing/0 extra); t threaded from CoreStateProvider via useT().
  • Provider-level test (CodeRabbit): new CoreStateProvider block asserts refreshCore forwards configRecovered → exactly one system notice, stays one-shot across refreshes (dispatch-spy), and no-ops when absent.
  • Parallel-test race (CodeRabbit / Greptile): recovery_signal latch tests serialized behind a shared poison-tolerant Mutex.
  • Misleading test comment (Greptile): reworded to state what it asserts (per-load flag) and where surfacing is wired.

Two reasoned calls (flagging for a maintainer decision rather than silently expanding scope):

  • Backup-vs-defaults wording (CodeRabbit, Major): valid concern — one boolean can't distinguish a .bak restore from a defaults reset, and the old copy wrongly said "reset to defaults" for the backup case. I fixed the correctness minimally by making the copy accurate for both ("restored from a backup or reset to defaults") instead of threading a 3-state RecoveryOutcome through 9 files + the snapshot wire. Distinct per-outcome messages would be a clean, separable follow-up.
  • Rust snapshot serde fixture (CodeRabbit, Minor): the configRecovered camelCase contract is compile-guaranteed by #[serde(rename_all=...)] and now asserted end-to-end by the provider test; a full AppStateSnapshot fixture needs ~50 lines of Default-less cross-module construction that's brittle to unrelated field churn, so I covered the behavioral gap (mid-session latching) instead.

FE verified locally: tsc --noEmit, eslint, vitest (config-recovery + provider suites green), i18n:check, prettier. Rust fmt clean; the touched recovery_signal / config-load tests pass. Happy to take either reasoned item further if preferred.

coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 4, 2026

@sanil-23 sanil-23 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Review

The mechanism is sound. I traced the full path end to end and it holds up:

  • AppStateSnapshot carries #[serde(rename_all = "camelCase")], so config_recoveredconfigRecovered matches the TS field. Worth calling out because the neighbouring health field's comment says the type has no camelCase rename — that refers to HealthSnapshot, not the outer struct, and it reads as a trap.
  • load_config_with_timeout has no cache (deliberately — the comment cites a racy prior attempt), so the per-poll re-latch genuinely catches mid-session corruption and the flag cannot get stuck true on a cached config.
  • config_was_corrupted folds both the read-recovery and the parse-recovery paths, and the assignment sits before apply_env_overrides_from, so no override can mask it.
  • CoreStateProvider is nested inside I18nProvider (App.tsx:144), so t resolves against the real locale rather than the default context. useT's t is useCallback-stable per locale, so adding it to refreshCore's dep array does not churn the poll loop.
  • All 14 locales in I18nContext's translations map got both keys — none missed.

One blocking item, then some smaller notes.


1. Blocking (one-liner) — the latch warn is unbounded when the heal itself fails

latch_from_config logs warn! on every call where the flag is set, and it is now called from snapshot() — which polls every 2–5s (app/src/providers/CoreStateProvider.tsx:50 / :61).

In the normal case that is fine: the file gets healed, later loads are clean, one warn total.

But impl_load.rs has a branch that deliberately does not heal — when fs::rename of the corrupt file fails it logs "skipping save to protect the .bak — will retry recovery on next startup". A Windows sharing violation (os error 32) puts you there, and this codebase already handles that error by name elsewhere. In that state every poll re-recovers and emits a fresh warn: roughly 12–30 lines/min, indefinitely, for the whole session.

The doc comment on latch_from_config claims it logs "once, on the load that actually recovered" — that is not true in this branch.

To be clear on blast radius: this is not a Sentry event flood. src/core/logging.rs:634 maps WARNBreadcrumb, so #5171's fix holds. It is unbounded local log noise — but in exactly the scenario #5167 originated from, which makes it worth fixing before merge.

mark_config_recovered already has the information needed:

pub fn latch_from_config(config: &Config) {
    if !config.recovered_from_corruption {
        return;
    }
    if !CONFIG_RECOVERED.swap(true, Ordering::Relaxed) {
        log::warn!("[app_state] config.toml was unreadable/corrupt and was recovered ...");
    }
}

2. Minor — the notice is persisted, and a repeat recovery will not resurface it

items is redux-persist-whitelisted and user-scoped (app/src/store/index.ts:145). notificationReceived replaces a matching id in place, and NotificationCenter renders items in array order with no sort (the array is insertion-ordered via unshift).

So a second recovery in a later session updates the row's timestamp and unread flag but leaves it buried at whatever position the previous session's notice occupied — potentially far down a list capped at 200. A per-session id suffix, or re-unshifting on replace, would fix it.

Related: resetUserScopedState resets the slice to initialState, but the module-level surfaced flag stays true. An identity flip occurring after the dispatch wipes the notice with no re-fire.


3. Nit — log::warn! vs tracing::warn!

recovery_signal.rs uses log::warn! while the surrounding config and app_state code uses tracing::warn!. It works through the bridge, but it loses structured fields and is inconsistent with the module it lives in.


4. Nit — dispatch happens before the mount guard

maybeSurfaceConfigRecovery is called before if (!isMountedRef.current) return; in refreshCore, so a superseded or unmounted refresh still dispatches. Harmless given the one-shot guard, but it is the only side effect in that function that ignores the request-id / mount discipline the rest of the body is careful about.


Requesting changes on (1) only; it is a one-line swap. The rest are non-blocking and fine as follow-ups.

…ing (tinyhumansai#5167)

Addresses @sanil-23's review on tinyhumansai#5340.

- latch_from_config logged warn on every call where the flag is set, and it
  now runs on every app_state_snapshot poll. When a corrupt config can't be
  healed (impl_load skips the rename to .corrupted.<ts> on e.g. a Windows
  sharing violation, os error 32, and retries next load) every poll re-recovers
  and would emit a fresh warn indefinitely. Gate the warn on the latch's
  false->true transition: mark_config_recovered now returns whether it was the
  first mark (via AtomicBool::swap), so the log fires once per process. Fixed
  the doc comment (it wrongly claimed 'once, on the load that recovered') and
  added a test asserting the transition semantics.
- Use tracing::warn! instead of log::warn! for consistency with the surrounding
  config/app_state code.
- Move maybeSurfaceConfigRecovery below refreshCore's mount guard so a
  superseded/unmounted refresh no longer dispatches; the core latches the flag,
  so the next live poll still surfaces the notice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@YellowSnnowmann

Copy link
Copy Markdown
Contributor Author

@sanil-23 thanks — this is a genuinely useful trace, and the blocking call is right.

(1) Blocking — unbounded warn on the non-heal branch — fixed in d2b786f0a

You nailed the failure mode: latch_from_config runs on every ~2–5s poll, and when the heal itself fails (impl_load.rs skips the rename to .corrupted.<ts> on e.g. a Windows sharing violation, os error 32, and retries next load), the flag is true on every poll → a fresh warn each time, forever.

Gated the log on the latch's falsetrue transition, exactly as you suggested — mark_config_recovered now returns whether it was the first mark, via swap:

fn mark_config_recovered() -> bool {
    !CONFIG_RECOVERED.swap(true, Ordering::Relaxed)
}

pub fn latch_from_config(config: &Config) {
    if !config.recovered_from_corruption {
        return;
    }
    if mark_config_recovered() {
        tracing::warn!("[app_state] config.toml was unreadable/corrupt and was recovered ...");
    }
}

So it's one warn per process even in the never-heals branch. Also corrected the doc comment (it claimed "once, on the load that actually recovered", which was false in that branch — now "once per process, gated on the transition, because this runs on every poll") and added a test asserting mark_config_recovered() returns true only on the first call.

(3) Nit — log::warn!tracing::warn! — fixed in d2b786f0a

Switched (folded into the change above) for consistency with the surrounding config/app_state code.

(4) Nit — dispatch before the mount guard — fixed in d2b786f0a

Moved maybeSurfaceConfigRecovery below the if (!isMountedRef.current) return; guard so a superseded/unmounted refresh no longer dispatches. Safe because the core latches configRecovered, so the next live poll still surfaces it. It's now consistent with the request-id/mount discipline the rest of refreshCore follows.

(2) Notice persistence / surfaced-on-flip — acknowledging as follow-ups

Both are real and I'd rather not fold them into this PR — they touch shared notification-slice ordering and the identity-flip reset path, which is broader than #5167's "surface a notice". Noting them for a follow-up:

  • buried on repeat recovery — a per-session id suffix (or re-unshift on replace in notificationReceived) so a later-session recovery re-surfaces at the top rather than updating in place at a stale position.
  • surfaced survives resetUserScopedState — an identity flip after the dispatch wipes the redux row without re-firing; would reset the module guard on flip.

Happy to file an issue for these (or fold them in if you'd prefer they ride along). Nice catch on the health comment reading as a trap, too — the camelCase rename is on the outer AppStateSnapshot, not HealthSnapshot.

Local checks on d2b786f0a: cargo fmt --check clean, cargo clippy --lib clean, the recovery_signal latch tests pass; FE tsc / eslint / prettier clean and the config-recovery + CoreStateProvider vitest suites green (35/35).

@senamakel
senamakel merged commit 00a0cf1 into tinyhumansai:main Aug 4, 2026
24 of 25 checks passed
@github-project-automation github-project-automation Bot moved this from Todo to Done in Team Openhuman Aug 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug rust-core Core Rust runtime in src/: CLI, core_server, shared infrastructure.

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

config.toml corruption: stream did not contain valid UTF-8 — two users affected

3 participants