Skip to content

Conversation

@reduckted
Copy link
Contributor

@reduckted reduckted commented Nov 7, 2025

Closes #23131

What I did

When an EBUSY error occurs in FileSystemCache.set, wait for 100ms and try again. Try up to five times (including the original attempt). If the EBUSY error still occurs, re-throw it.

Checklist for Contributors

Testing

The changes in this PR are covered in the following automated tests:

  • stories
  • unit tests
  • integration tests
  • end-to-end tests

Manual testing

This section is mandatory for all contributions. If you believe no manual test is necessary, please state so explicitly. Thanks!

This is very difficult for anyone to verify that it solves the original problem from #23131, because that error only seems to happen in certain environments.

If you can repeatedly reproduce the EBUSY errors, then manual testing is quite easy:

Without this change:

  1. Run Storybook and verify that an EBUSY error occurs.
  2. Repeat step 1 a lot to verify that the EBUSY error continues to occur.

With this change:

  1. Run Storybook and verify that no errors occur.
  2. Repeat step 1 a lot to verify that no errors occur.

I also manually tested this change with some logging:

  1. I added a console.log statement when an EBUSY error occurred in the set function.
  2. I added a console.log statement when the cache file is successfully written to after an EBUSY error occurred.

Every time the EBUSY error occurred, the first message was logged. The very next attempt to write (after waiting 100ms) would succeed and the second message would be logged.

Documentation

  • Add or update documentation reflecting your changes
  • If you are deprecating/removing a feature, make sure to update
    MIGRATION.MD

Checklist for Maintainers

  • When this PR is ready for testing, make sure to add ci:normal, ci:merged or ci:daily GH label to it to run a specific set of sandboxes. The particular set of sandboxes can be found in code/lib/cli-storybook/src/sandbox-templates.ts

  • Make sure this PR contains one of the labels below:

    Available labels
    • bug: Internal changes that fixes incorrect behavior.
    • maintenance: User-facing maintenance tasks.
    • dependencies: Upgrading (sometimes downgrading) dependencies.
    • build: Internal-facing build tooling & test updates. Will not show up in release changelog.
    • cleanup: Minor cleanup style change. Will not show up in release changelog.
    • documentation: Documentation only changes. Will not show up in release changelog.
    • feature request: Introducing a new feature.
    • BREAKING CHANGE: Changes that break compatibility in some way with current major version.
    • other: Changes that don't fit in the above categories.

🦋 Canary release

This PR does not have a canary release associated. You can request a canary release of this pull request by mentioning the @storybookjs/core team here.

core team members can create a canary release here or locally with gh workflow run --repo storybookjs/storybook publish.yml --field pr=<PR_NUMBER>

Summary by CodeRabbit

  • Improvements

    • File cache writes are now asynchronous and more resilient to transient file-system busy errors via automatic retry, reducing failed saves and improving responsiveness and stability.
  • Tests

    • Added comprehensive tests covering retry scenarios, timing behavior, and error handling to ensure reliable write operations across success and failure paths.

@reduckted
Copy link
Contributor Author

I've marked this as a draft PR because I'm having problems with the tests. All five new tests pass, but I end up with an uncaught error at the end of the last test. I can't work out how that's happening, because the last test verifies that the promise is rejected. 😕

Hopefully someone can point me in the right direction.

 ✓  storybook  src/common/utils/file-cache.test.ts (5 tests) 15ms
   ✓ FileSystemCache > set > can set values 5ms
   ✓ FileSystemCache > set > does not retry for errors without a code 1ms
   ✓ FileSystemCache > set > does not retry for non-EBUSY errors 1ms
   ✓ FileSystemCache > set > retries for EBUSY errors 2ms
   ✓ FileSystemCache > set > throws when EBUSY errors constantly occur 5ms
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Unhandled Errors ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Vitest caught 1 unhandled error during the test run.
This might cause false positive tests. Resolve unhandled errors to make sure your tests are 
not affected.

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Unhandled Rejection ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Error: locked
 ❯ core/src/common/utils/file-cache.test.ts:85:35
 ❯ node_modules/vitest/node_modules/@vitest/runner/dist/chunk-hooks.js:155:11
 ❯ node_modules/vitest/node_modules/@vitest/runner/dist/chunk-hooks.js:752:26
 ❯ node_modules/vitest/node_modules/@vitest/runner/dist/chunk-hooks.js:1897:20
 ❯ runWithTimeout node_modules/vitest/node_modules/@vitest/runner/dist/chunk-hooks.js:1863:10
 ❯ runTest node_modules/vitest/node_modules/@vitest/runner/dist/chunk-hooks.js:1574:12      
 ❯ runSuite node_modules/vitest/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8      
 ❯ runSuite node_modules/vitest/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8      
 ❯ runSuite node_modules/vitest/node_modules/@vitest/runner/dist/chunk-hooks.js:1729:8      

⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
Serialized Error: { code: 'EBUSY' }
This error originated in "core/src/common/utils/file-cache.test.ts" test file. It doesn't mean the error was thrown inside the file itself, but while it was running.
The latest test that might've caused the error is "throws when EBUSY errors constantly occur". It might mean one of the following:
- The error was thrown, while Vitest was running this test.
- If the error occurred after the test had been completed, this was the last documented test before it was thrown.
⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯

 Test Files  1 passed (1)
      Tests  5 passed (5)
Type Errors  no errors
     Errors  1 error

Comment on lines 95 to 114
for (let attempt = 1; attempt <= SAFE_WRITE_ATTEMPTS; attempt++) {
try {
await writeFile(fileName, fileData, fileOptions);
return;
} catch (err) {
// If an EBUSY error occurs on any attempt except
// the last, then wait for a bit and try again.
// https://github.com/storybookjs/storybook/issues/23131
if (
attempt < SAFE_WRITE_ATTEMPTS &&
err instanceof Error &&
'code' in err &&
err.code === 'EBUSY'
) {
await new Promise((resolve) => setTimeout(resolve, 100));
} else {
throw err;
}
}
}
Copy link
Member

Choose a reason for hiding this comment

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

This retry a write mechanism, seems something that belongs in a common helper function, not implemented in the file-cache mechanism directly.

Do you agree?

Then we can make the unit test purely test the retry-mechanism.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That sounds OK. Did you have a preference for where to create the helper function? Is there an existing file that contains similar helper functions that it could be added to?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I've moved it to code/core/src/common/utils/write-file-with-retry.ts, but feel free to move it elsewhere if there's somewhere better. 😄

@nx-cloud
Copy link

nx-cloud bot commented Nov 12, 2025

View your CI Pipeline Execution ↗ for commit 283917b

Command Status Duration Result
nx run-many -t build --parallel=3 ✅ Succeeded 53s View ↗

☁️ Nx Cloud last updated this comment at 2025-11-24 09:41:13 UTC

@reduckted reduckted marked this pull request as ready for review November 12, 2025 11:40
@coderabbitai

This comment was marked as spam.

coderabbitai[bot]

This comment was marked as spam.

@storybook-app-bot
Copy link

storybook-app-bot bot commented Nov 12, 2025

Package Benchmarks

Commit: 283917b, ran on 24 November 2025 at 09:32:54 UTC

No significant changes detected, all good. 👏

@ndelangen ndelangen changed the title Retry when EBUSY error occurs while writing to the cache Core: Retry writeFile cache when EBUSY error occurs Nov 12, 2025
@reduckted
Copy link
Contributor Author

All five new tests pass, but I end up with an uncaught error at the end of the last test. I can't work out how that's happening, because the last test verifies that the promise is rejected.

This appears to be a bug in vitest:
vitest-dev/vitest#9024

@ndelangen
Copy link
Member

I think there's a bug in the unit test @reduckted

@reduckted reduckted requested a review from ndelangen November 15, 2025 01:38
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 0

♻️ Duplicate comments (1)
code/core/src/common/utils/write-file-with-retry.test.ts (1)

7-14: Critical: Apply the previously suggested fix for mock state persistence.

The past review comment correctly identified the root cause of the unhandled rejection you're seeing after test 5. The fix has not yet been applied:

  1. Line 7 is missing { spy: true }
  2. Line 12-14 beforeEach is missing mock cleanup

As noted in the previous review, test 5 (line 84) uses mockRejectedValue (not ...Once), which makes the mock reject on ALL subsequent calls. Without proper mock cleanup, this state persists and causes the unhandled rejection.

Apply the previously suggested fix:

-vi.mock('node:fs/promises');
+vi.mock('node:fs/promises', { spy: true });
 beforeEach(async () => {
   vi.useFakeTimers();
+  vi.clearAllMocks();
 });
🧹 Nitpick comments (1)
code/core/src/common/utils/write-file-with-retry.test.ts (1)

82-104: Consider using multiple mockRejectedValueOnce calls for safer test isolation.

While the mock cleanup fix will resolve the unhandled rejection, this test could be made more robust by using five mockRejectedValueOnce calls instead of mockRejectedValue:

-    vi.mocked(fs.writeFile).mockRejectedValue(error);
+    vi.mocked(fs.writeFile)
+      .mockRejectedValueOnce(error)
+      .mockRejectedValueOnce(error)
+      .mockRejectedValueOnce(error)
+      .mockRejectedValueOnce(error)
+      .mockRejectedValueOnce(error);

This approach mirrors the pattern in the test at lines 50-55 and ensures the mock behavior is scoped to exactly this test's 5 attempts, making the test more self-contained and less reliant on cleanup.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 19c731b and dad75f3.

📒 Files selected for processing (1)
  • code/core/src/common/utils/write-file-with-retry.test.ts (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
code/core/src/common/utils/write-file-with-retry.test.ts (1)
code/core/src/common/utils/write-file-with-retry.ts (1)
  • writeFileWithRetry (7-25)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: Core Unit Tests, windows-latest
  • GitHub Check: normal
🔇 Additional comments (1)
code/core/src/common/utils/write-file-with-retry.test.ts (1)

20-104: Test coverage and logic are solid.

The test suite comprehensively covers all code paths:

  • Success on first attempt
  • Non-retryable errors (missing code, non-EBUSY)
  • EBUSY with eventual success after retries
  • EBUSY persisting through all attempts

The fake timer assertions correctly validate the 100ms delay and 5-attempt limit. Once the mock cleanup issue is resolved, this test suite will properly validate the retry behavior without unhandled rejections.

@reduckted
Copy link
Contributor Author

Any chance of this making it into 10.1? It looks like the test failures are unrelated and happening in other PRs too.

@ndelangen
Copy link
Member

@reduckted QA for 10.1 is going on right now.

I'll ping @yannbf and @valentinpalkovic here, to decide if it's something we want to add to the release.

@reduckted
Copy link
Contributor Author

Thank you!

@reduckted
Copy link
Contributor Author

I see the alpha versions of 10.2 have started being released. Any chance of this making it into 10.2?

@ndelangen ndelangen merged commit 9e7805b into storybookjs:next Dec 5, 2025
57 of 58 checks passed
@reduckted reduckted deleted the feature/file-system-cache-set-retry branch December 5, 2025 10:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: EBUSY: Resource busy or locked

2 participants