Skip to content

Build: harden metadata storage (guarded load, atomic save) #56

Closed

Description

@blooop

Question

Build: harden MetadataStorage (devlaunch/worktree/storage.py) so a corrupt or concurrently-written metadata.json can't crash the CLI or lose state.

  • _load() (storage.py:30-47) has no guard around json.load — a corrupt/truncated file raises during construction and takes down every dl command. Guard it (recover or quarantine + start fresh), matching the pattern read_completion_cache already uses (dl.py:72-76).
  • save() (storage.py:56-57) is a plain open+write — no temp-file+rename. An interrupted write corrupts state, and the background --update-cache process runs concurrently with the foreground one. Make it atomic like the completion-cache writes (dl.py:84-89).
  • add_worktree (storage.py:81-92) saves twice (once via add_repository, once itself) — collapse to one.

Whether metadata.json also gains a schema-version field is fog on the map — surface it in the resolution if this build makes it specifiable.

Activity

  1. blooop commented on Aug 7, 2026

    @blooop
    OwnerAuthor

    Explicitly deferred to the Rust rewrite effort by #53 (go). The map's destination admits "landed as merged fix PRs or explicitly deferred" — this is the second outcome, not an abandonment.

    Why deferred rather than built: this ticket's value is the design — guard the load, make the save atomic, collapse the double-save — and that design carries to devlaunch-core intact. Most of it also dissolves in Rust: serde-derive replaces the hand-rolled to_dict/from_dict, a Result at the parse boundary makes the unguarded json.load (storage.py:30-47) unrepresentable rather than merely guarded, and atomic save is std::fs::rename. Building it in Python first means implementing it twice.

    Carried forward as a requirement on devlaunch-core, along with the metadata.json schema-version question that was fog on the map — the format is shared between the Python and Rust implementations across the parallel-migration period, so versioning is now a core-design question rather than a Python build.

  2. blooop commented on Aug 7, 2026

    @blooop
    OwnerAuthor

    Deferral reversed — lands in Python, in the final Python release. Same correction as #60.

    One caveat that survives: the guarded-load and atomic-save work genuinely does get simpler in Rust (serde at the parse boundary, std::fs::rename), so this is the one reopened ticket where some rework is real. It lands anyway because a corrupt metadata.json taking down every dl command (storage.py:30-47) is a live crash for current users, and the final Python release is the last chance to fix it for them.

    The metadata.json schema-version question stays fog — but it is now sharper than before: the format is shared across the parallel-migration period, read by both the Python and Rust implementations, so whatever this build decides about versioning binds the core too. Surface it in the resolution.

  3. reopened this on Aug 7, 2026
  4. self-assigned this
    on Aug 7, 2026
  5. blooop commented on Aug 7, 2026

    @blooop
    OwnerAuthor

    Answer

    Landed in #67. All three items done, plus eight holes adversarial review found in the first cut — each now pinned by a test verified red-before-green.

    The three named: _load is total (unreadable or non-object file quarantined to metadata.json.corrupt, a single malformed entry skipped rather than costing the file); save() is a fsynced mkstemp + rename that does not swallow write failures; add_worktree and remove_worktree each write once instead of twice.

    What review changed

    Two were outright bugs in the fix itself. Catching json.JSONDecodeError instead of ValueError let UnicodeDecodeError from non-UTF-8 bytes escape the guard — the exact crash this ticket exists to prevent. And temp_path.replace() was a regression vs HEAD: it replaced a symlinked metadata.json instead of writing through it, silently breaking the link and every later write. Also fixed: a widened file mode (0600 -> 0664), a version: 1.0 header quarantining a perfectly loadable file, and a pid-based temp name that two instances in one process shared.

    The structural one: file-level damage was quarantined and recoverable, but a skipped entry was deleted permanently by the next write, with no backup. There is now one rule — any load that drops information copies the original to metadata.json.bak at load time, before a write can happen, and names the path on stderr.

    Schema version — the fog is resolved, and implemented

    SCHEMA_VERSION = 1 is written as a top-level key; absent means legacy, read as 1; a non-integral header never costs the entries, which do not depend on it. schema_version is exposed so #65 gets a deterministic, idempotent migration trigger instead of sniffing directory names — which #55 noted had a known false positive (a branch literally named foo-bexoza).

    One thing pulled forward deliberately

    from_dict did cls(**data), so a field added by a newer build raised TypeError. Every entry has the same shape, so one new field made every entry unreadable and the old build would rewrite the file with an empty worktree list. That is a live risk precisely because #62 makes this the last Python release — this is the build left behind while the schema moves on. from_dict now ignores fields it does not declare, warns naming them, and preserves the original.

    Correcting the ticket's premise

    The ticket said "the background --update-cache process runs concurrently with the foreground one". It does, but it is not a writer of this file: update_completion_cache (dl.py:170) only reads devpod and git state and writes the completion caches. The comment in save() claiming concurrency safety was removed rather than reworded.

    Fog this leaves

    save() serializes the whole document, so two concurrent foreground dl processes can still lose an update — atomic rename prevents corruption, not read-modify-write races. Needs a lock or a re-read-before-write; out of this slice.

    Also noted: write_completion_cache (dl.py:84) still uses a shared temp path and swallows OSError, the same two flaws fixed here. Not touched, since it is a genuinely disposable cache.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions