diff --git a/src/sim/engine.instances.test.ts b/src/sim/engine.instances.test.ts new file mode 100644 index 0000000..484d616 --- /dev/null +++ b/src/sim/engine.instances.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; +import { Engine } from './engine'; +import { makeNode } from './presets'; +import type { NodeStats, Topology } from './types'; + +/* + * `instances` reaches the engine from places that are not the inspector: a + * shared link, a `.breakscale` file and a restored session all carry it + * through, and `isTopology` checks nine core numbers and not this one. The + * engine writes one array element per instance on every snapshot, so what it + * does with a number the editor could never produce is a property of the + * engine rather than of the form that fed it. + */ + +function topology(instances: unknown): Topology { + const client = { ...makeNode('client', 0, 0), id: 'client' }; + client.config = { ...client.config, rps: 100 }; + const target = { ...makeNode('service', 200, 0), id: 'target' }; + target.config = { ...target.config, instances } as typeof target.config; + return { + nodes: [client, target], + edges: [{ id: 'client->target', from: 'client', to: 'target', weight: 1 }], + }; +} + +function unitsFor(instances: unknown): number { + const engine = new Engine(topology(instances), 7); + for (let i = 0; i < 30; i += 1) engine.advance(1000 / 60); + const stats = engine.snapshot().nodes['target'] as NodeStats | undefined; + return stats?.instances ?? -1; +} + +describe('the instance count the engine will act on', () => { + it('is one when the design carries no number at all', () => { + expect(unitsFor(undefined)).toBe(1); + }); + + it('is one for a value that is not a number', () => { + // Math.max(1, Math.floor(NaN)) is NaN, and `units.length = NaN` throws. + expect(unitsFor(Number.NaN)).toBe(1); + expect(unitsFor(Number.POSITIVE_INFINITY)).toBe(1); + }); + + it('is one for a count below one', () => { + expect(unitsFor(0)).toBe(1); + expect(unitsFor(-4)).toBe(1); + }); + + it('takes the whole machines out of a fractional count', () => { + expect(unitsFor(2.7)).toBe(2); + }); + + it('stops at the ceiling the inspector offers, rather than allocating', () => { + // A design asking for a billion machines used to allocate a billion array + // elements on the first snapshot, which takes the tab with it. + const started = Date.now(); + expect(unitsFor(1e9)).toBe(512); + expect(Date.now() - started).toBeLessThan(2000); + }); +}); diff --git a/src/sim/engine.ts b/src/sim/engine.ts index 4d99643..56109bd 100644 --- a/src/sim/engine.ts +++ b/src/sim/engine.ts @@ -31,6 +31,15 @@ const MAX_DELTA_MS = 100; const MAX_EVENTS_PER_ADVANCE = 60000; /** Hop-depth ceiling; deeper resolves as FailureReason 'depth'. */ const MAX_HOP_DEPTH = 32; +/** + * Instance-count ceiling, matching the inspector's own slider. + * + * The engine writes one array element per instance on every snapshot, so an + * unbounded count is an unbounded allocation. A design does not only come from + * the editor: a shared link, a `.breakscale` file and a restored session all + * carry `instances` straight through, and `isTopology` does not police it. + */ +const MAX_INSTANCES = 512; /** Trailing window for latency percentiles. */ const LATENCY_WINDOW_MS = 5000; /** Capacity of each latency ring buffer. */ @@ -2202,7 +2211,10 @@ export class Engine implements BehaviourCtx { */ effectiveInstances(state: NodeStateLike): number { const raw = state.config.instances; - return raw === undefined ? 1 : Math.max(1, Math.floor(raw)); + // `Math.max(1, Math.floor(NaN))` is NaN, so the ">= 1" above was a promise + // this could not keep, and `units.length = NaN` throws where it is read. + if (raw === undefined || !Number.isFinite(raw)) return 1; + return Math.min(MAX_INSTANCES, Math.max(1, Math.floor(raw))); } countHit(state: NodeStateLike): void {