Skip to content

fix(storage): storage settings corrupt recovery - #5282

Merged
Astro-Han merged 8 commits into
apache:mainfrom
chinawch007:fix/storage-settings-corrupt-recovery
Sep 27, 2026
Merged

Astro-Han merged 8 commits into
apache:mainfrom
chinawch007:fix/storage-settings-corrupt-recovery

Conversation

@chinawch007

@chinawch007 chinawch007 commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes #4285

#4505 hardened future JSON-store writes, but a settings file already emptied or truncated by an older build can still prevent startup. This PR completes the read-side recovery and provides repair guidance for malformed MCP configuration.

  • Copy the exact corrupt settings bytes into a private backup before atomically publishing defaults. Verify and reuse identical backups across failed retries and store instances. Recheck the source immediately before publication and cancel if an intervening edit is detected.
  • Accept a UTF-8 BOM. Preserve BOM-marked UTF-16 and corrupted symbolic links with manual repair guidance. Only JSON parsing SyntaxError triggers recovery; read, normalization and migration failures retain their own error behavior. Published-but-unconfirmed writes remain commit-unknown and never automatically replay the caller's mutation.
  • Show a localized application dialog with full file/backup paths and reset implications, including when native notifications fail or startup cannot create the main window. Boot and the existing watcher apply settings; a silent theme refresh cannot consume a later renderer notification.
  • Preserve corrupt mcp.json and refuse writes, carrying its safe repair path through typed IPC results. Desktop and TUI show localized guidance; TUI error details scroll, initialization errors disable management keys, and runtime errors preserve the live server list.

Recovery policy

This PR proposes automatic settings backup/reset because settings are a startup dependency. It resets preferences, bot configuration and onboarding, including Incognito's default of off; the application notice explicitly calls out reviewing privacy preferences. MCP definitions control commands and endpoints, so they receive repair guidance without automatic replacement. This policy still needs maintainer confirmation; silence on the issue is not treated as approval.

The final source check is not an atomic compare-and-swap against arbitrary external writers. Different corrupt snapshots are retained; backup reuse bounds repeated copies of identical content rather than pruning recovery data.

Verification

  • Passed clean full build, lint, format:check, typecheck, Desktop/UI knip, locale/TUI checks, Windows inventory, and ASF headers on an exported PR-source snapshot.
  • Clean compiled suites: storage 1,464 passed / 8 skipped, Desktop 2,820 passed (concurrency 4), CLI 1,140 passed / 3 skipped (concurrency 1). A prior parallel CLI run hit child-process deadlines; all 32 implicated tests and the complete serial suite passed unchanged.
  • Actual macOS Electron startup: exact-byte backup, valid defaults, full-path application notice with native banners disabled, and no duplicate after dismissal. Before/after TUI captures use the real component at 50 columns × 8 rows. Windows/Linux app smoke runs were not performed.

Same truncated settings fixture against the PR base and this revision:

Before: SyntaxError; settings load failed; no backup.
After:  defaults loaded; one byte-for-byte backup.

One existing MCP validation fixture now spells its raw control character as \u0001; the runtime test input is unchanged.

AI use

Select exactly one:

  • No generative tool made a substantive contribution
  • Generative tooling made a substantive contribution

Tool(s) and scope: OpenAI Codex implemented the recovery, regression tests and review fixes, ran verification, and drafted this description. Retain Generated-by: OpenAI Codex in all substantive AI-authored commits and the final squash commit.

Checklist

  • Tests cover the change and fail without it
  • Lint, format, typecheck and the affected suites pass locally

Does this PR entail a change in behavior?

  • Yes — described under Summary and Recovery policy above
  • No

@github-actions github-actions Bot added the effort/XL Under 2500 readable lines label Sep 14, 2026
@chinawch007
chinawch007 force-pushed the fix/storage-settings-corrupt-recovery branch from aabd93c to fab6ca4 Compare September 14, 2026 09:18

@Astro-Han Astro-Han 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.

Thanks for following #4505 through to the read side. Reviewed at head fab6ca4. Lint and format pass; the compiled storage (52), Desktop (41) and CLI (68) suites in scope all pass here. The mcp.json half is in good shape: the typed McpConfigSourceError, the untouched file, and the mcp-ipc-commit-unknown test that runs a real store through the IPC message and asserts the secret never reaches the error are exactly the right kind of coverage. The TUI config_error phase with scrolling is needed for the guidance to be readable in small terminals, so I don't see that as scope creep.

The settings.json half I'd like to step back on before looking at more detail.

1. The recovery policy hasn't been decided yet. Your plan comment on #4285 asked maintainers to veto option A (backup, reset to defaults, notify) before PR2; nobody has replied, and silence isn't a decision. Resetting preferences, bot configuration and onboarding state without the user confirming is an interaction change, so I think it needs a maintainer answer on the issue first. It's also worth noting that this PR already contains the cheaper alternative for mcp.json (typed error, file untouched, guidance to repair), and the description doesn't say why the two files get different treatment. A reasonable middle ground for settings is: boot on in-memory defaults, show one in-app notice that names the file, and move the file aside only when the user says so. That keeps the "don't brick startup" fix and drops most of the new machinery. I'm not asking for that specific design, only that #4285 settles it.

2. On the startup path the user gets no signal. This is the path #4285 describes. settingsStore.get() first runs at runtime-host-boot.ts:465/:919, settingsRecovery.setEffects at :1014, and the main window is created at :2161. safeSendToRenderer (main-window.ts:133) drops the event when no window exists, and rendererFingerprint has already advanced, so nothing is re-sent when the window appears. Even if it were delivered, settings:clientChanged only tells the renderer to reread; none of its four subscribers knows a reset happened or where the backup is. That leaves the native notification as the only channel, and the description reports it returned failed in your own smoke run. So today the outcome is a silent reset with a backup the user doesn't know about, which is harder to recover from than the original failure. Whatever policy #4285 lands on, the fix needs an in-app surface.

3. The reporter's refresh path duplicates an existing authority. startClientSettingsWatcher (client-settings-watcher.ts:47, wired at runtime-host-boot.ts:1050) already calls clientSettingsEffects.refresh(true) whenever settings.json changes, and recovery publishes through this.write(), so a runtime recovery already triggers it. On startup there is no stale state to refresh: the boot refresh(false) at :1552 applies the recovered defaults. That makes the pending/setEffects/refresh latch in settings-recovery.ts and the fingerprint split in client-settings-effects.ts removable, together with their tests. Removing them also removes the new behavior where the first refresh(true) after boot emits settings:externalChanged even when nothing changed.

With 1–3 settled, the smallest complete shape I can see for the settings side is roughly: the SyntaxError branch in readOrCreate, a backup by rename, the recovery callback, one notification hook in boot, and the tests that prove bytes are preserved and defaults are written. The SettingsRecoveryError phase/incompleteBackupPath fields and the commit-unknown subclass, outcome and copy have no consumer that branches on them (and commit-unknown is unreachable on Windows where syncDirectory is a no-op), so they would go with it.

Line-level notes are inline. Two description items: the before/after TUI screenshots are still an HTML placeholder, and it would help to state that the realistic trigger for a corrupt settings.json after #4505 is a file written by a pre-#4505 build, since the fsync fix closed the crash window going forward.

AI assistance: I used Claude Code to trace the boot ordering, reproduce the backup and TOCTOU cases, and run the suites; each finding here was checked by me against the code.

Comment thread packages/storage/src/settings-store.ts Outdated
const backupPath = await this.backupCorruptSettings(bytes);
const settings = createDefaultSettings();
try {
await this.write(settings);

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.

P2. Between readFile and this write, the file on disk can change (the natural sequel to #4285 is a user fixing the file in an editor while the app starts). The backup is taken from the bytes read earlier and this write replaces whatever is on disk now, so a repaired file is overwritten by defaults and appears in no backup. I reproduced this by swapping in valid JSON right after the backup handle opened. readStableBoundedFile in stable-storage.ts already does the dev/ino/size/mtime check that would catch this; asserting the source is unchanged before the reset, and retrying the read otherwise, closes it.

Comment thread packages/storage/src/settings-store.ts Outdated
* Keeping the source in place avoids turning a failed recovery into ENOENT.
* This has the same platform fsync limits as the shared atomic writer. */
private async backupCorruptSettings(bytes: Buffer): Promise<string> {
const backupPath = `${this.settingsPath}.corrupt-${Date.now()}-${randomUUID()}`;

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.

P2. Every call gets a fresh name and a failed reset isn't remembered, so while the reset keeps failing (rename refused because the file is locked or immutable, while the directory is still writable) every get()/update() writes another full copy. Five failing reads gave me six files. Remembering the backup path on the store after a failed reset, or reusing an identical existing backup, bounds it. Separately: a plain rename(settingsPath, backupPath) would replace this whole open/write/chmod/sync/cleanup sequence with one atomic step and no fsync; the ENOENT window it opens is microseconds and its outcome (defaults on next start) is the same outcome recovery produces anyway.

assert.equal(error.settingsPath, path);
assert.equal(error.backupPath, undefined);
assert.equal(error.incompleteBackupPath, undefined);
assert.equal(error.message.includes('do-not-log'), false);

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.

P3. This assertion (and the one at 413, and never-include-this in the mcp test) can't fail on the implementation it's guarding against: for a truncated object V8 reports a position-only message with no source excerpt, so 'invalid JSON: ' + error.message also passes. A fixture whose parse error embeds the token, e.g. Buffer.from('sk-live-SECRET') or {"a":sk-live-SECRET}, makes it a real check.

});

it('creates defaults only when settings.json is missing', async () => {
it('creates defaults without a backup when settings.json is missing', async () => {

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.

P3. The title now promises "without a backup" but the body didn't change and never lists the directory; the real check for that lives in the new recovery file. Suggest keeping the old title.

* the file after the storage operation releases its own queue. */
export function createSettingsRecoveryReporter(deps: SettingsRecoveryReporterDependencies) {
let effects: Pick<ClientSettingsEffects, 'refresh'> | undefined;
let pending: CorruptSettingsRecovery | undefined;

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.

See point 3 in the top-level comment: the settings watcher already refreshes on the recovery write, and boot's refresh(false) covers startup, so this latch and refresh() below can go.

// discovers corruption, and both storage and effects serialize operations.
void Promise.resolve().then(() => target.refresh(true)).catch(() => {
log(`[settings-recovery] refresh failed; outcome=${recovery.outcome}; backup=${recovery.backupPath}; restart the app`);
notify(recovery, true);

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.

P3. This raises a second system banner for the same event (the test asserts two). The log line above already records the refresh failure; one banner is enough.

rendererFingerprint = nextRendererFingerprint;
if (notifyRenderer && rendererChanged) dependencies.emitExternalChanged();
return rendererChanged || keepAwakeChanged || botChanged || appIconChanged;
appliedSettingsFingerprint = nextRendererFingerprint;

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.

P2. After this split, boot's refresh(false) no longer advances rendererFingerprint, so the first refresh(true) after startup emits settings:externalChanged even when settings are unchanged. The comment also says "delivery" but emitExternalChanged goes through safeSendToRenderer, which drops silently without a window, so what is tracked is the emit call, not delivery. If the reporter's refresh path goes (top-level point 3), this change isn't needed.

* the fixed config-error messages to safe, localized presentation. */
export function mcpConfigFailureMessage(error: unknown, copy: McpCopy): string | undefined {
const message = error instanceof Error ? error.message : typeof error === 'string' ? error : '';
const invalidFile = /MCP config at ([^\r\n]+) contains invalid JSON\. The file was not modified\. Close the app, back up and repair this file before retrying\.$/u.exec(message);

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.

P3. This turns the English wording of the storage error into a cross-process contract. McpConfigSourceError already carries reason and path, and the note on McpServerExistsError in the same store describes the intended pattern (IPC matches on instanceof and answers with a typed envelope). The CLI side does it that way. The new mcp-ipc-commit-unknown test will catch wording drift, so this isn't blocking, but the typed route is cheaper to keep.

const copy = MCP_STATUS_COPY[this.input.locale].footer;
if (this.phase.kind === 'config_error') return copy.diagnostic;
if (this.phase.kind !== 'list') return copy.back;
if (this.input.surface?.snapshot().initialization === 'error') return copy.readOnly;

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.

P2. The footer now says read-only in the error state, but handleListInput still accepts a, p, x, Enter, Space, t, r, d there. Either gate those keys in the error state or keep the manage footer; the current combination promises less than it does.

Comment thread packages/cli/src/tui-copy-catalog.ts Outdated
loadError:
'MCP configuration could not be loaded; no tools were published to the Runtime Host.',
invalidConfigFile:
'Invalid JSON in {path}. The file is unchanged. Close the app, back up and repair this file before retrying.',

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.

P3. "Close the app" is Desktop wording; in the terminal this should say quit maka (same for zh-CN/zh-TW).

@Astro-Han Astro-Han 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.

Reviewed the corrupt-settings recovery path end to end (packages/storage/src/settings-store.ts, apps/desktop/src/main/settings-recovery.ts, apps/desktop/src/main/client-settings-effects.ts) plus the read-only MCP half. I did not read the TUI rendering changes line by line.

What I verified on the recovery path

Detection is deliberately narrow. readOrCreate diverts to recovery only for SyntaxError from JSON.parse (settings-store.ts:174-178). EACCES/EIO still propagate, a missing file still creates defaults with no backup, and valid JSON that fails normalization or the legacy-proxy migration is left completely alone. Covered by read ${code} does not reset or create backups, missing and valid JSON keep their normal behavior without a backup, normalization errors are not interpreted as invalid JSON, and a migration write failure (${code}) cannot trigger creation or recovery.

Nothing is destroyed before the backup is durable. Backup first, publish second: backupCorruptSettings (settings-store.ts:211-240) writes the exact bytes through an exclusive open(backupPath, 'wx', 0o600), re-chmods on POSIX, fsyncs the file and then the parent directory, and only afterwards does recoverCorruptSettings call the shared atomic writer. Because writeAtomicFile publishes with rename, settings.json is never missing and the corrupt bytes exist in two places until the rename lands. The per-phase fault injection (open / writeFile / chmod / sync / close / directory) asserts the source bytes plus a single-entry directory afterwards, and reset publication failure retains the complete backup and never reports success asserts the backup survives a failed rename.

Secrets stay out of errors and logs. The SyntaxError is intentionally not attached as cause (settings-store.ts:176) and McpConfigSourceError composes its message from the path only (mcp-config-store.ts:193-201). Tests assert the planted secret never reaches an error message, a notification body, or the reporter's log lines.

Commit-unknown is separated from success and the caller's mutation is not replayed. SettingsRecoveryCommitUnknownError (settings-store.ts:77-88) is reported to the observer before being thrown, and since recovery happens inside readOrCreate, the caller's patch is never evaluated — post-publication failure remains commit-unknown through ${mutation} asserts predicateCalls === 0 for updateIf.

Idempotent and race-safe. A second get() sees the published defaults and does not re-backup (events.length === 1, directory holds exactly two entries), and 16 concurrent get() calls produce exactly one recovery because everything runs behind withQueue. reportRecovery (settings-store.ts:244-252) neither awaits nor propagates the observer, so a re-entrant store.get() from the callback cannot deadlock the queue — there is a test for that case too.

Desktop wiring holds up. onRecovery notifies, stashes the event, and defers refresh(true) to a microtask (settings-recovery.ts:130-152) so the storage queue is released first; a recovery observed before setEffects is replayed exactly once effects exist. Deferring rendererFingerprint until an actual delivery (client-settings-effects.ts:104-111) is the right fix for the swallowed reset event — the real-store integration test asserts exactly one emitExternalChanged for both notifyRenderer values, and the notification is still suppressed under e2e while diagnostics and the refresh are not.

MCP stays read-only. The new invalid-JSON error is thrown only from FileMcpConfigStore.read (mcp-config-store.ts:186-201); normalizeMcpImport keeps its generic message, so pasted-JSON mistakes do not get "back up and repair this file" advice (asserted via error.path === undefined). get/transform/upsert/remove all refuse to write over the corrupt file and the bytes are unchanged afterwards.

Conventions. ASF headers on both new files, UiCatalog/UI_LOCALES copy shape, the package's existing t.mock.method + syncBuiltinESMExports() fault-injection style, and the generated Windows inventory is internally consistent: the diff adds exactly five process.platform === 'win32' skips and five inventory rows, with portable-candidate 32→37 and the total 95→100.

Nits

  1. A UTF-8 BOM is treated as corruption and resets the file. JSON.parse('\uFEFF{…}') throws SyntaxError (I checked against this Node), so a settings.json that a Windows editor re-saved with a BOM lands in recovery and is reset to defaults. No bytes are lost — there is a backup and a notification — but the file was recoverable. Stripping a leading BOM before the parse in readOrCreate would remove the false positive; the same argument applies to a UTF-16 re-encode, which decodes to NULs and also ends up in recovery.

  2. A symlinked settings.json loses its link. Recovery publishes with rename, which replaces the symlink itself rather than its target, so a dotfile-managed config survives on disk but stops being what the app reads, and the backup stores bytes rather than the link. Following the symlink on read is pre-existing (readFile), so this is not a read regression — but the new write makes it destructive. Elsewhere in this package readStableBoundedFile opens final JSON documents O_NOFOLLOW and fails closed on ELOOP (stable-storage.ts:192-201); an lstat guard before the reset (or preserving the link in the backup) would match that posture.

  3. Backup retention. Backups are never pruned and there is no cap: each read after a failed reset retries the whole recovery and adds another byte-identical backup. Relatedly, if syncDirectory fails after the backup file was fully written and closed, the cleanup at settings-store.ts:227-236 deletes a complete backup and the resulting error says "No complete backup was confirmed" — accurate about the fence, but it discards a file that was actually written.

  4. refresh()'s return value changed meaning without a doc comment. client-settings-effects.ts:112 now returns settingsChanged || … ("something was applied") instead of "the renderer was notified". No production caller reads it (runtime-host-boot.ts:1020, 1053, 1551 all discard the boolean), so this is contract clarity only. Worth stating the second-order effect, though: because a silent apply no longer consumes the change, the first notifying refresh after one always emits settings:clientChanged even when the snapshot is unchanged (boot refresh(false) at runtime-host-boot.ts:1551, then a watcher-driven refresh(true)), costing one extra refreshShellSettings() in the renderer. Looks intended — confirming rather than objecting.

  5. Drive-by in mcp-config-store.test.ts: ['readadmin'] → ['read\u0001admin'] replaces a raw 0x01 byte that had been sanitized out of the source (I diffed the base file — the control character is there). That restores the intended test input, but it is unrelated to this PR's scope and deserves a sentence in the description.

  6. TUI key routing: handleListInput sends arrows and paging to handleTextScroll whenever servers.length === 0 (pi-tui-mcp-status.ts:280-283), which also covers a legitimately empty server list rather than just the corrupt-config state. Narrowing the condition to snapshot?.initialization === 'error' would keep the two cases distinct.

One question

Valid JSON that fails normalizeSettings still hard-fails with no backup and no guidance. Given that normalizeSettings returns defaults for non-objects and merges section by section (packages/core/src/settings.ts:974-996), the practical exposure looks small and the PR documents the choice — I just want to confirm "only SyntaxError recovers" is the intended line and not an oversight.

@chinawch007
chinawch007 force-pushed the fix/storage-settings-corrupt-recovery branch from fab6ca4 to 374389d Compare September 21, 2026 17:22
@chinawch007

Copy link
Copy Markdown
Contributor Author

The two reviews contain several inconsistent assessments or directions:

  • The fingerprint split is initially described as removable, then as the right fix.
  • The first review correctly distinguishes a dropped send from delivery; the second describes the existing code as tracking actual delivery.
  • Copying/synchronizing the backup, the reporter's refresh path and commit-unknown handling are initially candidates for replacement or removal, then receive positive assessments.

The last group does not explicitly withdraw the earlier simplification requests. I have addressed the concrete problems from both reviews rather than treating the later assessment as approval. References: first review, second review.

Why I kept copying the backup

I agree that rename(settingsPath, backupPath) has real advantages: it avoids copying data, captures the file currently at the source path, and naturally avoids backing up that same moved file again. In particular, if an editor repairs the file before the rename, the repaired contents normally end up in the backup. That is better than blindly replacing the current file after backing up only an earlier read.

However, the proposed rename followed by the existing default-settings writer changes the recovery failure contract:

  1. A failed reset leaves the settings path missing. The gap is not bounded to microseconds: writing and synchronizing the replacement can fail, or the process can stop between the two operations. With a copy, a failure before publication leaves the source path untouched by recovery. This matters because recovery is triggered by a read, without a separate user request to move their configuration.
  2. The existing missing-file path cannot distinguish interrupted recovery from first run. After a rename and failed reset, the next read creates defaults through the normal ENOENT branch. It loses the recovery context needed to explain the reset and name its backup. Making rename equivalent would require an explicit interrupted-recovery protocol. An attempted rollback would also have to avoid replacing a file the user recreated meanwhile.
  3. Rename does not solve the editor race by itself. Moving a repaired file into a backup still unnecessarily resets the active configuration. If the user saves a repaired file after the source has been moved, the subsequent default publication can replace that new file, which is absent from the backup. Also, a process holding an open descriptor to the original file can continue writing to the renamed backup. A copy provides a separate snapshot instead of moving the same underlying file.
  4. The current permissions and durability guarantees still need work under rename. A renamed file inherits the source permissions; it does not become a private 0600 backup automatically. Rename is atomic as a directory operation, but that is not the same as confirming the backup before replacing settings. To be precise, an already-synchronized source need not be synchronized redundantly just because it is renamed, and the final same-directory sync on a successful reset can cover the earlier rename. It does not establish the current backup confirmed before default publication boundary, or cover a failure before that final sync. rename semantics, directory synchronization.

A complete rename-based quarantine/recreate protocol is a reasonable alternative. For this read-triggered recovery, I prefer keeping the source in place and fixing the specific weaknesses of copying. The revision validates the source immediately before publication and reuses a verified backup for identical corrupt bytes. It does not retain the original unguarded, fresh-backup-on-every-retry behavior.

Fingerprints and refresh ownership

I accepted the first review's point about the reporter duplicating refresh ownership: it no longer waits for effects initialization, exposes setEffects, or schedules an additional effects refresh. Boot applies initial settings; the existing watcher refreshes runtime consumers. The reporter retains a separate queue only for application notices waiting to be shown.

I did not collapse the applied-settings and renderer-event fingerprints. A silent nativeTheme.updated refresh can read and apply changed settings before the watcher handles the write. With one fingerprint, that silent refresh consumes the change and the later notifying refresh emits nothing. This race exists independently of the recovery reporter.

The initial silent application now establishes the renderer baseline, avoiding the extra unchanged event after boot. Subsequent silent changes do not consume a pending renderer event. The code distinguishes an attempted send from a send with an available recipient; it does not claim renderer acknowledgment. The startup recovery explanation is a separate queued application dialog, so a settings-change event is no longer expected to carry recovery details.

I also documented the boolean return. The pre-PR implementation already returned true for changes applied during refresh(false); it was not exclusively a “renderer notified” result. The return remains a change/emission indication, not proof that the renderer has processed an event.

Commit-unknown and recovery errors

I retained commit-unknown handling. If the atomic rename publishes defaults and the following directory sync fails, saying “recovery failed and the source was not replaced” would be false. Automatically replaying the caller's mutation would also be unsafe. The callback therefore reports the published-but-unconfirmed outcome before the typed error is thrown, and an interrupted updateIf does not evaluate its predicate or patch.

The outcome has an actual consumer: the recovery notice chooses different wording for successful recovery and unconfirmed durability. Windows' directory-sync no-op limits where this particular failure can happen; it does not remove its significance on POSIX. The phase and backup-location diagnostics likewise distinguish a confirmed backup, an incomplete file that cleanup could not remove, and complete bytes retained after an unconfirmed directory fence. These are useful repair facts even when a UI displays the safe error message instead of branching on every field.

Disposition of the remaining comments

Concern Change or clarification
Recovery policy was not accepted; settings and MCP behave differently The description now explicitly presents automatic backup/reset as this PR's proposed policy, including preferences, bot configuration, onboarding and Incognito/privacy effects. It explains that settings are needed for bootstrap, while silently replacing MCP definitions would discard command/endpoint configuration. MCP stays unchanged and receives repair guidance. Silence on the issue is not treated as approval; the maintainer's policy decision remains open.
A repaired settings file can be replaced during recovery Recovery records stable regular-file identity/metadata and bytes, then revalidates after the default temp file has been written, synchronized and closed, immediately before rename. A detected change cancels publication. One bounded reread can return a valid repair; a removed, still-corrupt or nonregular file is left alone. This is a guard, not a compare-and-swap: an arbitrary external writer can still act between the final check and rename.
Failed retries accumulate identical backups Backup names are derived from the corrupt bytes. Existing candidates are reused only after verifying their type, identity, private POSIX mode, exact contents and synchronization. Reuse works across store instances/restarts. A mismatched, incomplete or linked candidate fails closed instead of being overwritten or causing another UUID backup. Distinct corruption snapshots are retained; there is no automatic pruning of potentially useful recovery data.
A completed backup is deleted when directory sync fails Complete bytes are retained, the error names the unconfirmed backup, and a later attempt revalidates and synchronizes it before proceeding. Cleanup still removes this attempt's incomplete files where possible.
Startup reset has no reliable visible explanation Recovery is queued for an application dialog once a usable window exists. It includes the settings and backup paths, outcome and reset/privacy implications. Native notification support or delivery failure does not consume the pending dialog.
Banner truncates the actionable path; click does not focus the app The optional banner puts the backup path first and uses the shared notification focus behavior. The full readable path remains available in the application dialog.
Refresh failure creates a second banner The reporter's refresh path and second banner are removed. Settings-effect failures remain diagnostics of the existing refresh path.
Secret assertions used parser errors that did not expose the planted secret Regression fixtures now use invalid tokens whose real JSON.parse message contains the planted token, and assert that precondition. Storage errors, IPC payloads and presentation must exclude it.
Onboarding test title promises no backup without checking The test now checks the directory contents as well as the returned settings.
English storage wording became an IPC contract Invalid persisted MCP JSON now crosses IPC as a typed plain-data result containing a discriminant and safe path. The renderer localizes that result. It no longer extracts the path from an English error-message regex. Pasted JSON validation remains separate.
TUI initialization-error footer disagrees with accepted keys The initialization-error state now accepts diagnostic scrolling and exit/back only; management keys cannot open editors or execute actions. Runtime mutation errors still return to the preserved live server list.
“Close the app” in terminal guidance All three TUI locales now say to quit maka.
UTF-8 BOM causes an unnecessary reset; UTF-16 can also be recoverable A leading UTF-8 BOM is accepted without rewriting the file or backing it up. BOM-marked UTF-16 is left untouched with explicit UTF-8 conversion guidance, rather than interpreted as corrupt UTF-8 and reset. This does not introduce a general encoding-conversion policy.
Recovery replaces a symbolic link Automatic recovery requires a stable regular-file path. A corrupted symlink or a link substituted during recovery is refused without replacing the link or its target. Ordinary valid-file behavior remains separate.
Scrolling also applies to a legitimate empty server list Kept intentionally, with a regression test: in a small viewport, publication/status text can push the empty-state/add hint below the visible rows. Restricting text scrolling to errors would make that content unreachable. Error-state mutation gating is handled separately.
Escaped control character in the MCP fixture looks unrelated The description explains that \u0001 is the source-safe spelling of the same existing control-character test input; it does not change the runtime fixture or add behavior.
Valid JSON that fails normalization does not recover Intentional. Only JSON parsing SyntaxError qualifies for destructive recovery. I/O, normalization and migration failures continue to propagate; a schema/programming failure must not be interpreted as permission to reset a parseable file.
Trigger and screenshots in the PR description The description names corrupt files left by pre-#4505 builds as the historical trigger, without implying the new fences repair existing files or guarantee against every future corruption source. The HTML screenshot placeholder is removed; actual UI evidence is supplied separately with this revision.

Verification

  • Passed clean full build, lint, format:check, typecheck, and knip for Desktop and packages/ui.
  • Clean compiled suites: storage 1,464 passed / 8 skipped; Desktop 2,820 passed, with concurrency 4.
  • CLI clean full suite: 1,140 passed / 3 skipped, with concurrency 1. A previous parallel run hit child-process deadlines; the affected 32 tests and then the full serial suite passed unchanged.
  • Locale hygiene, TUI copy boundaries, generated Windows inventory and ASF headers on an exported PR-source snapshot passed. Existing unrelated untracked documents were excluded from that source snapshot.
  • Actual macOS Electron startup recovery produced one exact-byte backup, valid defaults and a readable application dialog with full paths while native banners were disabled; dismissal produced no duplicate dialog. Real TUI component output was captured before/after at 50 columns × 8 rows.
  • Windows/Linux application smoke runs were not performed.

AI assistance: OpenAI Codex implemented these changes, ran the checks and drafted this reply. Final submission and review remain with the human contributor.

@chinawch007
chinawch007 force-pushed the fix/storage-settings-corrupt-recovery branch 2 times, most recently from 6851ab4 to fdafc10 Compare September 23, 2026 09:41

@Astro-Han Astro-Han 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.

Re-reviewed at fdafc10a — every concrete finding from both prior rounds is addressed in the actual code path, and I verified the fixes rather than the claims.

Storage: BOM handling (UTF-8 stripped silently; UTF-16-BOM gets guidance instead of a reset), symlink refusal with an lstat + O_NOFOLLOW close on the check→open window, content-derived backup naming with full verified reuse (type/identity/0600/bytes/sync — still fsyncs on reuse), the completed backup retained on directory-sync failure, and the pre-rename revalidation honestly documented as a guard rather than CAS. The durability ordering holds: backup fsync+dirsync strictly precedes the single rename commit — write-ahead-then-commit is the right pattern here. Detection stays correctly narrow (SyntaxError only), secrets stay out of errors, commit-unknown is preserved, and the recovery tests are real per-phase fault injection (including a child-process exit(73) durability test), not fixture theater.

Desktop/CLI: single refresh authority restored (the reporter no longer touches effects), the queued application dialog with path-first banner + real click-to-focus wiring, the typed invalid-mcp-config-file IPC result replacing English-message parsing end to end, TUI error-state gating covering all mutation keys, and the "quit maka" copy in all three locales.

On the decisions you kept against review suggestions — all three hold up on inspection: copy-over-rename (a rename-then-reset failure leaves the path missing and indistinguishable from first run), the fingerprint split (the silent-nativeTheme.updated swallow race is real in base), and empty-list scrolling (the error-state gate independently prevents the harmful case). You were also right that the two earlier reviews sent mixed signals on these points — the second review's verification was the authoritative read, and your dispositions resolved the first review's asks correctly.

Only P3s remain: BOM-less UTF-16 still takes the reset path (narrower than the BOM-marked case you fixed), mcp-config-store still treats a UTF-8 BOM as corrupt (inconsistent with settings now, though not a regression vs base), Esc-during-busy can swallow a late invalid-config-file result in the TUI (self-heals), and schema-invalid mcp.json gets generic guidance rather than the repair path (the deliberate invalid-json+path boundary).

中文

fdafc10a 复审通过:两轮评审的全部具体发现均在真实代码路径修复——BOM 处理、symlink 拒绝(lstat+O_NOFOLLOW 闭环)、内容寻址备份+完整复用验证、dirsync 失败后保留完成备份、rename 前源文件重校验(诚实标注 guard 非 CAS);持久化顺序正确(备份 fsync+dirsync 先于唯一 rename 提交点),检测仍正确收窄,测试是真·分阶段故障注入。Desktop/CLI 侧:刷新权威归一、队列化应用对话框+banner 路径前置+点击聚焦、typed IPC result 端到端替代英文解析、TUI 错误态键位门禁完整、三 locale "quit maka"。你保留的三处决定经审视全部成立(copy-over-rename、指纹拆分、空列表滚动);前两轮评审口径不一的问题你判断得对——第二轮验证结论为准。仅剩 P3:无 BOM UTF-16 仍走重置、mcp-config-store 的 BOM 处理不一致(非回归)、Esc-during-busy 吞迟到的错误结果(自愈)、schema-invalid 走通用错误(刻意边界)。


Re-review generated with AI assistance (Devin); verified against head fdafc10a.

@Astro-Han
Astro-Han force-pushed the fix/storage-settings-corrupt-recovery branch 4 times, most recently from 15b401a to fdafc10 Compare September 23, 2026 14:48
@chinawch007
chinawch007 force-pushed the fix/storage-settings-corrupt-recovery branch from fdafc10 to 1832e50 Compare September 23, 2026 20:49
@chinawch007

Copy link
Copy Markdown
Contributor Author

Thanks for the re-review and for confirming the earlier fixes. I rebased the branch onto current main (9082cf144) and addressed the four remaining P3 observations as follows.

  1. BOM-less UTF-16 settings: document the encoding contract; keep recovery behavior unchanged.

    A BOM-less UTF-16 file is not inherently invalid Unicode. However, without a BOM or an explicit external encoding declaration, we cannot reliably establish the intended encoding from arbitrary file contents. Alternating NUL bytes can suggest UTF-16 for ASCII-heavy JSON, but that is a heuristic, not a dependable encoding contract. Adding such detection would introduce more decisions about byte order, ambiguous inputs and unsupported encodings, with limited benefit for configuration files that are specified as UTF-8.

    I therefore documented in both README.md and README.zh-CN.md that manually edited settings.json and mcp.json must be saved as UTF-8, preferably without a BOM; a UTF-8 BOM is accepted. Users should explicitly convert UTF-16 files in their editor. Establishing this supported format is more practical than guessing the encoding during recovery.

    This is a documentation-only change for settings. The existing UTF-8 BOM handling and refusal of BOM-marked UTF-16 remain unchanged. A BOM-less UTF-16 settings file that fails JSON parsing still follows the existing exact-byte backup and default-settings recovery path; the documentation does not claim that this case has gained encoding detection.

  2. UTF-8 BOM in MCP configuration: fixed for both file reads and imports.

    The MCP store and import normalizer now use the same parsing helper, which removes exactly one leading U+FEFF before JSON parsing. This accepts UTF-8 BOM files and both wrapped and direct-map JSON imports. Reading a valid file does not rewrite it. The original-input size limit, schema validation and handling of BOM characters inside string values remain unchanged.

    Regression tests cover files with and without a BOM, both import shapes, preservation of the original file bytes, and malformed/double-BOM input. Truly malformed persisted JSON still produces the typed, path-bearing repair error, excludes parser secrets, and cannot be overwritten by a mutation.

  3. Esc during a busy TUI action: preserve late file diagnostics.

    Esc now dismisses the busy view without invalidating that operation's eventual file diagnostic. If the user is back at the server list when invalid-config-file arrives, the repair view is displayed. If the user has started editing or is in a confirmation view, the diagnostic is retained and presented when returning to the list, without destroying the draft or interrupting the confirmation.

    Closed overlays ignore late results. The operation sequence still prevents an older result from replacing a newer operation's result. Ordinary completion results do not reopen a dismissed busy view. A deferred diagnostic is cleared when a subsequent configuration write succeeds; another failure does not erase it merely because an operation completed.

    Tests exercise each of these cases. Previously, another operation could rediscover the corrupt file, but the lost diagnostic did not reappear by itself; this change removes that dependence on a user retry.

  4. Schema-invalid MCP JSON: leave the existing boundary in place.

    I agree that a more specific diagnostic would help when JSON parses successfully but its configuration structure is invalid. This revision does not add that behavior. This PR addresses recovery from damaged persisted JSON described in bug(storage): settings.json, atomic replace has no durability fence and a corrupted file is unrecoverable; the atomic-write pattern drifts across stores #4285; parseable-but-invalid configuration is a separate validation and compatibility problem.

    Handling it consistently would broaden the scope substantially: persisted-file errors would need to be distinguished from pasted input and programmatic edits, unsupported versions and invalid fields would need appropriate guidance, and safe diagnostics would need to remain consistent across storage, IPC, Desktop and CLI without exposing configuration secrets. This is beyond the original issue's recovery boundary. It does not inherently require an automatic reset, and a small, separately scoped diagnostic improvement could be considered later.

    Existing validation and error reporting from main remain intact. MCP files are not reset, and normalization errors do not become permission to reset settings. The dedicated persisted-file repair path remains limited to invalid-json errors with a file path.

The rebase also carries the existing typed MCP error transport into main's new Module Hub controller and its update/setEnabled APIs. A controller regression test verifies that both loading and actions still surface the repair diagnostic after the migration.

Verification on the rebased working tree:

  • Full Storage suite: 1,497 passed, 8 skipped (concurrency 2).
  • Full CLI suite: 1,183 passed, 3 skipped (concurrency 1).
  • Full Desktop suite: 2,847 passed (concurrency 2).
  • Full build, workspace typechecks, lint, formatting, and Desktop/UI knip passed.
  • Renderer architecture passed against upstream/main with --strict-base; locale hygiene, TUI copy boundaries, Windows test inventory, and ASF headers on the exported source snapshot passed.

These are local macOS checks; no Windows or Linux application smoke run was performed for this follow-up.

AI assistance: OpenAI Codex implemented the changes, ran the checks and drafted this reply. Final submission and review remain with the human contributor.

@chinawch007
chinawch007 force-pushed the fix/storage-settings-corrupt-recovery branch from 1832e50 to b9cfe47 Compare September 24, 2026 03:09
@github-actions github-actions Bot added effort/XXL Over 2500 readable lines and removed effort/XL Under 2500 readable lines labels Sep 24, 2026
@chinawch007
chinawch007 force-pushed the fix/storage-settings-corrupt-recovery branch from b9cfe47 to 6b9b304 Compare September 24, 2026 18:11

@hqhq1025 hqhq1025 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.

Reviewed the current head's storage-recovery and MCP error paths. I found no substantiated P0–P3 issue in the inspected changes.

The settings store backs up invalid JSON bytes before resetting defaults, verifies that the source has not changed before publication, and distinguishes a pre-publication failure from uncertain durability after rename (packages/storage/src/settings-store.ts:167-299,457-508; packages/storage/src/atomic-file-write.ts:100-125). The desktop reporter retains a recovery notice until a usable window exists (apps/desktop/src/main/settings-recovery.ts:116-165). MCP invalid-JSON diagnostics cross IPC as a structured failure rather than a parser message (apps/desktop/src/main/mcp-ipc-main.ts:83-104). The changed recovery tests cover interruption, repeat attempts, symlinks, cleanup failures, and concurrent reads.

The current-head test, package, and windows_recovery checks passed and git diff --check was clean. The PR currently conflicts with main in docs/windows-test-inventory.md (git merge-tree failed), so it is not merge-ready. This was a static review of the key storage, desktop notification, MCP IPC, CLI presentation, and adjacent tests; I did not run local tests (Node 18/no installed dependencies), a real disk-fault run, or a packaged Desktop recovery. No database schema/migration file changed. Resolve the conflict and revalidate the resulting head before a human merge decision.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

@chinawch007
chinawch007 force-pushed the fix/storage-settings-corrupt-recovery branch from 6b9b304 to f937160 Compare September 26, 2026 00:11

@hqhq1025 hqhq1025 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.

I independently reviewed the current PR head f9371607af2d974c75a62f5e995037a9b5104fcd. I found no substantiated P0–P3 issue in the inspected recovery and MCP paths.

The PR backs up corrupt settings.json bytes before publishing defaults, rechecks the source before replacement, and distinguishes pre-commit failures from uncertain durability (packages/storage/src/settings-store.ts:169-221,243-303,457-508). The Desktop reporter retains recovery guidance until a window can present it (apps/desktop/src/main/settings-recovery.ts:110-165); MCP invalid-file errors cross IPC as typed results (apps/desktop/src/main/mcp-ipc-main.ts:83-99). The focused tests cover backup faults, symlink/source races, startup notice delivery, and the preload's actual Runtime Host identity seam.

This head rebases the eight-commit series onto newer main: git range-diff shows the functional patches unchanged, with the Windows inventory totals adjusted for the newer base. I checked the resulting 41-file PR diff and affected call paths rather than treating the old-head review as current. Current-head test, package, and windows_recovery checks pass; the PR diff check and a synthetic merge onto freshly fetched main are clean. I did not run tests locally (Node 18/no installed dependencies), reproduce real disk faults, or exercise packaged Desktop recovery. No database schema/migration file changed. This is not a merge approval; the recovery policy and user-facing behavior remain for maintainers to decide.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

Recover only JSON parse failures after preserving the original bytes in an
exclusive owner-only backup. Keep read, normalization and migration errors
outside recovery, and distinguish failures before publication from an
unconfirmed default-settings publication without replaying the mutation.

Report recovery through localized desktop notifications and the existing
settings effects queue. Track renderer delivery separately from applied
settings so silent recovery cannot consume the pending change event.

Cover byte preservation, permissions, fault boundaries, callback failures,
queued mutations and desktop refresh behavior.

Refs apache#4285

Generated-by: OpenAI Codex
Return a typed invalid-JSON error naming the persisted file while preserving
its bytes and omitting parser messages that may contain credentials.

Show localized repair guidance in Desktop and TUI, including mutations after
a successful TUI startup. Keep live MCP state intact and make error details
scrollable in small terminals, with Escape returning to the server list.

Cover read and mutation refusal, localized rendering, scrolling, existing
connections and explicit operations after an external file repair.

Refs apache#4285

Generated-by: OpenAI Codex
Recheck the source immediately before publishing defaults, preserve external
repairs, and accept UTF-8 BOM files without resetting their settings. Reuse
verified content-based backups across retries while preserving interrupted
backup writes and moving to an available suffix.

Keep recovery guidance pending until an application dialog is acknowledged,
including startup failures and notification clicks with a closed window.
Use the existing settings refresh paths and retain pending renderer changes.

Cover interrupted processes, backup reuse failures, concurrent repairs,
encoding and link boundaries, and startup notification delivery.

Refs apache#4285

Generated-by: OpenAI Codex
Carry safe typed MCP file errors through a shared main/preload/renderer
result envelope instead of matching Electron-wrapped English messages.
Keep the original error cause available to native consumers.

Block management actions after TUI initialization fails while keeping repair
details scrollable, including the empty server list. Clarify localized repair
guidance and cover all MCP IPC operations and parser-secret redaction.

Refs apache#4285

Generated-by: OpenAI Codex
Keep the MCP failure guard beside its only renderer consumer and declare
shared IPC result types in a .d.ts file. This preserves typed repair errors
without adding a source module to the legacy renderer dependency closure.

Leave the architecture rules and debt ledger unchanged. Verified the strict
architecture check against the CI merge base, all 112 checker tests, 24 MCP
regression tests, Desktop build, typecheck, lint, formatting and Knip.

Refs apache#4285

Generated-by: OpenAI Codex
Handle the upstream runtime-host:awaitReady handshake and verify its target scope before exercising typed MCP config failures.

Generated-by: OpenAI Codex
Accept a leading UTF-8 BOM on MCP reads and imports, and retain late file diagnostics after dismissing the TUI busy view without interrupting drafts or newer operations.

Document the UTF-8 configuration contract and verify typed repair errors through the rebased Module Hub controller.

Generated-by: OpenAI Codex
@chinawch007
chinawch007 force-pushed the fix/storage-settings-corrupt-recovery branch from f937160 to f1b26dc Compare September 27, 2026 13:07

@hqhq1025 hqhq1025 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.

Re-review result

Exact head: f1b26dcc06614d44df25526b60ce4322a2d6e2c3

I found no substantiated P0-P3 issue in the inspected settings-recovery and MCP paths.

For invalid JSON only, SettingsStore preserves the exact source bytes in an exclusive private backup before publishing defaults, rechecks the source snapshot immediately before replacement, and keeps pre-publication failures distinct from an uncertain post-rename durability result (packages/storage/src/settings-store.ts:169-236,238-316). A concurrent repair is reread rather than overwritten. The Desktop reporter queues recovery guidance until a usable window acknowledges it, while native notification failures remain best-effort (apps/desktop/src/main/settings-recovery.ts:108-164). MCP accepts an optional UTF-8 BOM and carries invalid-file failures across IPC as a typed, path-only result without exposing parser text (packages/storage/src/mcp-config-store.ts:184-216; apps/desktop/src/main/mcp-ipc-main.ts:83-99). No database schema or migration changes are present.

The final commit only updates the MCP preload test's Runtime Host identity/scope mock for the current protocol. The recovery behavior remains unchanged. Validation on this exact head included a clean install and test build, Storage 1,528 passed / 11 skipped, Desktop 2,804/2,804, CLI 1,323 passed / 3 skipped, and 238/238 focused recovery/MCP/notification tests. One FIFO portability test failed only during the first heavily concurrent CLI run and passed in isolation on both this head and current main; the full isolated CLI rerun passed. Typecheck, lint, format, ASF headers, renderer architecture/build/staleness, Windows inventory, E2E budget, TUI copy, locale hygiene, and git diff --check passed. Hosted test, package, and windows_recovery are successful. A production probe preserved corrupt bytes and returned defaults on this head; exact base threw SyntaxError and created no backup.

Current main is 18827d99c5704e185398b94d9d4e1ca666d2219f. The merge tree is conflict-free, and a synthetic current-main merge completed build:test plus 238 focused recovery tests. The only main commit after the earlier synthetic validation changes unrelated interrupted-resume/UI files and has no path overlap with this PR.

Residual scope: I did not run native Windows/macOS recovery, actual power-loss or filesystem-durability fault injection, packaged Electron recovery, or real third-party OAuth.

Automated review notice: This comment was posted by an automated review agent operated by hqhq1025. It is not an independent human review and does not replace one.

@Astro-Han Astro-Han 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.

Reviewed f1b26dcc06614d44df25526b60ce4322a2d6e2c3 (8 commits; ~880 production lines across 21 files, the rest tests). The change makes an invalid-JSON settings.json recoverable — the original bytes are backed up, defaults are republished behind a precondition, the user is told what happened — and gives MCP config the same JSON-failure treatment across IPC.

No P0–P3 findings. Three areas, in the order the dispatch asked.

1. Recovery ordering — the fences compose correctly

  • Only a SyntaxError from parseSettings enters recovery (packages/storage/src/settings-store.ts:189-196), and the parse error is deliberately never attached, because its message can quote stored secrets.
  • Before anything is replaced, readSettingsSnapshot re-reads and byte-compares (:198-200): an external repair wins, and that path does one reread and never recurses (:218-232), so a still-corrupt file cannot loop or fall into the first-run ENOENT path.
  • The backup happens before the reset and is exclusive, private and content-addressed (:236-296): open(…, 'wx', 0o600), chmod 0600 off Windows, fsync, then a directory sync. A pre-existing candidate is reused only after verifyAndSyncBackup re-checks size, nlink === 1, mode and owner, and byte equality (:347-372).
  • The reset publishes through writeAtomicFile's new beforePublish hook, which re-checks the identical source (stat and bytes) immediately before rename (settings-store.ts:201-208, atomic-file-write.ts:114) — so an edit made between backup and publish still wins. The hook is documented as "not an atomic compare-and-swap … a rejection leaves the target untouched and removes the temp", which is the honest boundary; two concurrent recoveries would both publish the same defaults.
  • commit-unknown is never replayed: it is reported as commit-unknown and rethrown as SettingsRecoveryCommitUnknownError, which tells the caller to reload rather than retry (:96-108, :209-212). reportRecovery fires only after publication and is wrapped so a failing notification cannot change the write result (:298-310).

2. Cross-platform paths and permissions

  • Backups are created 0o600 and re-chmoded off Windows (:262-274); on Windows that is skipped, and settings.json keeps its historical umask-derived mode — the store says explicitly that it does not own the directory's permission policy (:402-410).
  • readSettingsSnapshot refuses anything that is not a regular file, opens with O_NOFOLLOW | O_NONBLOCK off Windows, and re-checks lstat, open-stat, read-stat and lstat again with a byte-length cross-check (:333-357). Symlinks and FIFOs therefore cannot be opened, and a concurrent swap invalidates the snapshot. verifyAndSyncBackup additionally requires nlink === 1 and the current euid as owner (:352-356).
  • Windows specifics I could only reason about: O_NOFOLLOW is absent (handled by the win32 branch) and ino/ctimeNs are weaker discriminators there. Every fence that decides whether a file may be replaced is backed by a byte comparison in the caller, so a timestamp or inode collision cannot cause a changed file to be overwritten.

3. MCP config failures crossing IPC carry no parser text

  • The store drops the SyntaxError and throws McpConfigSourceError('invalid-json', …, path) with a location and guidance, on the stated ground that JSON.parse can quote credentials in its message (packages/storage/src/mcp-config-store.ts:285-297). parseMcpJson also accepts a leading UTF-8 BOM at the document boundary (:184-187).
  • Main turns exactly that case into data — { kind: 'invalid-mcp-config-file', path } with control characters stripped (apps/desktop/src/main/mcp-ipc-main.ts:84-98) — for the stated reason that Electron IPC and the context bridge do not preserve custom Error properties, while every other error still rethrows.
  • The renderer unwraps that data into a local error and maps it to localized copy, walking the cause chain with a cycle guard (packages/.../model/mcp-page-model.ts:30-52); the CLI renders a scrollable config_error phase holding only the path; and the recovery reporter's log contract is explicit that it "Receives only the result and paths, never JSON contents or parser errors" (apps/desktop/src/main/settings-recovery.ts:127).

The user-facing path is not fire-and-forget either: a native banner is attempted immediately, and the persistent dialog is held pending until a usable window exists (early-window.ts — showNotice returns false while the window is missing, hidden or minimized), with the backup path placed first in the banner body because native banners truncate (settings-recovery.ts:99-110). A UTF-16 settings file is deliberately not auto-recovered: it fails with guidance to save it as UTF-8 and leaves the file untouched.

Checks on this revision: test, package and windows_recovery are all green.

What I could not judge

  • No native Windows or macOS run, no real power-loss or fsync fault injection, no packaged Electron, and no live third-party OAuth.
  • The Windows ino/ctimeNs reasoning above is read from the code, not observed on Windows; the byte comparisons are what make that acceptable, and I did not test a same-tick modification there.

I did not approve, request changes, or merge.


Automated review notice: This comment was posted by an automated review agent operated by Astro-Han. It is not an independent human review and does not replace one.

@Astro-Han Astro-Han 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.

Third independent review lineage at f1b26dcc (supplements the two earlier reviews on this head).

No P0–P2. Focused on data loss and false recovery:

  • The original bytes are backed up durably before defaults are published: exclusive, private, content-addressed, with the file and directory fsynced.
  • The source is re-read and compared byte for byte before publishing, and a commit-unknown outcome is never replayed or reported as success.
  • Only a genuine JSON SyntaxError enters recovery. Transient read errors, UTF-16 files, and valid-but-unexpected JSON are not reset.
  • The tests would fail on the old code.

Two non-blocking P3s (inline):

  • The pre-publish re-check is not an atomic compare-and-swap. An in-place external fix landing between beforePublish and rename would still be replaced by defaults. The window is tiny, and the backup still holds the corrupt original.
  • The recovery notice is held only in memory. If the app exits before any window can show it, the user is never told where the backup went. Persisting a marker, or re-deriving it from the backup directory on next launch, would close that gap.

Not verified: tests were not run locally; no native Windows/macOS or real power-loss run.

This review was produced with automated assistance (AI review agents) and checked by a maintainer-side reviewer before posting.

await handle.close().catch(() => {});
throw error;
}
await options.beforePublish?.();

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.

P3 — beforePublish followed by rename is check-then-act, not an atomic compare-and-swap. An external in-place fix to settings.json that lands after the re-check but before the rename is overwritten by defaults. The window is microseconds and the backup keeps the corrupt original, so this is not blocking. Worth a comment noting that the fence narrows the race but does not close it.

/** Keeps recovery guidance until the app can display it. The existing startup
* and file-watcher paths remain the only settings-effect refresh authorities. */
export function createSettingsRecoveryReporter(deps: SettingsRecoveryReporterDependencies) {
const pending: CorruptSettingsRecovery[] = [];

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.

P3 — pending lives only in memory. If the app quits (or crashes) after recovery but before a usable window acknowledges the notice, the next launch reads the now-valid defaults and never tells the user their settings were reset or where the backup is. Consider persisting a small marker next to the backup, or re-deriving the notice from unacknowledged backups on startup.

@Astro-Han Astro-Han 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.

Approved at @Astro-Han's explicit request: independent automated reviews of this head from three model families found no blocking (P0–P2) issues, and CI (test, package, windows_recovery) is green. The two P3 notes are non-blocking follow-ups.

@Astro-Han
Astro-Han merged commit 14c0d76 into apache:main Sep 27, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/XXL Over 2500 readable lines

Projects

None yet

Development

Successfully merging this pull request may close these issues.

bug(storage): settings.json, atomic replace has no durability fence and a corrupted file is unrecoverable; the atomic-write pattern drifts across stores

3 participants