Skip to content

Commit fd16aff

Browse files
committed
fix(sdk-commands): serialize local storage writes to prevent server-storage.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.
1 parent 96e9a29 commit fd16aff

2 files changed

Lines changed: 171 additions & 41 deletions

File tree

packages/@dcl/sdk-commands/src/commands/start/server/runtime-env.ts

Lines changed: 73 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,29 @@ export interface ServerStorage {
1616
players: Record<string, Record<string, unknown>>
1717
}
1818

19-
const DEFAULT_STORAGE: ServerStorage = {
20-
env: {},
21-
world: {},
22-
players: {}
19+
function createDefaultStorage(): ServerStorage {
20+
return {
21+
env: {},
22+
world: {},
23+
players: {}
24+
}
25+
}
26+
27+
let writeQueue: Promise<unknown> = Promise.resolve()
28+
29+
/**
30+
* Serializes read-modify-write cycles against server-storage.json. The entire
31+
* load→mutate→save must run under one lock: two handlers that each load the same
32+
* snapshot would otherwise lose one update, and two concurrent saves would interleave
33+
* their writes into a corrupt file.
34+
*/
35+
function serialize<T>(task: () => Promise<T>): Promise<T> {
36+
const run = writeQueue.then(task, task)
37+
writeQueue = run.then(
38+
() => undefined,
39+
() => undefined
40+
)
41+
return run
2342
}
2443

2544
/**
@@ -45,21 +64,20 @@ export async function loadServerStorage(components: Pick<CliComponents, 'fs' | '
4564
try {
4665
const exists = await components.fs.fileExists(storagePath)
4766
if (!exists) {
48-
return { ...DEFAULT_STORAGE }
67+
return createDefaultStorage()
4968
}
5069

5170
const content = await components.fs.readFile(storagePath, 'utf-8')
5271
const parsed = JSON.parse(content) as Partial<ServerStorage>
5372

54-
// Merge with defaults to ensure all keys exist
5573
return {
5674
env: parsed.env ?? {},
5775
world: parsed.world ?? {},
5876
players: parsed.players ?? {}
5977
}
6078
} catch (error) {
6179
components.logger.error(`Failed to load ${SERVER_STORAGE_FILE}: ${error}`)
62-
return { ...DEFAULT_STORAGE }
80+
return createDefaultStorage()
6381
}
6482
}
6583

@@ -74,7 +92,9 @@ export async function saveServerStorage(
7492
const storagePath = path.join(RUNTIME_DATA_DIR, SERVER_STORAGE_FILE)
7593

7694
try {
77-
await components.fs.writeFile(storagePath, JSON.stringify(data, null, 2))
95+
const tmpPath = `${storagePath}.tmp`
96+
await components.fs.writeFile(tmpPath, JSON.stringify(data, null, 2))
97+
await components.fs.rename(tmpPath, storagePath)
7898
} catch (error) {
7999
components.logger.error(`Failed to save ${SERVER_STORAGE_FILE}: ${error}`)
80100
throw error
@@ -163,23 +183,27 @@ export async function setEnvValue(
163183
key: string,
164184
value: string
165185
): Promise<void> {
166-
const storage = await loadServerStorage(components)
167-
storage.env[key] = value
168-
await saveServerStorage(components, storage)
186+
return serialize(async () => {
187+
const storage = await loadServerStorage(components)
188+
storage.env[key] = value
189+
await saveServerStorage(components, storage)
190+
})
169191
}
170192

171193
/**
172194
* Deletes a runtime environment variable.
173195
* Returns true if key existed and was deleted, false otherwise.
174196
*/
175197
export async function deleteEnvValue(components: Pick<CliComponents, 'fs' | 'logger'>, key: string): Promise<boolean> {
176-
const storage = await loadServerStorage(components)
177-
if (!(key in storage.env)) {
178-
return false
179-
}
180-
delete storage.env[key]
181-
await saveServerStorage(components, storage)
182-
return true
198+
return serialize(async () => {
199+
const storage = await loadServerStorage(components)
200+
if (!(key in storage.env)) {
201+
return false
202+
}
203+
delete storage.env[key]
204+
await saveServerStorage(components, storage)
205+
return true
206+
})
183207
}
184208

185209
/**
@@ -211,9 +235,11 @@ export async function setWorldValue(
211235
key: string,
212236
value: unknown
213237
): Promise<void> {
214-
const storage = await loadServerStorage(components)
215-
storage.world[key] = value
216-
await saveServerStorage(components, storage)
238+
return serialize(async () => {
239+
const storage = await loadServerStorage(components)
240+
storage.world[key] = value
241+
await saveServerStorage(components, storage)
242+
})
217243
}
218244

219245
/**
@@ -224,13 +250,15 @@ export async function deleteWorldValue(
224250
components: Pick<CliComponents, 'fs' | 'logger'>,
225251
key: string
226252
): Promise<boolean> {
227-
const storage = await loadServerStorage(components)
228-
if (!(key in storage.world)) {
229-
return false
230-
}
231-
delete storage.world[key]
232-
await saveServerStorage(components, storage)
233-
return true
253+
return serialize(async () => {
254+
const storage = await loadServerStorage(components)
255+
if (!(key in storage.world)) {
256+
return false
257+
}
258+
delete storage.world[key]
259+
await saveServerStorage(components, storage)
260+
return true
261+
})
234262
}
235263

236264
/**
@@ -254,12 +282,14 @@ export async function setPlayerValue(
254282
key: string,
255283
value: unknown
256284
): Promise<void> {
257-
const storage = await loadServerStorage(components)
258-
if (!storage.players[address]) {
259-
storage.players[address] = {}
260-
}
261-
storage.players[address][key] = value
262-
await saveServerStorage(components, storage)
285+
return serialize(async () => {
286+
const storage = await loadServerStorage(components)
287+
if (!storage.players[address]) {
288+
storage.players[address] = {}
289+
}
290+
storage.players[address][key] = value
291+
await saveServerStorage(components, storage)
292+
})
263293
}
264294

265295
/**
@@ -271,11 +301,13 @@ export async function deletePlayerValue(
271301
address: string,
272302
key: string
273303
): Promise<boolean> {
274-
const storage = await loadServerStorage(components)
275-
if (!storage.players[address] || !(key in storage.players[address])) {
276-
return false
277-
}
278-
delete storage.players[address][key]
279-
await saveServerStorage(components, storage)
280-
return true
304+
return serialize(async () => {
305+
const storage = await loadServerStorage(components)
306+
if (!storage.players[address] || !(key in storage.players[address])) {
307+
return false
308+
}
309+
delete storage.players[address][key]
310+
await saveServerStorage(components, storage)
311+
return true
312+
})
281313
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import {
2+
loadServerStorage,
3+
saveServerStorage,
4+
setEnvValue,
5+
setWorldValue,
6+
setPlayerValue,
7+
getPlayerValue
8+
} from '../../../../packages/@dcl/sdk-commands/src/commands/start/server/runtime-env'
9+
10+
/**
11+
* In-memory stand-in for the `.runtime-data/` directory that runtime-env reads and
12+
* writes. Async fns yield at each `await`, so concurrent read-modify-write cycles
13+
* interleave exactly as they would on Node's event loop. runtime-env derives the
14+
* storage path from its own package location, so it is learned from the first access.
15+
*/
16+
function makeComponents(initialFile?: string) {
17+
const files = new Map<string, string>()
18+
let mainPath = ''
19+
const learn = (filePath: string) => {
20+
if (filePath.endsWith('.tmp')) return
21+
mainPath = filePath
22+
if (initialFile !== undefined && !files.has(filePath)) files.set(filePath, initialFile)
23+
}
24+
const fs = {
25+
fileExists: jest.fn(async (filePath: string) => {
26+
learn(filePath)
27+
return files.has(filePath)
28+
}),
29+
readFile: jest.fn(async (filePath: string) => files.get(filePath) ?? ''),
30+
directoryExists: jest.fn(async () => true),
31+
mkdir: jest.fn(async () => undefined),
32+
writeFile: jest.fn(async (filePath: string, content: string) => {
33+
files.set(filePath, content)
34+
}),
35+
rename: jest.fn(async (from: string, to: string) => {
36+
files.set(to, files.get(from)!)
37+
files.delete(from)
38+
learn(to)
39+
})
40+
}
41+
const logger = { debug: jest.fn(), error: jest.fn(), info: jest.fn(), log: jest.fn(), warn: jest.fn() }
42+
return { components: { fs, logger } as any, fs, logger, readMain: () => files.get(mainPath) }
43+
}
44+
45+
describe('runtime-env concurrent write safety', () => {
46+
it('does not lose player upserts issued concurrently', async () => {
47+
const { components } = makeComponents(JSON.stringify({ env: {}, world: {}, players: { '0xabc': {} } }))
48+
49+
const keys = Array.from({ length: 20 }, (_, i) => `k${i}`)
50+
await Promise.all(keys.map((key, i) => setPlayerValue(components, '0xabc', key, i)))
51+
52+
for (let i = 0; i < keys.length; i++) {
53+
expect(await getPlayerValue(components, '0xabc', keys[i])).toBe(i)
54+
}
55+
})
56+
57+
it('does not lose concurrent writes across the env/world/player buckets', async () => {
58+
const { components, readMain } = makeComponents(JSON.stringify({ env: {}, world: {}, players: {} }))
59+
60+
await Promise.all([
61+
setEnvValue(components, 'FOO', 'bar'),
62+
setWorldValue(components, 'score', 42),
63+
setPlayerValue(components, '0xabc', 'coins', 7)
64+
])
65+
66+
const stored = JSON.parse(readMain()!)
67+
expect(stored.env).toEqual({ FOO: 'bar' })
68+
expect(stored.world).toEqual({ score: 42 })
69+
expect(stored.players).toEqual({ '0xabc': { coins: 7 } })
70+
})
71+
})
72+
73+
describe('runtime-env atomic writes', () => {
74+
it('writes a temp file and renames it over the target', async () => {
75+
const { components, fs, readMain } = makeComponents()
76+
77+
await setEnvValue(components, 'FOO', 'bar')
78+
79+
const writtenPath: string = fs.writeFile.mock.calls[0][0]
80+
expect(writtenPath).toMatch(/server-storage\.json\..+/)
81+
expect(fs.rename).toHaveBeenCalledWith(writtenPath, expect.stringMatching(/server-storage\.json$/))
82+
expect(JSON.parse(readMain()!).env).toEqual({ FOO: 'bar' })
83+
})
84+
})
85+
86+
describe('runtime-env default isolation', () => {
87+
it('does not leak state between default (no-file) loads', async () => {
88+
const { components } = makeComponents()
89+
90+
const a = await loadServerStorage(components)
91+
a.env.LEAK = 'yes'
92+
a.players.someone = { x: 1 }
93+
94+
const b = await loadServerStorage(components)
95+
expect(b.env).toEqual({})
96+
expect(b.players).toEqual({})
97+
})
98+
})

0 commit comments

Comments
 (0)