Skip to content

Commit 401ee50

Browse files
authored
fix(ecs): stop a re-arming setTimeout from hanging the scene on a large dt (#1420)
* fix(ecs): stop a re-arming setTimeout from hanging the scene on a large dt A timer callback that schedules a new timer mutates the Map the timer system is iterating, and for..of visits entries inserted mid-iteration. Since the loop also applies the frame's full 1000 * dt to those new timers, any interval <= 1000 * dt fires immediately within the same pass, so a self-re-arming setTimeout loops forever once dt exceeds its interval and the scene worker never returns. Seed timers armed inside a firing callback with the negative of the time already consumed this frame before the parent fired, so the loop's += elapsedMs nets to the true elapsed time. Re-arming chains then stay phase-accurate (matching setInterval) and catch up a bounded number of times instead of looping. Top-level timers, including setTimeout(cb, 0), are unchanged. * test: update snapshots for timer re-arm fix
1 parent 558451f commit 401ee50

14 files changed

Lines changed: 147 additions & 55 deletions

packages/@dcl/ecs/src/runtime/helpers/timers.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -99,54 +99,70 @@ type TimerData = {
9999
export function createTimers(targetEngine: IEngine): Timers {
100100
const timers: Map<TimerId, TimerData> = new Map()
101101
let timerIdCounter = 0
102+
// While a timer's callback is running this holds the time already consumed this
103+
// frame before that timer's logical fire instant (`elapsedMs - residualMs`);
104+
// `null` otherwise. A timer armed from within the callback is seeded with the
105+
// negative of this, so its delay is measured from the parent's fire instant
106+
// rather than from the start of the frame.
107+
let armContext: { accruedMs: number } | null = null
102108

103109
function system(dt: number) {
110+
const elapsedMs = 1000 * dt
104111
for (const [timerId, timerData] of timers) {
105-
timerData.accumulatedTime += 1000 * dt
112+
timerData.accumulatedTime += elapsedMs
106113

107114
if (timerData.accumulatedTime < timerData.interval) {
108115
continue
109116
}
110117

118+
// Time elapsed past this timer's logical fire instant this frame.
119+
const residualMs = timerData.recurrent
120+
? timerData.accumulatedTime % timerData.interval
121+
: timerData.accumulatedTime - timerData.interval
122+
111123
if (timerData.recurrent) {
112-
// For intervals, subtract full interval periods to handle accumulated time
113-
const fullIntervals = Math.floor(timerData.accumulatedTime / timerData.interval)
114-
timerData.accumulatedTime -= fullIntervals * timerData.interval
124+
// Collapse any missed periods into a single callback, keep the remainder.
125+
timerData.accumulatedTime = residualMs
115126
} else {
116127
timers.delete(timerId)
117128
}
118129

130+
armContext = { accruedMs: elapsedMs - residualMs }
119131
timerData.callback()
132+
armContext = null
120133
}
121134
}
122135

123136
targetEngine.addSystem(system, Number.MAX_SAFE_INTEGER, '@dcl/ecs/timers')
124137

138+
function addTimer(callback: TimerCallback, interval: number, recurrent: boolean): TimerId {
139+
const timerId = timerIdCounter++
140+
let accumulatedTime = 0
141+
142+
if (armContext) {
143+
// Armed from inside a firing callback: this timer is appended to the map
144+
// being iterated, so the loop will still add this frame's elapsed to it.
145+
// Inherit the residual so that `+= elapsedMs` nets to the true time
146+
// elapsed since the parent fired (phase-accurate). Each successive arming
147+
// starts one interval lower, so a re-arming chain terminates this frame.
148+
accumulatedTime = -armContext.accruedMs
149+
}
150+
151+
timers.set(timerId, { callback, interval, recurrent, accumulatedTime })
152+
return timerId
153+
}
154+
125155
return {
126156
setTimeout(callback: TimerCallback, ms: number): TimerId {
127-
const timerId = timerIdCounter++
128-
timers.set(timerId, {
129-
callback,
130-
interval: ms,
131-
recurrent: false,
132-
accumulatedTime: 0
133-
})
134-
return timerId
157+
return addTimer(callback, ms, false)
135158
},
136159

137160
clearTimeout(timerId: TimerId): void {
138161
timers.delete(timerId)
139162
},
140163

141164
setInterval(callback: TimerCallback, ms: number): TimerId {
142-
const timerId = timerIdCounter++
143-
timers.set(timerId, {
144-
callback,
145-
interval: ms,
146-
recurrent: true,
147-
accumulatedTime: 0
148-
})
149-
return timerId
165+
return addTimer(callback, ms, true)
150166
},
151167

152168
clearInterval(timerId: TimerId): void {

test/ecs/timers.spec.ts

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ describe('Timer helpers', () => {
304304
expect(called).toBe(true)
305305
})
306306

307-
it('should handle callback that schedules another timer', async () => {
307+
it('a timer scheduled inside a callback is measured from when it was armed', async () => {
308308
const engine = Engine()
309309
const timers = createTimers(engine)
310310
const order: number[] = []
@@ -316,10 +316,86 @@ describe('Timer helpers', () => {
316316
}, 100)
317317
}, 100)
318318

319-
await engine.update(0.1) // Both callbacks fire in the same frame
319+
// Outer fires at 100ms; the inner is armed at that instant and asks for
320+
// 100ms more, so it must NOT fire in the same frame.
321+
await engine.update(0.1)
322+
expect(order).toEqual([1])
323+
324+
// It fires on the next frame, ~100ms after it was armed.
325+
await engine.update(0.1)
320326
expect(order).toEqual([1, 2])
321327
})
322328

329+
it('does not lock up when a setTimeout re-arms itself under a large dt', async () => {
330+
const engine = Engine()
331+
const timers = createTimers(engine)
332+
let count = 0
333+
const rearm = () => {
334+
count++
335+
timers.setTimeout(rearm, 1000) // re-arm every 1s
336+
}
337+
timers.setTimeout(rearm, 1000)
338+
339+
// 10s in a single frame: the chain must catch up a bounded, correct number
340+
// of times (10) and return — not loop forever.
341+
await engine.update(10)
342+
expect(count).toBe(10)
343+
})
344+
345+
it('a re-arming setTimeout catches up at the requested rate, like setInterval', async () => {
346+
const engine = Engine()
347+
const timers = createTimers(engine)
348+
let rearmCount = 0
349+
let intervalCount = 0
350+
351+
const rearm = () => {
352+
rearmCount++
353+
timers.setTimeout(rearm, 250)
354+
}
355+
timers.setTimeout(rearm, 250)
356+
timers.setInterval(() => intervalCount++, 250)
357+
358+
// Uneven frames (100ms) vs a 250ms period exercises the residual carry; a
359+
// re-arming setTimeout should stay in lock-step with setInterval.
360+
for (let i = 0; i < 30; i++) {
361+
await engine.update(0.1)
362+
expect(rearmCount).toBe(intervalCount)
363+
}
364+
})
365+
366+
it('a finite chain of zero-delay timers runs within the same frame', async () => {
367+
const engine = Engine()
368+
const timers = createTimers(engine)
369+
const order: number[] = []
370+
371+
// A common pattern: each callback schedules the next step with a 0ms delay,
372+
// expecting them to run promptly in sequence within the frame (not one per
373+
// frame). A finite chain of distinct timers terminates the same frame.
374+
timers.setTimeout(() => {
375+
order.push(1)
376+
timers.setTimeout(() => {
377+
order.push(2)
378+
timers.setTimeout(() => order.push(3), 0)
379+
}, 0)
380+
}, 0)
381+
382+
await engine.update(0.016)
383+
expect(order).toEqual([1, 2, 3])
384+
})
385+
386+
it('a top-level setTimeout(0) still fires on the next frame', async () => {
387+
const engine = Engine()
388+
const timers = createTimers(engine)
389+
let called = false
390+
timers.setTimeout(() => {
391+
called = true
392+
}, 0)
393+
394+
expect(called).toBe(false)
395+
await engine.update(0.016)
396+
expect(called).toBe(true)
397+
})
398+
323399
it('should handle interval that clears itself', async () => {
324400
const engine = Engine()
325401
const timers = createTimers(engine)

test/snapshots/development-bundles/static-scene.test.ts.crdt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SCENE_COMPILED_JS_SIZE_PROD=564k bytes
1+
SCENE_COMPILED_JS_SIZE_PROD=564.1k bytes
22
THE BUNDLE HAS SOURCEMAPS
33
(start empty vm 0.21.0-3680274614.commit-1808aa1)
44
OPCODES ~= 0k
@@ -12,7 +12,7 @@ EVAL test/snapshots/development-bundles/static-scene.test.js
1212
REQUIRE: ~system/EngineApi
1313
REQUIRE: ~system/Runtime
1414
OPCODES ~= 73k
15-
MALLOC_COUNT = 16289
15+
MALLOC_COUNT = 16301
1616
ALIVE_OBJS_DELTA ~= 3.24k
1717
CALL onStart()
1818
main.crdt: PUT_COMPONENT e=0x200 c=1 t=0 data={"position":{"x":5.880000114440918,"y":2.7916901111602783,"z":7.380000114440918},"rotation":{"x":0,"y":0,"z":0,"w":1},"scale":{"x":1,"y":1,"z":1},"parent":0}
@@ -57,4 +57,4 @@ CALL onUpdate(0.1)
5757
OPCODES ~= 5k
5858
MALLOC_COUNT = -5
5959
ALIVE_OBJS_DELTA ~= 0.00k
60-
MEMORY_USAGE_COUNT ~= 1425.94k bytes
60+
MEMORY_USAGE_COUNT ~= 1426.65k bytes

test/snapshots/development-bundles/testing-fw.test.ts.crdt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SCENE_COMPILED_JS_SIZE_PROD=564.5k bytes
1+
SCENE_COMPILED_JS_SIZE_PROD=564.6k bytes
22
THE BUNDLE HAS SOURCEMAPS
33
(start empty vm 0.21.0-3680274614.commit-1808aa1)
44
OPCODES ~= 0k
@@ -12,7 +12,7 @@ EVAL test/snapshots/development-bundles/testing-fw.test.js
1212
REQUIRE: ~system/EngineApi
1313
REQUIRE: ~system/Runtime
1414
OPCODES ~= 83k
15-
MALLOC_COUNT = 16841
15+
MALLOC_COUNT = 16853
1616
ALIVE_OBJS_DELTA ~= 3.40k
1717
CALL onStart()
1818
LOG: ["Adding one to position.y=0"]
@@ -63,4 +63,4 @@ CALL onUpdate(0.1)
6363
OPCODES ~= 6k
6464
MALLOC_COUNT = -53
6565
ALIVE_OBJS_DELTA ~= -0.01k
66-
MEMORY_USAGE_COUNT ~= 1431.70k bytes
66+
MEMORY_USAGE_COUNT ~= 1432.41k bytes

test/snapshots/development-bundles/two-way-crdt.test.ts.crdt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SCENE_COMPILED_JS_SIZE_PROD=564.5k bytes
1+
SCENE_COMPILED_JS_SIZE_PROD=564.7k bytes
22
THE BUNDLE HAS SOURCEMAPS
33
(start empty vm 0.21.0-3680274614.commit-1808aa1)
44
OPCODES ~= 0k
@@ -12,7 +12,7 @@ EVAL test/snapshots/development-bundles/two-way-crdt.test.js
1212
REQUIRE: ~system/EngineApi
1313
REQUIRE: ~system/Runtime
1414
OPCODES ~= 83k
15-
MALLOC_COUNT = 16841
15+
MALLOC_COUNT = 16853
1616
ALIVE_OBJS_DELTA ~= 3.40k
1717
CALL onStart()
1818
LOG: ["Adding one to position.y=0"]
@@ -63,4 +63,4 @@ CALL onUpdate(0.1)
6363
OPCODES ~= 6k
6464
MALLOC_COUNT = -53
6565
ALIVE_OBJS_DELTA ~= -0.01k
66-
MEMORY_USAGE_COUNT ~= 1431.70k bytes
66+
MEMORY_USAGE_COUNT ~= 1432.41k bytes

test/snapshots/production-bundles/append-value-crdt.ts.crdt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ EVAL test/snapshots/production-bundles/append-value-crdt.js
1010
REQUIRE: ~system/EngineApi
1111
REQUIRE: ~system/Runtime
1212
OPCODES ~= 85k
13-
MALLOC_COUNT = 15153
13+
MALLOC_COUNT = 15161
1414
ALIVE_OBJS_DELTA ~= 3.40k
1515
CALL onStart()
1616
Renderer: APPEND_VALUE e=0x200 c=1063 t=0 data={"button":0,"hit":{"position":{"x":1,"y":2,"z":3},"globalOrigin":{"x":1,"y":2,"z":3},"direction":{"x":1,"y":2,"z":3},"normalHit":{"x":1,"y":2,"z":3},"length":10,"meshName":"mesh","entityId":512},"state":1,"timestamp":1,"analog":5,"tickNumber":0}
@@ -56,4 +56,4 @@ CALL onUpdate(0.1)
5656
OPCODES ~= 15k
5757
MALLOC_COUNT = 31
5858
ALIVE_OBJS_DELTA ~= 0.01k
59-
MEMORY_USAGE_COUNT ~= 1068.72k bytes
59+
MEMORY_USAGE_COUNT ~= 1069.14k bytes

test/snapshots/production-bundles/billboard.ts.crdt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
SCENE_COMPILED_JS_SIZE_PROD=288.8k bytes
1+
SCENE_COMPILED_JS_SIZE_PROD=288.9k bytes
22
(start empty vm 0.21.0-3680274614.commit-1808aa1)
33
OPCODES ~= 0k
44
MALLOC_COUNT = 1005
@@ -10,7 +10,7 @@ EVAL test/snapshots/production-bundles/billboard.js
1010
REQUIRE: ~system/EngineApi
1111
REQUIRE: ~system/Runtime
1212
OPCODES ~= 87k
13-
MALLOC_COUNT = 17670
13+
MALLOC_COUNT = 17678
1414
ALIVE_OBJS_DELTA ~= 3.87k
1515
CALL onStart()
1616
OPCODES ~= 0k
@@ -78,4 +78,4 @@ CALL onUpdate(0.1)
7878
OPCODES ~= 12k
7979
MALLOC_COUNT = 0
8080
ALIVE_OBJS_DELTA ~= 0.00k
81-
MEMORY_USAGE_COUNT ~= 1254.70k bytes
81+
MEMORY_USAGE_COUNT ~= 1255.12k bytes

test/snapshots/production-bundles/cube-deleted.ts.crdt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ EVAL test/snapshots/production-bundles/cube-deleted.js
1010
REQUIRE: ~system/EngineApi
1111
REQUIRE: ~system/Runtime
1212
OPCODES ~= 76k
13-
MALLOC_COUNT = 14273
13+
MALLOC_COUNT = 14281
1414
ALIVE_OBJS_DELTA ~= 3.17k
1515
CALL onStart()
1616
OPCODES ~= 0k
@@ -43,4 +43,4 @@ CALL onUpdate(0.1)
4343
OPCODES ~= 6k
4444
MALLOC_COUNT = 1
4545
ALIVE_OBJS_DELTA ~= 0.00k
46-
MEMORY_USAGE_COUNT ~= 1031.00k bytes
46+
MEMORY_USAGE_COUNT ~= 1031.41k bytes

test/snapshots/production-bundles/cube.ts.crdt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ EVAL test/snapshots/production-bundles/cube.js
1010
REQUIRE: ~system/EngineApi
1111
REQUIRE: ~system/Runtime
1212
OPCODES ~= 75k
13-
MALLOC_COUNT = 14246
13+
MALLOC_COUNT = 14254
1414
ALIVE_OBJS_DELTA ~= 3.16k
1515
CALL onStart()
1616
OPCODES ~= 0k
@@ -33,4 +33,4 @@ CALL onUpdate(0.1)
3333
OPCODES ~= 3k
3434
MALLOC_COUNT = 0
3535
ALIVE_OBJS_DELTA ~= 0.00k
36-
MEMORY_USAGE_COUNT ~= 1020.75k bytes
36+
MEMORY_USAGE_COUNT ~= 1021.17k bytes

test/snapshots/production-bundles/cubes.ts.crdt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ EVAL test/snapshots/production-bundles/cubes.js
1010
REQUIRE: ~system/EngineApi
1111
REQUIRE: ~system/Runtime
1212
OPCODES ~= 127k
13-
MALLOC_COUNT = 21007
13+
MALLOC_COUNT = 21015
1414
ALIVE_OBJS_DELTA ~= 5.16k
1515
CALL onStart()
1616
OPCODES ~= 0k
@@ -1653,4 +1653,4 @@ CALL onUpdate(0.1)
16531653
OPCODES ~= 725k
16541654
MALLOC_COUNT = 0
16551655
ALIVE_OBJS_DELTA ~= 0.00k
1656-
MEMORY_USAGE_COUNT ~= 1479.18k bytes
1656+
MEMORY_USAGE_COUNT ~= 1479.60k bytes

0 commit comments

Comments
 (0)