Skip to content

fix(sdk-commands): serialize local storage writes to prevent server-storage.json corruption - #1545

Merged
cyaiox merged 2 commits into
auth-serverfrom
fix/local-storage-file
Aug 19, 2026
Merged

fix(sdk-commands): serialize local storage writes to prevent server-storage.json corruption#1545
cyaiox merged 2 commits into
auth-serverfrom
fix/local-storage-file

Conversation

@cyaiox

@cyaiox cyaiox commented Aug 17, 2026

Copy link
Copy Markdown
Member

Bug Description

The local preview/storage dev server (@dcl/sdk-commands start) persists all runtime state — env, world, and players — to a single server-storage.json via a whole-file read-modify-write. Concurrent writes were unguarded, so the file could be silently corrupted or lose data.

Expected: concurrent storage upserts (e.g. a scene issuing several set() calls on load) all persist, and the file always stays valid JSON.

Actual: overlapping requests interleaved on Node's event loop — later saves clobbered earlier ones (lost updates), overlapping writes could interleave into invalid JSON (which the loader then discarded, resetting everything to defaults), and default (no-file) loads aliased shared objects so state leaked between unrelated reads.

Root Cause

Every mutator did loadServerStorage() → mutate in memory → saveServerStorage() with no serialization. Because each step awaits, two in-flight requests interleave at those yield points:

  • Lost updates: both handlers load the same snapshot; the later save overwrites the other's change.
  • Byte corruption: saveServerStorage wrote directly to the real file (no temp+rename), so two concurrent writes could interleave, and a crash mid-write left a truncated file.
  • State leak: DEFAULT_STORAGE was a shared const; { ...DEFAULT_STORAGE } shallow-copied it, so every default load shared the same nested env/world/players objects.

Type of Change

  • Bug fix

Fix Description

  • Route every load → mutate → save cycle through a single in-process FIFO queue (serialize()), so writes apply in issue order and never race.
  • Make saveServerStorage atomic: write a temp file, then rename over the target — a crash mid-write can no longer leave an unparseable file.
  • Replace the shared DEFAULT_STORAGE const with a createDefaultStorage() factory so default loads no longer alias each other.

How to Reproduce (Before Fix)

  1. sdk-commands start a scene that issues several player/world/env set() calls on load (or drive concurrent storage ... set --target http://localhost:<port>).
  2. Inspect <@dcl/sdk-commands>/.runtime-data/server-storage.json.
  3. Observe missing keys (lost updates) or, on unlucky interleavings, invalid JSON that gets reset to defaults.

How to Verify (After Fix)

  1. node_modules/.bin/jest --forceExit --testPathPatterns='test/sdk-commands/commands/start/runtime-env' — the new concurrency/atomicity/isolation tests pass.
  2. Repeat the reproduction: all values persist and the file stays valid JSON.
  3. node_modules/.bin/jest --forceExit --testPathPatterns='test/sdk-commands/commands/start' — full start suite green (33/33).

Impact Assessment

  • Severity: Medium
  • Users affected: Subset — developers using the local preview/storage server with concurrent or bursty writes.
  • Duration: Present since the local storage server was introduced.

Regression Risk

  • Behavior/API unchanged — same functions, return types, and HTTP endpoints; purely a robustness change.

  • Writes are now serialized in-process; a single hung executor would delay subsequent writes (bounded to local dev use).

Checklist

  • Root cause identified and documented above
  • Fix addresses the root cause (not just symptoms)
  • Added test to prevent regression
  • Existing tests pass locally
  • Tested the specific reproduction steps
  • No new warnings or console errors introduced

Related Issues

cyaiox added 2 commits August 17, 2026 18:39
…torage.json corruption

The local preview/storage server persisted env/world/player state via an
unguarded whole-file read-modify-write on server-storage.json. Concurrent
upserts (a scene firing several set() calls on load, multiple tabs, or the
storage CLI) interleaved on the event loop, causing lost updates and, with
overlapping saves, byte-level corruption that the loader then discarded to
defaults.

- Run every load->mutate->save through a single in-process FIFO queue so
  writes apply in order and never race.
- Make saveServerStorage atomic (write temp file, then rename) so a crash
  mid-write can never leave a truncated, unparseable file.
- Replace the shared DEFAULT_STORAGE const with a createDefaultStorage()
  factory so default loads no longer alias (and leak into) each other.

Adds runtime-env.spec.ts covering concurrent upserts, cross-bucket writes,
atomic-write, and default isolation.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 17, 2026

Copy link
Copy Markdown

Deploying js-sdk-toolchain with  Cloudflare Pages  Cloudflare Pages

Latest commit: 5a06d4b
Status: ✅  Deploy successful!
Preview URL: https://23cd1dad.js-sdk-toolchain.pages.dev
Branch Preview URL: https://fix-local-storage-file.js-sdk-toolchain.pages.dev

View logs

@github-actions

github-actions Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Test this pull request

  • The @dcl/sdk package can be tested in scenes by running

    npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/fix/local-storage-file/dcl-sdk-7.26.1-32046756184.commit-8033348.tgz"
  • The @dcl/js-runtime package can be tested in scenes by running

    npm install "https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/fix/local-storage-file/@dcl/js-runtime/dcl-js-runtime-7.26.1-32046756184.commit-8033348.tgz"
  • To test with npx init

    export SDK_COMMANDS="https://sdk-team-cdn.decentraland.org/@dcl/js-sdk-toolchain/branch/fix/local-storage-file/dcl-sdk-commands-7.26.1-32046756184.commit-8033348.tgz"
    npx $SDK_COMMANDS init
  • The /changerealm command to test test in-world

    /changerealm https://sdk-team-cdn.decentraland.org/ipfs/fix/local-storage-file-e2e
    
  • You can preview this build entering:
    https://playground.decentraland.org/?sdk-branch=fix/local-storage-file

@decentraland-bot decentraland-bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review — PR #1545

PR: fix(sdk-commands): serialize local storage writes to prevent server-storage.json corruption
Branch: fix/local-storage-fileauth-server
Files changed: 3 (+172 −41)
CI: ✅ All checks passing (lint, build, test, docs, CLI E2E, Cloudflare Pages)


Verdict: ✅ APPROVE

This is a well-crafted bug fix that correctly addresses three distinct failure modes in the local dev server storage:

  1. Lost updates — concurrent read-modify-write cycles now serialized via a promise-chain FIFO queue
  2. File corruption — writes are now atomic (temp file + rename)
  3. State leaks — shared DEFAULT_STORAGE const replaced with a createDefaultStorage() factory

The serialize() implementation is correct: writeQueue.then(task, task) ensures the queue never stalls on a rejection, and the tail reassignment (writeQueue = run.then(…)) properly swallows errors for queue continuation while still propagating real results to callers. No memory leak — V8 GCs settled promises in the chain. Read-only operations correctly left outside the lock since atomic rename guarantees readers always see a complete file.

No P0 or P1 issues found. API surface is unchanged — purely behavioral fix with no breaking changes.


Git Style (ADR-6)

  • ✅ PR title follows <type>(<scope>): <summary> format
  • ✅ Branch follows fix/<summary> pattern
  • ✅ Base branch correctly targets auth-server (not main)
  • ℹ️ No issue reference — consider adding closes #N if a tracking issue exists

Security Review

No security issues introduced by this PR. The storage path is constructed from constants (no user input in file paths), error messages don't leak secrets, and the atomic write pattern is sound.

Two pre-existing observations in the unchanged storage-service.ts (not introduced by this PR, noted for awareness):

  • [P2] Prototype pollution surface — a PUT /players/__proto__/values/key request would write to Object.prototype since address is not validated against reserved property names. Low severity given local-dev-only context.
  • [P2] No runtime type validationsetEnvValue accepts value: string at the type level, but the JSON-parsed body isn't validated at runtime.

Findings

[P2] loadServerStorage / saveServerStorage exported without serialization guard

runtime-env.ts:61, 87 — Both functions are exported, allowing external callers to bypass the serialize() queue with a manual load→mutate→save cycle. Currently no consumer does this (the only import in storage-service.ts calls the properly-serialized mutator functions), so there is no active bug. Consider either un-exporting saveServerStorage or adding a @internal JSDoc warning.

[P2] Promise<unknown | undefined> is a no-op union

runtime-env.ts:225, 271unknown | undefined collapses to unknown since undefined ⊂ unknown. The | undefined suggests the caller should check for it, but the type doesn't enforce that.

[P2] ensureRuntimeDir swallows mkdir failure

runtime-env.ts:48-56 — If mkdir throws, the error is logged but not re-thrown. Execution continues to writeFile, which fails with a less diagnostic error, hiding the root cause.

[P2] Empty player records not cleaned up after delete

runtime-env.ts:299-313deletePlayerValue removes a key but leaves the empty {} object behind. Over many deleted players, empty records accumulate in the JSON file.

[P2] Test coverage gaps

runtime-env.spec.ts — No tests for deleteEnvValue, deleteWorldValue, or deletePlayerValue under concurrency. Also no test for error recovery (task throws → queue continues). The existing tests are solid for the three bugs being fixed.

Consumer Impact

runtime-env.ts is consumed only by storage-service.ts within @dcl/sdk-commands. The module is not publicly exported from the package. Function signatures unchanged — purely internal behavioral fix with no downstream impact.

Review Agents Used

  • TypeScript reviewer
  • Architecture strategist
  • Security sentinel

Reviewed by Jarvis 🤖 · Requested by Gabriel Díaz (<@U03MGHMAJL8>) via Slack


try {
await components.fs.writeFile(storagePath, JSON.stringify(data, null, 2))
const tmpPath = `${storagePath}.tmp`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[P2] The temp file name is deterministic. If two separate dcl start processes targeted the same .runtime-data/ directory, they would clobber each other's temp file. Safe for single-process local dev, but a unique suffix would be more defensive:

Suggested change
const tmpPath = `${storagePath}.tmp`
const tmpPath = `${storagePath}.${process.pid}.tmp`

@pravusjif pravusjif left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Tested with 2 local test scenes for multiplayer server and seem to work OK

@cyaiox
cyaiox merged commit 3c77d90 into auth-server Aug 19, 2026
8 checks passed
@cyaiox
cyaiox deleted the fix/local-storage-file branch August 19, 2026 09:53
cyaiox added a commit that referenced this pull request Aug 19, 2026
…er project

Local preview server storage (server-storage.json) had three issues:

- World values were not namespaced by scene, so previewing different scenes
  shared one bucket. World storage is now keyed by scene base coordinates
  ("x,y"), read from scene.json.
- The file lived inside node_modules/@dcl/sdk-commands, so every SDK upgrade
  wiped all local dev progress. It now lives in the project's .runtime-data/
  directory (threaded via baseDir), surviving `npm i @dcl/sdk@newer` and keeping
  same-base-coord scenes in different projects isolated.
- A legacy flat-format world file was discarded on load. It is now migrated once
  at preview-server startup into the currently previewed scene's bucket, so no
  local data is lost on the format change.

Reconciles with the upstream serialize() write-lock (#1545): every
read-modify-write still runs under the queue.
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.

3 participants