Skip to content

Harden build manifest persistence#61

Open
samdark wants to merge 1 commit into
masterfrom
fix-build-manifest-atomic-save
Open

Harden build manifest persistence#61
samdark wants to merge 1 commit into
masterfrom
fix-build-manifest-atomic-save

Conversation

@samdark

@samdark samdark commented Jun 13, 2026

Copy link
Copy Markdown
Member

Summary

  • treat corrupt build manifests as cache misses instead of crashing future builds
  • save manifests through a same-directory temporary file and rename into place
  • throw when manifest writes or replacement fail

Tests

  • make test -- --filter BuildManifestTest
  • make psalm
  • make test

Summary by CodeRabbit

  • Bug Fixes

    • Manifest files now gracefully handle corruption by resetting to an empty cache state rather than failing.
    • Improved reliability of manifest saving with atomic file operations to prevent data loss.
  • Tests

    • Added test coverage for corrupt manifest recovery behavior.
    • Added test coverage for temporary file cleanup during save operations.

Copilot AI review requested due to automatic review settings June 13, 2026 08:41
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

BuildManifest strengthens error resilience and write safety. The manifest loader now handles JSON parse failures and missing files by resetting internal state, while the save method writes atomically via a temporary file and rename operation to prevent partial or corrupted writes.

Changes

BuildManifest robustness and atomic writes

Layer / File(s) Summary
Load robustness and JsonException handling
src/Build/BuildManifest.php
load() now imports JsonException and wraps json_decode(..., JSON_THROW_ON_ERROR) in a try/catch, resetting the manifest state and returning early when file_get_contents fails, JSON parsing throws, or decoded data is not an array.
Atomic save and reset mechanism
src/Build/BuildManifest.php
save() encodes the manifest to a temporary file and atomically replaces the target via rename(), with cleanup on rename failure. A new private reset() method clears entries, configFiles, and trackedDirectories, and is called by load() on all error paths.
Tests for load resilience and atomic save
tests/Unit/Build/BuildManifestTest.php
testCorruptManifestLoadsAsEmptyCache() verifies that invalid JSON is handled gracefully, leaving the cache empty and marked as changed. testSaveDoesNotLeaveTemporaryManifestFile() confirms that save() succeeds and cleans up its temporary file.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐰 A cache that stumbles, now stands back up,
With atomic writes in a .tmp cup—
No corrupt JSON can break the flow,
And temp files vanish like morning snow!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Harden build manifest persistence' accurately captures the main objectives of the changeset: improving robustness of manifest handling through better error handling, atomic writes, and recovery from corruption.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix-build-manifest-atomic-save
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch fix-build-manifest-atomic-save

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 and usage tips.

Copilot AI 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.

Pull request overview

This PR hardens build manifest persistence so builds don’t crash when the manifest is unreadable/corrupt, and so manifest writes are performed via a temp file + rename to reduce the risk of partial writes.

Changes:

  • Treat unreadable/corrupt manifest JSON as a cache miss (reset state instead of throwing).
  • Write manifests to a same-directory temporary file and rename into place; throw on write/replace failures.
  • Add unit tests covering corrupt manifest load behavior and ensuring no .tmp file remains after save.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/Build/BuildManifest.php Makes manifest loading tolerant to corrupt JSON and implements atomic-ish save via temp file + rename with explicit failure exceptions.
tests/Unit/Build/BuildManifestTest.php Adds regression tests for corrupt-manifest handling and temp-file cleanup behavior on save.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 35 to +39
$json = file_get_contents($this->manifestPath);
if ($json === false) {
$this->entries = [];
$this->reset();
return;
}

@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: 2

Caution

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

⚠️ Outside diff range comments (1)
src/Build/BuildManifest.php (1)

30-33: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reset full state when manifest file is absent.

On Line 30-33, only $this->entries is cleared. If the same BuildManifest instance is reused, stale configFiles/trackedDirectories can leak across loads. Use reset() here for a true cache miss state.

Proposed fix
     public function load(): void
     {
         if (!is_file($this->manifestPath)) {
-            $this->entries = [];
+            $this->reset();
             return;
         }
🤖 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/Build/BuildManifest.php` around lines 30 - 33, In the BuildManifest
class, when the manifest file is absent (the if (!is_file($this->manifestPath))
branch) replace the partial reset that only sets $this->entries = [] with a full
state reset by calling $this->reset() so that configFiles and trackedDirectories
are also cleared and no stale state leaks across loads.
🤖 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/Build/BuildManifest.php`:
- Around line 78-87: The save() logic currently writes to a deterministic ".tmp"
file and treats any non-false return from file_put_contents as success; change
it to create a unique temp file per call (use
tempnam(dirname($this->manifestPath), basename($this->manifestPath) . '.tmp') or
similar) and write the JSON with exclusive lock, verifying the number of bytes
written equals strlen($json) (or perform a looped fwrite to guarantee full
write); on any write shortfall or error unlink the unique temp file and throw,
and only after a successful full-byte write perform the rename($tmp,
$this->manifestPath) and on rename failure unlink the temp and throw. Ensure you
reference the same $this->manifestPath for temp directory creation and cleanup,
and update the temporaryPath variable usage accordingly.

In `@tests/Unit/Build/BuildManifestTest.php`:
- Around line 135-164: Add PHPUnit tests that cover the remaining branches in
BuildManifest::load() and BuildManifest::save(): add a test (e.g.,
testLoadResetsWhenDecodedJsonIsNotArray) that writes a manifest file whose JSON
decodes to a non-array (for example "null" or a JSON string), calls
BuildManifest::load(), and asserts the manifest was reset (sourceFiles(),
configFiles() empty and isChanged() true for a new source); and add two tests
for save failure branches (e.g., testSaveHandlesWriteFailure and
testSaveHandlesRenameFailure) that simulate file write and rename failures (use
vfsStream or create a non-writable directory to force file_put_contents/rename
to fail), call BuildManifest::save() and assert the code follows the expected
failure behavior (the test should expect the specific exception or error
handling your implementation uses and verify no persistent .tmp file remains or
that the temporary file handling matches the branch behavior).

---

Outside diff comments:
In `@src/Build/BuildManifest.php`:
- Around line 30-33: In the BuildManifest class, when the manifest file is
absent (the if (!is_file($this->manifestPath)) branch) replace the partial reset
that only sets $this->entries = [] with a full state reset by calling
$this->reset() so that configFiles and trackedDirectories are also cleared and
no stale state leaks across loads.
🪄 Autofix (Beta)

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: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1cabc00-6f1b-432d-a793-07a9a4f76b90

📥 Commits

Reviewing files that changed from the base of the PR and between 315ad8d and 3e1c60d.

📒 Files selected for processing (2)
  • src/Build/BuildManifest.php
  • tests/Unit/Build/BuildManifestTest.php

Comment on lines +78 to +87
$temporaryPath = $this->manifestPath . '.tmp';
if (file_put_contents($temporaryPath, $json) === false) {
throw new RuntimeException(sprintf('Unable to write build manifest temporary file "%s".', $temporaryPath));
}

if (!rename($temporaryPath, $this->manifestPath)) {
if (is_file($temporaryPath)) {
unlink($temporaryPath);
}
throw new RuntimeException(sprintf('Unable to replace build manifest "%s".', $this->manifestPath));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid shared .tmp path collisions and detect short writes.

Line 78 uses a deterministic temp filename. Concurrent save() calls targeting the same manifest can race on the same temp file and cause failed/incorrect persistence. Also Line 79 treats short writes as success. Use a unique temp file per call and validate full-byte write.

Proposed fix
-        $temporaryPath = $this->manifestPath . '.tmp';
-        if (file_put_contents($temporaryPath, $json) === false) {
+        $temporaryPath = tempnam($dir, basename($this->manifestPath) . '.tmp.');
+        if ($temporaryPath === false) {
+            throw new RuntimeException(sprintf('Unable to create build manifest temporary file in "%s".', $dir));
+        }
+        $written = file_put_contents($temporaryPath, $json);
+        if ($written !== strlen($json)) {
+            if (is_file($temporaryPath)) {
+                unlink($temporaryPath);
+            }
             throw new RuntimeException(sprintf('Unable to write build manifest temporary file "%s".', $temporaryPath));
         }
🤖 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/Build/BuildManifest.php` around lines 78 - 87, The save() logic currently
writes to a deterministic ".tmp" file and treats any non-false return from
file_put_contents as success; change it to create a unique temp file per call
(use tempnam(dirname($this->manifestPath), basename($this->manifestPath) .
'.tmp') or similar) and write the JSON with exclusive lock, verifying the number
of bytes written equals strlen($json) (or perform a looped fwrite to guarantee
full write); on any write shortfall or error unlink the unique temp file and
throw, and only after a successful full-byte write perform the rename($tmp,
$this->manifestPath) and on rename failure unlink the temp and throw. Ensure you
reference the same $this->manifestPath for temp directory creation and cleanup,
and update the temporaryPath variable usage accordingly.

Comment on lines +135 to +164
public function testCorruptManifestLoadsAsEmptyCache(): void
{
$manifestPath = $this->tempDir . '/manifest.json';
file_put_contents($manifestPath, '{"entries":');

$manifest = new BuildManifest($manifestPath);
$manifest->load();

$sourceFile = $this->tempDir . '/entry.md';
file_put_contents($sourceFile, '# Hello');

assertTrue($manifest->isChanged($sourceFile));
assertSame([], $manifest->sourceFiles());
assertSame([], $manifest->configFiles());
assertFalse($manifest->hasTrackedDirectories());
}

public function testSaveDoesNotLeaveTemporaryManifestFile(): void
{
$sourceFile = $this->tempDir . '/entry.md';
file_put_contents($sourceFile, '# Hello');
$manifestPath = $this->tempDir . '/manifest.json';

$manifest = new BuildManifest($manifestPath);
$manifest->record($sourceFile, ['/out/entry/index.html']);
$manifest->save();

assertTrue(is_file($manifestPath));
assertFalse(is_file($manifestPath . '.tmp'));
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Add tests for the remaining new error branches in load()/save().

The new tests are good, but they don’t cover all newly introduced branches (e.g., decoded non-array reset path and save() failure paths for write/rename). Please add targeted PHPUnit cases for those branches so persistence hardening is guarded against regressions.

As per coding guidelines, **/*Test.php: “For each piece of code add a test using phpunit.”

🤖 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 `@tests/Unit/Build/BuildManifestTest.php` around lines 135 - 164, Add PHPUnit
tests that cover the remaining branches in BuildManifest::load() and
BuildManifest::save(): add a test (e.g.,
testLoadResetsWhenDecodedJsonIsNotArray) that writes a manifest file whose JSON
decodes to a non-array (for example "null" or a JSON string), calls
BuildManifest::load(), and asserts the manifest was reset (sourceFiles(),
configFiles() empty and isChanged() true for a new source); and add two tests
for save failure branches (e.g., testSaveHandlesWriteFailure and
testSaveHandlesRenameFailure) that simulate file write and rename failures (use
vfsStream or create a non-writable directory to force file_put_contents/rename
to fail), call BuildManifest::save() and assert the code follows the expected
failure behavior (the test should expect the specific exception or error
handling your implementation uses and verify no persistent .tmp file remains or
that the temporary file handling matches the branch behavior).

Source: Coding guidelines

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