Skip to content

fix(merge): write-temp-then-rename so a failed merge never destroys prior output - #15

Merged
mledour merged 2 commits into
mainfrom
fix/merge-atomic-rename
Jun 8, 2026
Merged

mledour merged 2 commits into
mainfrom
fix/merge-atomic-rename

Conversation

@mledour

@mledour mledour commented Jun 8, 2026

Copy link
Copy Markdown
Owner

Finding #3 (code review): removeStale deletes a valid merged CSV on a failed later session

⚠️ Stacked on #2 (#14). Merge #14 first — then this PR's diff reduces to just the MergeIntoOutput change and fast-forwards cleanly. Until then the diff below also shows #2's commit.

MergeIntoOutput deleted frames-merged-<pid>.csv via removeStale() on every error path once g_frameCounter > 0, and opened the destination in truncate mode before writing. A process runs many start/stop sessions (the hotkey toggles repeatedly), so a later session that recorded frames but then failed transiently — a per-side CSV that never flushed (disk full, lost ACL), zero matched pairs, a failed open, or ENOSPC partway through the write — destroyed a still-valid merged CSV from an earlier session and left nothing in its place. The "current session has just superseded it" assumption only held when the current session actually succeeded.

Fix

Write the merge to a sibling <out>.tmp and atomically rename it over the destination only after the write fully succeeds:

  • The previous merged CSV survives every failure path (it's replaced only by a complete, valid new file).
  • Closes the open-in-truncate window where a disk-full mid-write corrupted the destination before the write could finish (the trunc open destroyed the old file first).
  • Drops removeStale() entirely (all 6 call sites + the lambda).
  • A stale-but-valid file from an earlier session is preserved on failure — consistent with the existing zero-frame guard, and the logs say the merge was skipped.

Verification

  • MergeIntoOutput is OS-facing glue (paths + ofstream); like the rest of it, it's exercised via the build + the pure-function tests in test_merge.cpp rather than a unit test of its own (matching the file's existing policy).
  • Build + tests validated by CI.
  • No removeStale references remain; no lines >120 cols.

🤖 Generated with Claude Code

michael-ledour and others added 2 commits June 6, 2026 15:29
The merge paired pre<->post rows on (frame_idx, thread_id), where
frame_idx was each DLL's independent g_frameCounter. The two counters
stay in lockstep only under pre-upstream layer ordering and a symmetric
start -- both of which the README documents as the user's manual job
("Layer ordering is on you"; a 4th layer can slip between pre and post).
Any asymmetry (reversed ordering, a one-sided Start() throw, a one-sided
shared-memory read) shifted every subsequent pair by one, so target_us
was computed from two DIFFERENT host frames and the merge silently
emitted plausible-but-wrong numbers for the whole session.

Key on the frame's intrinsic identity instead: frameEndInfo->displayTime
(an XrTime) is the same value on both halves for a given host frame and
flows through the chain unchanged, so ordering and start-alignment stop
mattering -- a frame seen by only one side simply fails to join (one
dropped frame) instead of cascading. Renames the frame_idx CSV column to
display_time across the per-side, GPU, and merged CSVs, merge.{h,cpp},
the GPU timer surface, analyze.py, and both test suites. g_frameCounter
is retained solely to feed MergeIntoOutput's zero-frame guard.

displayTime is stored as uint64_t (XrTime is non-negative in practice),
so the merge keys and the GPU ring need no signed/unsigned churn. Adds a
frameEndInfo null guard on both sides since the record path now
dereferences it. A target layer that itself rewrites displayTime now
yields an empty merge (a loud, logged failure) instead of wrong numbers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rior output

MergeIntoOutput deleted frames-merged-<pid>.csv via removeStale() on every
error path once g_frameCounter>0, and opened the destination in truncate
mode before writing. A process runs many start/stop sessions (the hotkey
toggles repeatedly), so a later session that recorded frames but then
failed transiently -- a per-side CSV that never flushed (disk full, lost
ACL), zero matched pairs, a failed open, or ENOSPC partway through the
write -- destroyed a still-valid merged CSV from an earlier session and
left nothing in its place. The "current session has just superseded it"
assumption only held when the current session actually succeeded.

Write the merge to a sibling <out>.tmp and atomically rename it over the
destination only after the write fully succeeds. The previous merged CSV
now survives every failure path (replaced only by a complete, valid new
file), which also closes the open-in-truncate window where a disk-full
mid-write corrupted the destination before the write could finish. Drops
removeStale() entirely. A stale-but-valid file from an earlier session is
preserved on failure -- consistent with the existing zero-frame guard, and
the logs say the merge was skipped.

MergeIntoOutput is OS-facing glue (paths + ofstream); like the rest of it,
it is exercised through the build and the pure-function tests in
test_merge.cpp rather than a unit test of its own.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mledour
mledour merged commit dc9c93b into main Jun 8, 2026
3 checks passed
mledour added a commit that referenced this pull request Jun 8, 2026
refactor: dedupe stats reduction (#15) + name the data-loss guard (#14)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants