Skip to content

Fix startup update reinstall loop with a durable attempt marker - #1378

Merged
Kosinkadink merged 6 commits into
mainfrom
fix/startup-update-loop-marker-durability
Aug 12, 2026
Merged

Kosinkadink merged 6 commits into
mainfrom
fix/startup-update-loop-marker-durability

Conversation

@Kosinkadink

@Kosinkadink Kosinkadink commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Hopefully addresses #1367

Problem

Telemetry shows single devices re-running the same startup install up to 246 times for one target version (22 devices with >5 attempts on 1.0.29 alone, recurring on every release since). The existing loop-breaker (lastStartupUpdateAttemptVersion) works for most machines (6.5k loop_breaker skips across 2.8k devices) but is clearly not surviving between launches on the looping machines.

The marker is the last settings write before the app quits into the installer, so settings.json.bak is always one write behind it. readFileSafe restored .bak over the primary after ANY read error - including transient AV/indexer locks (EPERM/EACCES/EBUSY). On interference-prone machines that rollback erased the attempt marker while keeping the pending-update marker, so the same version reinstalled on every boot, indefinitely.

Fix

  • safe-file.ts: retry transient lock errors on reads (primary and .bak) and sync renames. Restore .bak over the primary only when the primary is genuinely missing or empty; a merely locked primary is served from .bak WITHOUT restoring. Adds a bakFallbacks diagnostic counter.
  • startup-attempt-marker.ts (new): durable sidecar marker file in configDir() - no .bak machinery to roll it back, read-back verification after writing, and a tri-state read (present/absent/unavailable) so an unreadable (locked) marker is never mistaken for an absent one.
  • updater.ts: the loop-breaker consults both marker homes; the verified sidecar must be durable on disk before any marker is written or the install proceeds. Fail-closed skips (marker_unavailable, marker_not_durable) open the UI normally instead of looping; the explicit pill install is unaffected. Stale sidecars are cleared once the target version is running.

Telemetry to confirm the root cause in the field

The rollback mechanism is telemetry-inferred, not yet directly observed, so this PR also instruments the diagnosis:

  • startup_install_skipped with reason: loop_breaker now carries source: settings | sidecar. A sidecar hit means the settings marker was erased between launches - direct field evidence of the rollback. If loops continue with neither firing, the cause is elsewhere (updater cache errors are the next suspect).
  • startup_install and skip events carry bakFallbacks to correlate looping devices with .bak-served reads.

Tests

New safe-file.test.ts (13) and startup-attempt-marker.test.ts (8) pin the retry/no-restore semantics and marker durability. updater.test.ts adds sidecar-only loop-break, both fail-closed paths (asserting no marker is written so the next launch retries), newer-version override of a failed attempt, and pill-install bypass. Full suite: 226 files / 3695 tests green; typecheck and lint clean.

…arker (#1367)

Telemetry shows single devices re-running the same startup install up
to 246 times for one version. The loop-breaker marker
(lastStartupUpdateAttemptVersion) is the last settings write before the
app quits into the installer, so settings.json.bak is always one write
behind it - and readFileSafe restored .bak over the primary after ANY
read error, including transient AV/indexer locks. On interference-prone
machines the marker kept getting erased while the pending-update marker
survived, so the same version reinstalled on every boot.

safe-file: retry transient EPERM/EACCES/EBUSY on reads and sync
renames; only restore .bak when the primary is genuinely missing or
empty, serving .bak without restoring when the primary is merely
locked; expose a bakFallbacks diagnostic counter.

startup-attempt-marker (new): a tiny sidecar file in configDir()
mirrors the attempt marker with no .bak machinery to roll it back, a
read-back verification after writing, and a tri-state read so an
unreadable (locked) marker is never mistaken for an absent one.

updater: the loop-breaker consults both marker homes; an unavailable
sidecar fails the install decision closed (marker_unavailable); the
sidecar must be verifiably durable before any marker is written or the
install proceeds (marker_not_durable skips install without blocking
future retries); stale sidecars are cleared once the target version is
running; startup_install telemetry carries bakFallbacks to correlate
loops with filesystem interference.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019fd4f6-8904-746f-a85d-9280a17b564b
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The change adds retry-aware safe-file operations, tri-state read results, durable startup-attempt markers, guarded settings and installation writes, and startup-install loop prevention with diagnostics.

Changes

Startup update reliability

Layer / File(s) Summary
Retry-aware safe file operations
src/main/lib/safe-file.ts, src/main/lib/safe-file.test.ts
Safe reads and writes retry transient filesystem errors. Reads return structured outcomes for data, absent, and unreadable files. Backup restoration is limited to absent or empty primaries.
Startup-attempt marker lifecycle
src/main/lib/startup-attempt-marker.ts, src/main/lib/startup-attempt-marker.test.ts
A sidecar marker supports tri-state reads, durable recording with read-back verification, malformed-content handling, and tolerant clearing.
Guarded settings and installation persistence
src/main/settings.ts, src/main/installations.ts, src/main/lib/ipc/index.ts
Settings and installation mutations refuse to overwrite unreadable files. Backup-aware safe-write options are used. Startup persistence failures are logged.
Updater loop prevention and telemetry
src/main/lib/updater.ts, src/main/lib/updater.test.ts
Startup installation checks and records the sidecar marker, fails closed when marker access is unavailable, and reports marker source and safe-file fallback diagnostics. Tests cover marker states, retries, bypasses, and cleanup.

Sequence Diagram(s)

sequenceDiagram
  participant Updater
  participant StartupAttemptMarker
  participant SafeFile
  participant Telemetry
  Updater->>StartupAttemptMarker: readStartupAttemptMarker()
  StartupAttemptMarker->>SafeFile: read marker with retry
  SafeFile-->>StartupAttemptMarker: marker content or read state
  StartupAttemptMarker-->>Updater: present, absent, or unavailable
  Updater->>StartupAttemptMarker: recordStartupAttempt(version)
  StartupAttemptMarker->>SafeFile: write and read back marker
  SafeFile-->>StartupAttemptMarker: persistence result
  Updater->>Telemetry: report marker and fallback diagnostics
Loading

Suggested reviewers: deepme987, benceruleanlu

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR directly addresses issue #1367 by preventing startup update reinstall loops through durable markers, safe-file recovery, retries, and fail-closed behavior.
Out of Scope Changes check ✅ Passed The changes support the startup update loop fix and add focused tests, telemetry, and safeguards without unrelated functionality.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/startup-update-loop-marker-durability
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch fix/startup-update-loop-marker-durability

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

❤️ Share

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

Comments describe current behavior concisely instead of narrating the
pre-fix history; restore the bakPath locals in readFileSafe and
readFileSafeAsync instead of repeating the string concatenation.
No behavior change.

Amp-Thread-ID: https://ampcode.com/threads/T-019fd4f6-8904-746f-a85d-9280a17b564b
Co-authored-by: Amp <amp@ampcode.com>
@coderabbitai
coderabbitai Bot requested a review from deepme987 August 6, 2026 04:20

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 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 `@src/main/lib/safe-file.test.ts`:
- Around line 98-150: Extend src/main/lib/safe-file.test.ts:98-150 by capturing
getSafeFileDiagnostics().bakFallbacks before a .bak fallback read and asserting
it increases by one, accounting for the process-wide monotonic counter. In
src/main/lib/updater.test.ts:789-837, add bakFallbacks: expect.any(Number) to at
least one startup_install_skipped assertion and one startup_install assertion;
update only these tests and preserve their existing assertions.

In `@src/main/lib/safe-file.ts`:
- Around line 129-145: Update readFileSafe and its load() caller to distinguish
an unreadable file from a genuinely missing file: when the primary exists but
readFileWithRetrySync cannot read it and no backup is available, return or
propagate an unreadable result instead of null. Ensure load() fails closed for
unreadable settings so save() cannot treat the file as empty and overwrite it,
while preserving null/empty handling for truly absent files.

In `@src/main/lib/startup-attempt-marker.test.ts`:
- Around line 93-100: Add a companion test alongside the existing
persistence-failure test that allows recordStartupAttempt to write the marker
successfully but forces its read-back to return unavailable, then assert it
returns false. Exercise the verification/read-back branch in
recordStartupAttempt rather than the writeFileSafe failure path, preserving the
fail-closed behavior.

In `@src/main/lib/startup-attempt-marker.ts`:
- Around line 69-78: Update recordStartupAttempt and its marker-write path to
use durable storage semantics rather than treating readStartupAttemptMarker as
proof of persistence. Ensure the marker file and replacement directory are
flushed before returning success, while keeping the existing fast write behavior
for unrelated settings paths through an explicit opt-in or marker-specific
durable option.

In `@src/main/lib/updater.ts`:
- Around line 894-899: Update evaluateStartupInstall and its callers to accept
and reuse the existing StartupAttemptMarkerRead result instead of reading the
startup marker again; at the boot call site, perform a second read only if
clearStartupAttemptMarker actually removed the marker. Import
StartupAttemptMarkerRead with the existing marker types and update the
hasPendingStartupUpdate() caller consistently.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 01e38da6-4c10-4a3a-88ee-4428c66f64cd

📥 Commits

Reviewing files that changed from the base of the PR and between 88632d0 and 978b440.

📒 Files selected for processing (6)
  • src/main/lib/safe-file.test.ts
  • src/main/lib/safe-file.ts
  • src/main/lib/startup-attempt-marker.test.ts
  • src/main/lib/startup-attempt-marker.ts
  • src/main/lib/updater.test.ts
  • src/main/lib/updater.ts

Comment thread src/main/lib/safe-file.test.ts Resolved
Comment thread src/main/lib/safe-file.ts Resolved
Comment thread src/main/lib/startup-attempt-marker.ts Resolved
Comment thread src/main/lib/updater.ts Resolved
…marker, single sidecar read

- readFileSafe/readFileSafeAsync now return tri-state outcomes so callers can
  tell an unreadable (locked) file apart from a missing one; settings.set()
  and installations mutators fail closed instead of overwriting an intact
  file with reconstructed defaults
- the startup attempt marker write is durable (fsync before rename, plus
  best-effort parent dir fsync) so a mid-update power cut cannot erase it
- writeFileSafe/writeFileSafeAsync take a SafeWriteOptions object
- applyPendingUpdateOnStartup reads the sidecar once and passes it into
  evaluateStartupInstall, halving worst-case blocking on locked markers
- pin the bakFallbacks diagnostic end to end and the marker read-back
  fail-closed branch in tests

Amp-Thread-ID: https://ampcode.com/threads/T-019fd4f6-8904-746f-a85d-9280a17b564b
Co-authored-by: Amp <amp@ampcode.com>
@coderabbitai
coderabbitai Bot requested a review from benceruleanlu August 12, 2026 07:16

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/main/installations.ts (1)

91-115: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Let a newer explicit useSharedModels win over the legacy forced true.

Step 2 states the rule plainly: on a mixed-schema record the per-folder flags are newer, so they win. Step 1 does not follow the same rule. ...rest is spread first, then useSharedModels: true overwrites any explicit value the record already carried. A mixed record that holds both useSharedPaths and useSharedModels: false loses the user's choice, and the next write persists the flipped value to disk.

Mixed records are rare, but the migration is sticky — one downgrade dance and shared models waltz back on uninvited.

🐛 Proposed fix to honor the newer flag
   if ('useSharedPaths' in rec) {
     const legacy = rec.useSharedPaths as boolean | undefined
     const { useSharedPaths: _drop, ...rest } = rec
     rec = {
       ...rest,
-      useSharedModels: true,
+      // A mixed-schema record may already carry the newer flag; it wins.
+      useSharedModels: typeof rest.useSharedModels === 'boolean' ? rest.useSharedModels : true,
       useSharedInputOutput: typeof legacy === 'boolean' ? legacy : true
     } as InstallationRecord
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/main/installations.ts` around lines 91 - 115, Update migrateRecord so the
legacy useSharedPaths migration does not overwrite an existing explicit
useSharedModels value. Preserve a boolean useSharedModels from the original
record when present, and only default it to true when absent; keep the existing
useSharedInputOutput migration behavior unchanged.
🤖 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 `@src/main/installations.ts`:
- Around line 179-205: Update loadOutcome() so JSON.parse failures return
unreadable: true rather than an empty readable result, allowing load() to retain
its read-only fallback while loadForWrite() throws before saving. Update
loadForWrite()’s documentation and error message to cover both unreadable files
and corrupt JSON, and leave settings.ts behavior unchanged unless the existing
design confirms it should also fail closed.
- Line 241: Update register() startup calls to seedDefaults(), both
ensureExists() invocations, and enforceCloudName() so each promise is awaited or
has attached error handling; ensure loadForWrite() rejections are handled
without unhandled promise rejections, while preserving the existing _queue
recovery behavior.

In `@src/main/lib/safe-file.test.ts`:
- Around line 173-186: Make fs spy cleanup deterministic in
src/main/lib/safe-file.test.ts: add one suite-level afterEach that calls
vi.restoreAllMocks(), covering the readFileSync spy at lines 173-186, the
promises.readFile spy at lines 236-248, and the fsyncSync spy at lines 44-55.
Remove the explicit vi.restoreAllMocks() call at line 224 because the shared
cleanup replaces it.

In `@src/main/lib/safe-file.ts`:
- Line 237: Replace the synchronous fsyncDirBestEffort call in
writeFileSafeAsync’s durable rename path with an awaited async counterpart. Add
fsyncDirBestEffortAsync beside fsyncDirBestEffort using asynchronous directory
open, sync, and close operations while preserving best-effort error handling,
then await it after the rename.

---

Outside diff comments:
In `@src/main/installations.ts`:
- Around line 91-115: Update migrateRecord so the legacy useSharedPaths
migration does not overwrite an existing explicit useSharedModels value.
Preserve a boolean useSharedModels from the original record when present, and
only default it to true when absent; keep the existing useSharedInputOutput
migration behavior unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 73e3e800-11b6-46c5-a993-eae0bc0290fb

📥 Commits

Reviewing files that changed from the base of the PR and between 978b440 and 96be4a7.

📒 Files selected for processing (8)
  • src/main/installations.ts
  • src/main/lib/safe-file.test.ts
  • src/main/lib/safe-file.ts
  • src/main/lib/startup-attempt-marker.test.ts
  • src/main/lib/startup-attempt-marker.ts
  • src/main/lib/updater.test.ts
  • src/main/lib/updater.ts
  • src/main/settings.ts

Comment thread src/main/installations.ts Resolved
Comment thread src/main/installations.ts Resolved
Comment thread src/main/lib/safe-file.test.ts Resolved
Comment thread src/main/lib/safe-file.ts Resolved
Kosinkadink and others added 2 commits August 12, 2026 00:34
…for a locked primary

readFileSafe/Async now tag backup data served for an unreadable primary
(primaryUnreadable), and settings/installations refuse to persist while
that holds - saving state derived from the stale backup would overwrite
the newer locked primary once the lock clears. Also dedupes the sync and
async fallback policy into one helper and pins that the durable temp-file
fsync happens before the publishing rename.

Amp-Thread-ID: https://ampcode.com/threads/T-019fd4f6-8904-746f-a85d-9280a17b564b
Co-authored-by: Amp <amp@ampcode.com>
….json, handle startup mutation rejections, async dir fsync

- A readable-but-corrupt installations.json now flags the load outcome
  unreadable, so loadForWrite() rejects instead of letting save() replace
  every record with a list built from nothing.
- register() startup mutations (seedDefaults, ensureExists x2,
  enforceCloudName) attach .catch handlers so a fail-closed rejection
  logs a warning instead of becoming an unhandled rejection.
- writeFileSafeAsync uses an async directory fsync so a durable async
  write cannot block the main-process event loop.

Amp-Thread-ID: https://ampcode.com/threads/T-019fd4f6-8904-746f-a85d-9280a17b564b
Co-authored-by: Amp <amp@ampcode.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 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 `@src/main/settings.ts`:
- Around line 409-418: The loadOutcome JSON.parse failure path must fail closed:
in its catch block, set unreadable to true so corrupt primary settings are never
overwritten by normalization or set(). Add a corrupt-primary test alongside the
locked-primary test, covering both automatic loading and set() preservation, and
ensure no rewrite occurs for corrupt data.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

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

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 6788e5d3-895e-4d1f-ade3-b32506dd7acd

📥 Commits

Reviewing files that changed from the base of the PR and between 96be4a7 and d5b5b1a.

📒 Files selected for processing (7)
  • src/main/installations.test.ts
  • src/main/installations.ts
  • src/main/lib/ipc/index.ts
  • src/main/lib/safe-file.test.ts
  • src/main/lib/safe-file.ts
  • src/main/settings.test.ts
  • src/main/settings.ts

Comment thread src/main/settings.ts
Comment on lines +409 to +418
function loadOutcome(): { settings: Settings; unreadable: boolean } {
maybeSeedFromEnv()
let parsed: Record<string, unknown> | null = null
const raw = readFileSafe(dataPath)
if (raw) {
let unreadable = false
const read = readFileSafe(dataPath)
if (read.kind === 'unreadable') {
return { settings: { ...defaults }, unreadable: true }
}
if (read.kind === 'data') {
unreadable = read.primaryUnreadable === true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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

Fail closed after a settings JSON parse failure.

If JSON.parse(read.data) throws at Lines 419-425, unreadable remains false. set() can then save defaults over the corrupt settings.json. Mark this outcome unreadable in the catch block, so both automatic normalization writes and set() preserve the file. Add a corrupt-primary test beside the locked-primary test. Corrupt data must not get a rewrite delight.

Proposed fix
     } catch (err) {
       console.warn('Settings: failed to parse settings JSON:', (err as Error).message)
+      return { settings: { ...defaults }, unreadable: true }
     }

Also applies to: 577-578, 615-623

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

In `@src/main/settings.ts` around lines 409 - 418, The loadOutcome JSON.parse
failure path must fail closed: in its catch block, set unreadable to true so
corrupt primary settings are never overwritten by normalization or set(). Add a
corrupt-primary test alongside the locked-primary test, covering both automatic
loading and set() preservation, and ensure no rewrite occurs for corrupt data.

@Kosinkadink
Kosinkadink added this pull request to the merge queue Aug 12, 2026
Merged via the queue into main with commit bf30592 Aug 12, 2026
14 checks passed
@Kosinkadink
Kosinkadink deleted the fix/startup-update-loop-marker-durability branch August 12, 2026 07:50
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 12, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants