Skip to content

Fix node renderer upload race condition with per-request directories#2456

Merged
AbanoubGhadban merged 8 commits intomasterfrom
2449-fix-node-renderer-upload-race-condition
Feb 21, 2026
Merged

Fix node renderer upload race condition with per-request directories#2456
AbanoubGhadban merged 8 commits intomasterfrom
2449-fix-node-renderer-upload-race-condition

Conversation

@AbanoubGhadban
Copy link
Copy Markdown
Collaborator

@AbanoubGhadban AbanoubGhadban commented Feb 20, 2026

Summary

  • Root cause: All concurrent multipart uploads wrote to a single shared path (<serverBundleCachePath>/uploads/<filename>), causing overwrites, ENOENT errors, and cross-contamination between requests. This was confirmed by a production postmortem showing SyntaxError: Bad control character in string literal in JSON during pod rollovers.
  • Fix: Each request now gets its own upload directory (uploads/<randomUUID>/) assigned in an onRequest hook. The onFile handler writes to the per-request directory. An onResponse hook cleans up the directory after the response is sent.
  • Tests: 3 new deterministic race condition tests using a preHandler barrier technique that forces concurrent requests' onFile phases to complete before any route handler runs. Tests cover /upload-assets (single asset, multiple assets) and /bundles/:ts/render/:digest endpoints.

Closes #2449

Changes

packages/react-on-rails-pro-node-renderer/src/worker.ts

  • Added declare module 'fastify' augmentation for uploadDir on FastifyRequest
  • Added decorateRequest('uploadDir', '') + onRequest hook assigning uploads/<randomUUID>/
  • Added onResponse hook to clean up per-request upload directory
  • Changed onFile from arrow function to method function so this (the Fastify request) provides the per-request uploadDir

packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts (new)

  • Concurrent /upload-assets — single asset, different content, different target bundles
  • Concurrent /upload-assets — multiple assets, different content, different target bundles
  • Concurrent render requests — different bundle timestamps, same-named assets with different content

Test plan

  • All 3 new race condition tests pass (previously failed deterministically)
  • All 17 existing worker.test.ts tests pass (no regressions)
  • ESLint passes

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Per-request isolated upload directories to prevent cross-request asset contamination; automatic periodic cleanup of stale temporary upload folders.
    • Uploaded assets are no longer removed immediately after processing, avoiding accidental loss.
  • Tests

    • Added comprehensive tests validating concurrent upload and render request isolation across endpoints and bundle directories.
  • Documentation

    • Recorded the fix in the changelog under Unreleased.

…2449)

Concurrent requests uploading same-named files (e.g. loadable-stats.json)
all wrote to a single shared path (uploads/<filename>), causing overwrites,
ENOENT errors, and cross-contamination between requests. Each request now
gets its own upload directory (uploads/<uuid>/), and the directory is cleaned
up in an onResponse hook after the response is sent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Comment thread packages/react-on-rails-pro-node-renderer/src/worker.ts Fixed
@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented Feb 20, 2026

Greptile Summary

Fixes a critical race condition where concurrent multipart uploads overwrote each other's files in a shared uploads/ directory, causing ENOENT errors and cross-contamination during production pod rollovers.

Core changes:

  • Each request now gets a unique upload directory (uploads/<randomUUID>/) assigned in an onRequest hook
  • The onFile handler was changed from an arrow function to a regular function so this (the Fastify request) provides access to req.uploadDir
  • An onResponse hook cleans up per-request directories after responses complete

Test coverage:

  • Three deterministic race condition tests using a preHandler barrier technique that forces concurrent requests' file uploads to complete before any route handler executes
  • Tests cover both /upload-assets (single/multiple assets) and /bundles/:ts/render/:digest endpoints
  • All tests verify correct content delivery without cross-contamination

The fix is clean, well-tested, and directly addresses the root cause identified in the production postmortem.

Confidence Score: 5/5

  • This PR is safe to merge with high confidence
  • The fix directly addresses a critical production bug with a clean solution. The per-request upload directory approach eliminates the race condition at its root. Tests are comprehensive and deterministic, using a barrier pattern to reliably reproduce the race condition. All existing tests pass, and the code changes are minimal, focused, and well-documented. The onResponse cleanup prevents directory accumulation.
  • No files require special attention

Important Files Changed

Filename Overview
packages/react-on-rails-pro-node-renderer/src/worker.ts Adds per-request upload directories using randomUUID() to prevent race conditions, changes onFile to regular function for this binding, implements cleanup hook
packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts Comprehensive tests using preHandler barrier pattern to deterministically reproduce race conditions, covers both /upload-assets and /bundles/:ts/render/:digest endpoints

Sequence Diagram

sequenceDiagram
    participant Client1 as Request A
    participant Client2 as Request B
    participant Fastify as Fastify Server
    participant UploadA as uploads/UUID-A/
    participant UploadB as uploads/UUID-B/
    participant BundleA as bundle-A/
    participant BundleB as bundle-B/

    Note over Client1,BundleB: Before: Shared Upload Path (Race Condition)
    Note over Client1,Client2: Both write to uploads/file.json → overwrite!
    
    Note over Client1,BundleB: After: Per-Request Upload Directories
    
    Client1->>Fastify: POST /upload-assets
    activate Fastify
    Fastify->>Fastify: onRequest: req.uploadDir = uploads/UUID-A/
    
    Client2->>Fastify: POST /upload-assets
    Fastify->>Fastify: onRequest: req.uploadDir = uploads/UUID-B/
    
    Fastify->>UploadA: onFile: save to uploadDir/file.json
    Fastify->>UploadB: onFile: save to uploadDir/file.json
    
    Note over UploadA,UploadB: Files isolated - no collision!
    
    Fastify->>BundleA: Handler: copy from UUID-A/ to bundle-A/
    Fastify->>BundleB: Handler: copy from UUID-B/ to bundle-B/
    
    Fastify-->>Client1: 200 OK
    deactivate Fastify
    Fastify->>UploadA: onResponse: rm uploads/UUID-A/
    
    Fastify-->>Client2: 200 OK
    Fastify->>UploadB: onResponse: rm uploads/UUID-B/
Loading

Last reviewed commit: 5e120cd

@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 20, 2026

Review: Fix node renderer upload race condition with per-request directories

The fix correctly identifies and addresses the root cause of the race condition. Assigning a UUID-based per-request upload directory via onRequest is a sound pattern for concurrent upload isolation. The barrier-based test strategy for making the race deterministic is clever and well-explained.

A few issues worth addressing before merging:

Functional concern: this binding in onFile is an implementation detail

The async onFile(part) method relies on @fastify/multipart calling it with this bound to the Fastify request. This is not explicitly documented in the plugin's public API (the README shows onFile as an arrow function accepting only part). If a future version of @fastify/multipart changes how the callback is invoked, this.uploadDir would silently resolve to undefined and paths would become undefined/<filename> — a hard-to-diagnose failure. This should be verified against the plugin source, and ideally a defensive check or an alternative approach (e.g., passing uploadDir via a closure using a per-request plugin) should be considered.

Redundant deleteUploadedAssets call

The /upload-assets handler still calls deleteUploadedAssets(assets) to unlink individual files before sending the response. After this fix, the onResponse hook will rm the entire per-request UUID directory anyway. The individual file deletion is now redundant I/O. It's not harmful, but it should either be removed or the onResponse cleanup should be scoped to only handle the case where the handler didn't already clean up.

Silent cleanup failure in onResponse

await rm(req.uploadDir, { recursive: true, force: true }).catch(() => {});

Swallowing all errors entirely hides disk-space exhaustion or permission issues. Even a log.warn would help operators notice accumulating upload directories.

Stale test file comment

Lines 7–11 of uploadRaceCondition.test.ts describe the pre-fix state: "Currently these tests FAIL because...". Since this PR is the fix, that comment misrepresents the post-merge state and should be rewritten to describe why the tests are now reliable (isolation guarantee).

Minor: createReadStream imported from fs-extra

The test imports createReadStream from fs-extra — it's a native Node fs export and should be imported from 'fs' for clarity.

Comment thread packages/react-on-rails-pro-node-renderer/src/worker.ts Outdated
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Feb 20, 2026

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 62.5 KB (0%)
react-on-rails/client bundled (gzip) (time) 62.5 KB (0%)
react-on-rails/client bundled (brotli) 53.71 KB (0%)
react-on-rails/client bundled (brotli) (time) 53.71 KB (0%)
react-on-rails-pro/client bundled (gzip) 63.5 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 63.5 KB (0%)
react-on-rails-pro/client bundled (brotli) 54.67 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 54.67 KB (0%)
registerServerComponent/client bundled (gzip) 127.16 KB (0%)
registerServerComponent/client bundled (gzip) (time) 127.16 KB (0%)
registerServerComponent/client bundled (brotli) 61.54 KB (0%)
registerServerComponent/client bundled (brotli) (time) 61.54 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 121.76 KB (+0.07% 🔺)
wrapServerComponentRenderer/client bundled (gzip) (time) 121.69 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 56.63 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 56.63 KB (0%)

Comment thread packages/react-on-rails-pro-node-renderer/src/worker.ts Outdated
Comment thread packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts Outdated
Comment thread packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts Outdated
…e comment

- Log warning instead of silently swallowing onResponse cleanup errors
- Add runtime assertion in onFile to catch if @fastify/multipart changes
  this binding behavior
- Rewrite test file header to describe post-fix invariant, not pre-fix state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Feb 20, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds per-request upload directories (UUID-named) set on Fastify requests; multipart onFile now writes to the request-specific directory via this.uploadDir; per-request upload dirs are removed on response; master runs periodic orphaned-upload cleanup; deleteUploadedAssets removed; tests add concurrent upload/render race coverage.

Changes

Cohort / File(s) Summary
Worker — request-scoped uploads
packages/react-on-rails-pro-node-renderer/src/worker.ts
Adds randomUUID import; augments FastifyRequest with uploadDir; decorates request and sets uploadDir in onRequest; multipart onFile converted to a this: FastifyRequest method that writes files to this.uploadDir (with validation and filename sanitization); schedules rm of the per-request directory in onResponse.
Master — orphan cleanup
packages/react-on-rails-pro-node-renderer/src/master.ts
Adds periodic scan of serverBundleCachePath/uploads/* to remove directories older than a threshold (imports readdir, stat, rm); runs cleanup on an interval alongside existing master logic.
Handle render — remove post-copy cleanup
packages/react-on-rails-pro-node-renderer/src/worker/handleRenderRequest.ts
Removes import and invocation of deleteUploadedAssets after bundle processing; uploaded assets are no longer deleted as part of post-processing.
Shared utils — API removal
packages/react-on-rails-pro-node-renderer/src/shared/utils.ts
Removes deleteUploadedAssets function and its unlink usage from exported utilities.
Tests — race condition coverage
packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts
Adds a comprehensive test suite that synchronizes concurrent uploads and renders (barrier) to assert per-request/per-bundle isolation and asset integrity under concurrent scenarios.
Changelog
CHANGELOG.md
Appends an Unreleased Pro entry documenting the upload race condition fix and introduction of per-request upload directories.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Fastify as Fastify
    participant FS as Filesystem
    participant Master as Master

    rect rgba(135,206,235,0.5)
    Client->>Fastify: POST /upload-assets (multipart)
    note right of Fastify: onRequest sets this.uploadDir = <cache>/uploads/<uuid>/
    Fastify->>FS: mkdir(this.uploadDir)
    Fastify->>Fastify: multipart parsing (onFile uses this.uploadDir)
    Fastify->>FS: write file -> this.uploadDir/filename
    Fastify->>Fastify: acquire lock and process upload
    Fastify->>FS: move/copy assets -> bundle directory
    Fastify->>FS: rm -r this.uploadDir (onResponse)
    Fastify-->>Client: 200 OK
    end

    rect rgba(240,230,140,0.5)
    Master->>FS: periodic scan of <cache>/uploads/*
    Master->>FS: rm -r old upload dirs
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐇
I dug a tiny UUID den, neat and new,
Each request hops in with its own view.
Files land safe in burrows made just so,
I sweep the crumbs when every render's through.
No crossed tracks now — hop, stash, and go!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 20.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 accurately summarizes the main change: fixing a node renderer upload race condition by implementing per-request directories.
Linked Issues check ✅ Passed The PR implements all core requirements from issue #2449: per-request upload isolation via UUID, onRequest/onResponse hooks for cleanup, onFile handler refactoring for request binding, orphan cleanup in master process, and comprehensive race-condition tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to fixing the upload race condition: worker.ts modifications, test suite for isolation verification, orphan cleanup in master.ts, removal of the old deleteUploadedAssets function, and related cleanup in handleRenderRequest.ts.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch 2449-fix-node-renderer-upload-race-condition

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.

Comment thread packages/react-on-rails-pro-node-renderer/src/worker.ts Fixed
Comment thread packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts Outdated
Comment thread packages/react-on-rails-pro-node-renderer/src/worker.ts
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 20, 2026

Review

The core fix is correct and well-motivated: per-request UUID upload directories eliminate the shared-path race condition cleanly, and the barrier-based test strategy makes the formerly non-deterministic race reproducible. A few items to address:

Barrier function can deadlock (tests)

If one of the two concurrent requests fails before reaching preHandler (e.g. onFile throws, or auth/protocol checks send an early rejection), arrived never reaches expectedCount, release() is never called, and the surviving request hangs until Jest's global timeout. The failure message will be a cryptic timeout rather than the root cause. Adding a safety-valve timeout (see inline comment) is a simple fix.

Previous review's "silent cleanup failure" note is incorrect

The onResponse hook in the current code does log via log.warn — it is not swallowing errors silently:

await rm(req.uploadDir, { recursive: true, force: true }).catch((err: unknown) => {
  log.warn({ msg: 'Failed to clean up per-request upload directory', uploadDir: req.uploadDir, err });
});

This is correctly implemented.

deleteUploadedAssets is redundant

The /upload-assets handler still calls deleteUploadedAssets(assets) (which unlinks individual files) before sending the response. After this PR, onResponse will recursively rm the entire UUID directory anyway. The individual unlinks are now redundant I/O and generate log noise ("Deleted assets ...") for files that were already going to be cleaned up. Either remove the deleteUploadedAssets call or leave a comment explaining why both are kept (e.g. as an early best-effort cleanup before the response is sent).

createReadStream import from fs-extra

Noted in the previous review and still present — see inline comment.

this binding reliance

The defensive guard at onFile entry (typeof this?.uploadDir \!== 'string' || this.uploadDir === '') is good and will surface any future breakage clearly. Still worth verifying this behavior is stable against @fastify/multipart semver bumps — the README examples use arrow functions and don't call this out explicitly.

Copy link
Copy Markdown
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.

🧹 Nitpick comments (1)
packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts (1)

62-76: Consider closing the Fastify app after each test.

Each test creates a new Fastify instance via worker() but never calls app.close(). While inject() doesn't open sockets, Fastify's internal lifecycle (timers, plugin state) isn't torn down. This can cause "open handles" warnings from the test runner.

One approach: hoist app to a let at describe scope and close it in afterEach:

Suggested pattern
  describe('concurrent upload isolation (issue `#2449`)', () => {
    let tmpDirA: string;
    let tmpDirB: string;
+   let app: ReturnType<typeof worker>;

    // ... beforeEach ...

    afterEach(async () => {
+     await app?.close();
      await resetForTest(testName);
      // ...
    });

Then assign app in each test instead of declaring it with const.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts`
around lines 62 - 76, Tests create Fastify instances via worker() but never
close them, leaving timers/plugins open; hoist a let app to describe scope,
assign the Fastify instance returned by worker() into that app in each test
(replace local const app declarations), and call await app.close() in afterEach
to properly tear down Fastify lifecycle; reference worker(), app.inject() and
app.close() when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts`:
- Around line 62-76: Tests create Fastify instances via worker() but never close
them, leaving timers/plugins open; hoist a let app to describe scope, assign the
Fastify instance returned by worker() into that app in each test (replace local
const app declarations), and call await app.close() in afterEach to properly
tear down Fastify lifecycle; reference worker(), app.inject() and app.close()
when making the change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 20, 2026

Review: Fix node renderer upload race condition with per-request directories

The core fix is correct and well-motivated. Assigning each request an isolated UUID upload directory eliminates the shared-path collision at the root of the ENOENT/corruption bug, and the barrier technique in the tests makes the race deterministic rather than relying on timing. Good work overall.

A few items to address:


Security: path traversal in uploaded filenames

part.filename is user-supplied and is joined directly into the filesystem path without sanitization:

const destinationPath = path.join(this.uploadDir, part.filename);

If a client (or compromised Rails gem) sends a filename like ../../sensitive.js, path.join will normalize it and the file is written outside the intended UUID directory. This isn't new to this PR — the old code had the same pattern — but the fix is a good opportunity to add a guard. Recommended: validate that the resolved destination path is still a child of this.uploadDir before writing.


Moderate: this binding is not type-safe

The comment explaining why a regular function is used instead of an arrow function is helpful, but the TypeScript compiler doesn't know what type this has inside onFile. If @fastify/multipart ever changes its binding behavior, TypeScript won't catch it — only the runtime guard will fire. Consider using thisParameterType or explicit this: FastifyRequest in the function signature so the type checker enforces the contract.


Minor: redundant individual-file deletion before directory cleanup

In the /upload-assets handler, deleteUploadedAssets(assets) explicitly unlinks individual files after copying them. Then onResponse does rm(req.uploadDir, { recursive: true, force: true }) which removes the whole UUID directory anyway. The explicit per-file deletion is harmless but redundant — the onResponse cleanup makes it unnecessary. This is worth cleaning up to reduce complexity, though it doesn't affect correctness.


Minor: no startup cleanup of orphaned UUID directories

If the server exits uncleanly (SIGKILL, OOM), onResponse never runs and UUID directories accumulate under uploads/. There's no sweep on startup to remove old entries. This was a pre-existing concern with the flat uploads/ directory, but UUID directories are harder to identify as orphans. Consider adding a startup cleanup of any pre-existing uploads/<uuid>/ directories (entries matching a UUID pattern) after the server initializes.

Comment thread packages/react-on-rails-pro-node-renderer/src/worker.ts Outdated
Comment thread packages/react-on-rails-pro-node-renderer/src/worker.ts Outdated
…eteUploadedAssets

The onResponse hook was redundant since deleteUploadedAssets already deletes individual
files. Now the parent UUID directory is cleaned up in the same function, keeping the
cleanup logic co-located with the file deletion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown
Contributor

claude Bot commented Feb 20, 2026

Review: Fix node renderer upload race condition with per-request directories

The root cause analysis is correct and the fix is conceptually sound — per-request UUID directories are the right approach for eliminating shared-path collisions. The test barrier technique is particularly clever. A few issues to address:

Bug: UUID directory leaks when a bundle is uploaded without assets

In the render endpoint, when a new bundle is provided but assetsToCopy is empty, deleteUploadedAssets([]) is called but does nothing (empty parentDirs set). The UUID directory created for the bundle upload was vacated by moveUploadedAsset but never removed:

# Bundle-only render request lifecycle:
uploads/<uuid>/bundle.js  → moved to bundleDir/ (file gone, UUID dir stays)
deleteUploadedAssets([])  → parentDirs is empty, UUID dir never removed  ✗

The simplest fix is to clean up req.uploadDir directly in an onResponse hook (which you had earlier and then removed). Alternatively, change deleteUploadedAssets to accept an optional explicit directory to clean up regardless of whether there were assets.

Security: unsanitized part.filename used in path.join

path.join(this.uploadDir, part.filename) is vulnerable to directory traversal if a client sends a filename containing ../. Node's path.join normalizes traversals rather than rejecting them:

path.join('/cache/uploads/uuid123', '../../../etc/cron.d/malicious')
// → '/etc/cron.d/malicious'

Add path.basename(part.filename) before constructing the destination path. (This is a pre-existing pattern but the new per-request directory structure makes sanitization more important since it's the only isolation boundary.)

Code quality: deleteUploadedAssets name no longer matches behaviour

The function now silently cleans up parent directories in addition to deleting files. Callers don't expect this side-effect, and callers that pass an empty array (bundle-only case above) get no cleanup at all. Consider either renaming to deleteUploadedAssetsAndCleanupDir with an explicit uploadDir parameter, or extracting the directory cleanup into a dedicated utility.

Code quality: this binding in onFile relies on undocumented behaviour

The comment acknowledges this, but the chosen failure mode (throwing an unhandled error and crashing the request mid-multipart-parse) may leave partially-written files in the UUID directory with no cleanup path. It would be worth documenting the minimum @fastify/multipart version where this binding is confirmed, so future upgrades are aware of the contract.

@AbanoubGhadban AbanoubGhadban deleted the 2449-fix-node-renderer-upload-race-condition branch February 21, 2026 09:34
AbanoubGhadban added a commit that referenced this pull request Feb 25, 2026
…2456)

- **Root cause**: All concurrent multipart uploads wrote to a single
shared path (`<serverBundleCachePath>/uploads/<filename>`), causing
overwrites, ENOENT errors, and cross-contamination between requests.
This was confirmed by a production postmortem showing `SyntaxError: Bad
control character in string literal in JSON` during pod rollovers.
- **Fix**: Each request now gets its own upload directory
(`uploads/<randomUUID>/`) assigned in an `onRequest` hook. The `onFile`
handler writes to the per-request directory. An `onResponse` hook cleans
up the directory after the response is sent.
- **Tests**: 3 new deterministic race condition tests using a
`preHandler` barrier technique that forces concurrent requests' `onFile`
phases to complete before any route handler runs. Tests cover
`/upload-assets` (single asset, multiple assets) and
`/bundles/:ts/render/:digest` endpoints.

Closes #2449

- Added `declare module 'fastify'` augmentation for `uploadDir` on
`FastifyRequest`
- Added `decorateRequest('uploadDir', '')` + `onRequest` hook assigning
`uploads/<randomUUID>/`
- Added `onResponse` hook to clean up per-request upload directory
- Changed `onFile` from arrow function to method function so `this` (the
Fastify request) provides the per-request `uploadDir`

`packages/react-on-rails-pro-node-renderer/tests/uploadRaceCondition.test.ts`
(new)
- Concurrent `/upload-assets` — single asset, different content,
different target bundles
- Concurrent `/upload-assets` — multiple assets, different content,
different target bundles
- Concurrent render requests — different bundle timestamps, same-named
assets with different content

- [x] All 3 new race condition tests pass (previously failed
deterministically)
- [x] All 17 existing `worker.test.ts` tests pass (no regressions)
- [x] ESLint passes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

* **Bug Fixes**
* Per-request isolated upload directories to prevent cross-request asset
contamination; automatic periodic cleanup of stale temporary upload
folders.
* Uploaded assets are no longer removed immediately after processing,
avoiding accidental loss.

* **Tests**
* Added comprehensive tests validating concurrent upload and render
request isolation across endpoints and bundle directories.

* **Documentation**
  * Recorded the fix in the changelog under Unreleased.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Justin Gordon <justin808@users.noreply.github.com>
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.

Race condition in node renderer uploads causes ENOENT and silent asset corruption

3 participants