Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions src/sim/engine.instances.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
14 changes: 13 additions & 1 deletion src/sim/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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 {
Expand Down
Loading