Files
galaxy/specs/danielpeng/QUALITY-474/TECH.md
T

18 KiB

Settings File Offline Edit Detection — Tech Spec

Linear: QUALITY-474

Problem

CloudPreferencesSyncer::handle_initial_load uses ForceCloudToMatchLocal::No on normal startup, meaning cloud values overwrite local values for all synced keys. This is incorrect when the user has made local changes (file edit while Warp was closed, or UI change while offline) that haven't been synced to cloud.

Relevant Code

  • app/src/settings/cloud_preferences_syncer.rsCloudPreferencesSyncer, handle_initial_load, maybe_sync_local_prefs_to_cloud, ForceCloudToMatchLocal
  • app/src/settings/init.rsinit(), init_public_user_preferences(), UserDefaultsOnStartup
  • app/src/lib.rs (930-960)initialize_app, where settings::init is called and UserDefaultsOnStartup is consumed
  • crates/warpui_extras/src/user_preferences/toml_backed.rsTomlBackedUserPreferences, flush(), reload_from_disk()
  • crates/settings/src/manager.rsSettingsManager

Current State

Cloud sync runs after startup via an async chain: handle_user_fetchedsync() → waits for initial_load_complete()handle_initial_load(ForceCloudToMatchLocal::No). For every synced key present in cloud, maybe_sync_cloud_pref_to_local overwrites the local value via update_setting_with_storage_key(..., from_cloud_sync: true), which writes through to the TOML file.

The ForceCloudToMatchLocal::Yes path already exists — it skips cloud→local and forces local→cloud instead. It's currently only triggered when the user manually re-enables settings sync.

Already implemented on the daniel/inhibit-writes-mode branch:

  • Flush suppression when TOML file fails to parse (broken file preserved on disk)
  • Settings error banner with workspace-level UI
  • SettingsFileError type, WarpConfigUpdateEvent::SettingsErrors/SettingsErrorsCleared
  • init_public_user_preferences() returns (Model, Option<String>) with parse error

Proposed Changes

1. Hash computation on TomlBackedUserPreferences

crates/warpui_extras/src/user_preferences/toml_backed.rs:

/// Hashes the settings file content on disk.
///
/// Returns `None` if the file is missing, empty/whitespace-only, or
/// unreadable. These cases are all treated as "no local state" rather
/// than "local state that should win" — see the startup comparison
/// logic in `init.rs` for the rationale.
///
/// Uses SHA-256 so that persisted hashes are stable across Rust
/// toolchain upgrades and crate version bumps (unlike `SipHasher`
/// or `DefaultHasher`, whose output is not guaranteed to be stable).
pub fn file_content_hash(file_path: &Path) -> Option<String> {
    use sha2::{Digest, Sha256};
    let contents = match std::fs::read_to_string(file_path) {
        Ok(c) => c,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
        Err(err) => {
            log::warn!(
                "Failed to read settings file at {}: {err}",
                file_path.display()
            );
            return None;
        }
    };
    // An empty/whitespace-only file is semantically equivalent to a
    // missing file — no settings are defined. Treating them the same
    // way avoids wiping cloud with defaults if the user empties the
    // file to reset.
    if contents.trim().is_empty() {
        return None;
    }
    let digest = Sha256::digest(contents.as_bytes());
    Some(format!("{digest:x}"))
}

sha2 is already a workspace dependency, so no new dependency is required.

2. Single entry point for syncer construction

The hash comparison and the storage-key constant live in cloud_preferences_syncer.rs alongside the syncer they configure, rather than in init.rs. This keeps the syncer's startup decision cohesive with the syncer module and gives both production callers and tests a single seam to exercise — see the Test Infrastructure subsection of Testing and Validation for why this matters.

pub(super) const SETTINGS_FILE_LAST_SYNCED_HASH_KEY: &str =
    "SettingsFileLastSyncedHash";

/// Constructs the cloud preferences syncer, computing the
/// `force_local_wins_on_startup` flag by comparing the current
/// settings file hash against the last-synced hash stored in private
/// preferences. This is the only entry point used to construct the
/// syncer at app startup; production code in `lib.rs` and end-to-end
/// tests both call it so they exercise the same code path.
pub fn initialize_cloud_preferences_syncer(
    toml_file_path: &Path,
    startup_toml_parse_error: Option<&str>,
    ctx: &mut ModelContext<CloudPreferencesSyncer>,
) -> CloudPreferencesSyncer {
    let current_hash = TomlBackedUserPreferences::file_content_hash(toml_file_path);
    let stored_hash = ctx
        .private_user_preferences()
        .read_value(SETTINGS_FILE_LAST_SYNCED_HASH_KEY)
        .unwrap_or_default();

    let file_has_unsynced_changes = match (current_hash, stored_hash) {
        // File present, stored hash present: trust the comparison.
        (Some(current), Some(stored)) => current != stored,
        // File present, no stored hash (first launch, fresh install,
        // or the stored hash was cleared): cloud wins, consistent
        // with today's behavior.
        (Some(_), None) => false,
        // File missing/empty, stored hash present (user deleted or
        // emptied the file): cloud wins. If we treated this as
        // "local differs" we'd upload defaults and wipe the user's
        // cloud settings — exactly what they likely don't want.
        (None, Some(_)) => false,
        // File missing/empty, no stored hash (fresh install with no
        // file yet): cloud wins.
        (None, None) => false,
    };

    // Broken-file guard: when the file can't be parsed, there are no
    // meaningful local values to preserve. Cloud sync restores
    // settings in memory while flush suppression protects the broken
    // file on disk.
    let force_local_wins_on_startup =
        file_has_unsynced_changes && startup_toml_parse_error.is_none();

    CloudPreferencesSyncer::new(force_local_wins_on_startup, ctx)
}

init() does not need to compute the hash itself — it continues to populate settings_file_error on UserDefaultsOnStartup (already implemented). lib.rs extracts the file-parse error from that field and passes it through to initialize_cloud_preferences_syncer so the broken-file guard still applies.

3. Plumb the flag through CloudPreferencesSyncer

app/src/settings/cloud_preferences_syncer.rs:

The syncer shouldn't know why local is considered authoritative — only that it should treat local as authoritative on the first initial load. Pass the signal through the constructor so future triggers (crash-recovery marker, offline settings queue, etc.) can be OR'd into the same bool without touching the syncer.

The constructor also takes a toml_file_path: PathBuf so that update_stored_settings_hash can compute the hash without relying on a global function.

pub struct CloudPreferencesSyncer {
    // ... existing fields ...
    force_local_wins_on_startup: bool,
    toml_file_path: PathBuf,
}
impl CloudPreferencesSyncer {
    pub fn new(
        force_local_wins_on_startup: bool,
        toml_file_path: PathBuf,
        ctx: &mut ModelContext<Self>,
    ) -> Self {
        let mut me = Self::new_internal(ctx, Arc::new(DefaultClientIdProvider), toml_file_path);
        me.force_local_wins_on_startup = force_local_wins_on_startup;
        me.retry_failed_settings(ctx);
        me
    }
}

In handle_initial_load, override force_cloud_to_match_local only on the first invocation:

let force_cloud_to_match_local =
    if !self.has_completed_initial_load && self.force_local_wins_on_startup {
        ForceCloudToMatchLocal::Yes
    } else {
        force_cloud_to_match_local
    };

The !self.has_completed_initial_load guard makes this a genuine one-time override — robust against any future code path that calls sync() multiple times.

Two additional guards in the syncer:

  • Subscription guard: The UpdateManagerEvent::CloudPreferencesUpdated subscription handler also checks !has_completed_initial_load && force_local_wins_on_startup and returns early. Without this, mock_initial_load synchronously emits cloud preference events that would overwrite local values before handle_initial_load's future-based override runs.
  • Key ordering in handle_initial_load: The keys_to_sync list is sorted so that settings with RespectUserSyncSetting::No are processed first. This ensures IsSettingsSyncEnabled is restored from cloud before other settings check settings_sync_enabled. Without this, HashMap iteration order could leave sync disabled when the file is deleted or on a fresh device, causing other settings to be silently skipped.

In lib.rs, call the entry point from Section 2 with the file path and the file-parse error already available on UserDefaultsOnStartup:

let toml_path = settings::user_preferences_toml_file_path();
let parse_error = user_defaults_on_startup
    .settings_file_error
    .as_ref()
    .and_then(|err| match err {
        SettingsFileError::FileParseFailed(msg) => Some(msg.clone()),
        SettingsFileError::InvalidSettings(_) => None,
    });
ctx.add_singleton_model(move |ctx| {
    initialize_cloud_preferences_syncer(
        &toml_path,
        parse_error.as_deref(),
        ctx,
    )
});

4. Update the stored hash after sync reconciliation

Add a helper in cloud_preferences_syncer.rs:

fn update_stored_settings_hash(&self, ctx: &mut ModelContext<Self>) {
    let Some(hash) = TomlBackedUserPreferences::file_content_hash(&self.toml_file_path)
    else {
        return;
    };
    if let Err(err) = ctx
        .private_user_preferences()
        .write_value(SETTINGS_FILE_LAST_SYNCED_HASH_KEY, hash)
    {
        log::warn!("Failed to persist settings file hash after sync: {err}");
    }
}

This is called from two sites:

  1. handle_initial_load — synchronously at the end, after InitialLoadCompleted is emitted. At this point reconciliation is complete.
  2. handle_sync_queue_event — a subscription on SyncQueue events that fires when a cloud preference is successfully created or updated on the server. This replaces the earlier approach of calling update_stored_settings_hash synchronously at the end of maybe_sync_local_prefs_to_cloud, which was incorrect: the upload is async (enqueued to the SyncQueue), so the hash was being recorded before the server had actually accepted the change. If the user was offline, the upload would silently fail but the hash would already reflect the new file content, causing the next startup to miss the divergence.

The SyncQueue subscription checks whether the successfully synced object is actually a cloud preference (not another GenericStringObject type like env var collections or MCP servers) by looking up the server_id in CloudModel::get_all_cloud_preferences_by_storage_key().

Broken-file interaction: when flush is suppressed, file_content_hash reads the broken file from disk and stores its hash. On the next startup, if the file is still broken, hashes match → cloud wins (the broken-file guard still applies). If the user fixed the file, hashes differ → local wins with the fixed content. Both are correct.

Missing-file interaction: if the file is missing after sync (edge case — normally sync would have written cloud values to disk), file_content_hash returns None and the helper is a no-op. On the next startup, (None, stored_hash_from_previous_session) → cloud wins, which triggers recovery.

End-to-End Flow

sequenceDiagram
    participant User
    participant File as settings.toml
    participant LibRs as lib.rs
    participant Init as initialize_cloud_preferences_syncer
    participant Syncer as CloudPreferencesSyncer
    participant Cloud as Cloud Storage
    participant PrivPrefs as Private Preferences

    Note over User,File: User edits file while Warp is closed

    User->>File: Edit settings.toml

    Note over LibRs: Warp starts up
    LibRs->>Init: initialize_cloud_preferences_syncer(toml_path, parse_error)
    Init->>File: file_content_hash(toml_path)
    Init->>PrivPrefs: Read stored hash
    Init->>Init: Compare hashes → differ → force_local_wins_on_startup = true
    Init->>Syncer: CloudPreferencesSyncer::new(force_local_wins_on_startup)
    Note over Syncer: Cloud sync initial load
    Syncer->>Syncer: Override to ForceCloudToMatchLocal::Yes
    Syncer->>Cloud: Upload local values (local wins)
    Syncer->>File: file_content_hash(toml_path)
    Syncer->>PrivPrefs: Store new hash

Risks and Mitigations

  • Race between flush suppression and hash storage: When the file is broken on startup, flush is suppressed. The hash stored after initial sync is the broken file's hash (read from disk). This is correct: if the file is later fixed, the hash will differ on next startup, triggering local-wins with the fixed content.
  • Two devices with offline changes: The last device to sync wins. This is a known limitation documented in the product spec as a non-goal.

Testing and Validation

Test infrastructure

The new end-to-end tests in this spec rely on two pieces of infrastructure introduced alongside the production changes. Existing settings sync tests in app/src/settings/cloud_preferences_syncer_tests.rs continue to work unchanged and do not need to be migrated; new settings sync tests should use this infrastructure.

FakeObjectClient (new, in app/src/server/cloud_objects/fake_object_client.rs): a stateful impl ObjectClient backed by an Arc<Mutex<FakeCloudState>>. Replaces per-method scripted MockObjectClient expectations for tests that want to assert on round-trip behavior rather than on which methods got called. Key properties:

  • Pass-through client_id semantics so tests no longer need to pre-allocate ids via TestClientIdProvider.
  • bulk_create_generic_string_objects and update_generic_string_object write into the store; fetch_changed_objects returns whatever is currently in the store. A test can write a setting locally, trigger a refresh, and observe what the cloud now holds.
  • Helper methods seed_preference(storage_key, value_json, platform) and cloud_value(storage_key, platform) use the same CloudPreferenceModel serialization the real syncer uses, so format drift is caught at compile time rather than via hand-rolled JSON literals. snapshot_as_initial_load_response() builds an InitialLoadResponse from the current fake state for use with UpdateManager::mock_initial_load.
  • Only the ObjectClient methods the syncer actually calls need real implementations; the rest panic with unimplemented!() until a future test needs them.

initialize_cloud_preferences_syncer seam (Section 2): the single function that both production and tests call to construct the syncer with the hash check applied. Without this seam, end-to-end tests would have to either call all of init() (too many side effects) or hard-code force_local_wins_on_startup, neither of which actually exercises the hash comparison logic.

End-to-end tests build on top of these two pieces by:

  1. Creating a tempfile::TempDir and writing a real settings.toml into it.
  2. Registering a TomlBackedUserPreferences pointing at that temp file as the public preferences singleton, replacing the #[cfg(test)] InMemoryPreferences shortcut in init_public_user_preferences.
  3. Seeding SETTINGS_FILE_LAST_SYNCED_HASH_KEY in the test's InMemoryPreferences-backed private preferences to simulate the previously-synced state.
  4. Wiring a FakeObjectClient through create_update_manager_struct and seeding any cloud-side starting state via seed_setting.
  5. Calling initialize_cloud_preferences_syncer(&toml_path, parse_error, ctx) and asserting on cloud.cloud_value::<S>(...), the on-disk file contents, and the new stored hash.

Unit tests

  • file_content_hash returns None for missing files.
  • file_content_hash returns None for empty / whitespace-only files.
  • file_content_hash returns identical hashes for identical content and different hashes for different content.

End-to-end tests

Each of these is a variation on the scaffolding above (different file contents, stored hash, parse error, and seeded cloud values):

  • Startup with current file hash matching stored hash → cloud wins; seeded cloud value is preserved in local state.
  • Startup with file hash differing from stored hash (file parsed OK) → local wins; the seeded cloud value is overwritten with the local value, the on-disk file is unchanged, and the stored hash now matches the current file hash.
  • Startup with file hash differing but file broken (startup_toml_parse_error = Some(...)) → cloud wins (broken-file guard); seeded cloud value is preserved.
  • Startup with file missing and stored hash present → cloud wins; seeded cloud value is preserved (no wipe of cloud).
  • Startup with file empty and stored hash present → cloud wins; seeded cloud value is preserved (no wipe of cloud).
  • First launch with no stored hash → cloud wins.
  • After a UI-initiated setting change and the resulting upload, the stored hash is updated to match the current file hash (covers Section 4's SyncQueue event subscription).
  • The first-load override is one-shot: a second sync() after has_completed_initial_load = true does not force local-wins again.
  • Offline UI change regression test: Setting is changed while the SyncQueue is stopped (simulating offline). The stored hash must NOT update while the upload is pending. After restarting the queue (simulating coming back online) and draining sync queue futures, the stored hash updates to match the new file.

Follow-ups

  • Per-key conflict detection for more granular merge behavior (deferred as non-goal).
  • User-facing prompt for conflict resolution (deferred as non-goal).