diff --git a/archify.zip b/archify.zip index 59b525489..7b14ffcb5 100644 Binary files a/archify.zip and b/archify.zip differ diff --git a/archify/renderers/waterfall/render-waterfall.mjs b/archify/renderers/waterfall/render-waterfall.mjs index 54d334d2e..ad16256d7 100644 --- a/archify/renderers/waterfall/render-waterfall.mjs +++ b/archify/renderers/waterfall/render-waterfall.mjs @@ -66,6 +66,9 @@ for (const [index, span] of raw.entries()) { const hasDuration = Number.isFinite(span.duration); const timing = (message, fixes) => fail('waterfall/invalid-timing', `Span "${span.id}" ${message}`, { path: `/spans/${index}`, nodeId: span.id }, { start: span.start, end: span.end ?? null, duration: span.duration ?? null, status: span.status ?? 'ok' }, fixes); + if (hasDuration && !Number.isFinite(span.start + span.duration)) { + timing('has a derived end outside the finite numeric range.', ['Rescale the recorded timing and unit to a representable range.']); + } if (hasEnd && span.end < span.start) timing(`ends (${span.end}) before it starts (${span.start}).`, ['Correct start or end from the recorded timing.']); if (hasEnd && hasDuration && Math.abs(span.end - span.start - span.duration) > 1e-9) { timing(`states end ${span.end} and duration ${span.duration}, which disagree with start ${span.start}.`, ['Keep only one of end or duration.']); @@ -126,7 +129,7 @@ const rows = []; }(spans.filter((span) => span.parent === undefined), 0)); function number(value) { - const rounded = Math.round(value * 100) / 100; + const rounded = Math.abs(value) > Number.MAX_VALUE / 100 ? value : Math.round(value * 100) / 100; return rounded.toLocaleString('en-US', { maximumFractionDigits: 2 }); } const formatDuration = (value) => `${number(value)} ${unitLabel}`; @@ -157,6 +160,11 @@ const labelW = Math.ceil(Math.max(140, ...rows.map(nameWidth))); const axisX0 = layout.margin + labelW + layout.columnGap; const axisX1 = axisX0 + layout.axisWidth; const scale = wall > 0 ? layout.axisWidth / wall : 0; +if (!Number.isFinite(scale)) { + fail('waterfall/invalid-timing', 'The recorded range cannot form a finite axis scale.', + { path: '/spans' }, { start: t0, end: t1, wall }, ['Rescale the recorded timing and unit to a representable range.']); + throwDiagnosticProblems('Waterfall validation failed', problems, { diagnostics: details }); +} const xOf = (t) => Math.round((axisX0 + (t - t0) * scale) * 100) / 100; const rowsTop = layout.top + layout.axisH; @@ -171,7 +179,14 @@ const tickStep = (() => { } })(); const ticks = []; -for (let t = Math.ceil(t0 / tickStep) * tickStep; t <= t1 + 1e-9; t += tickStep) ticks.push({ t, x: xOf(t) }); +const firstTick = Math.ceil(t0 / tickStep) * tickStep; +const tickCount = Math.max(0, Math.floor((t1 - firstTick) / tickStep + 1e-9) + 1); +for (let index = 0; index < tickCount; index += 1) { + const t = firstTick + index * tickStep; + // Large timestamps can round adjacent ticks to the same double. Index-based + // iteration remains bounded even when adding tickStep cannot advance t. + if (ticks.at(-1)?.t !== t) ticks.push({ t, x: xOf(t) }); +} const LABEL_GAP = 7; const parentIds = new Set(rows.map((row) => row.parent).filter((id) => id !== undefined)); diff --git a/test/waterfall-rendering.test.mjs b/test/waterfall-rendering.test.mjs index d7c3340f0..10cb376a5 100644 --- a/test/waterfall-rendering.test.mjs +++ b/test/waterfall-rendering.test.mjs @@ -19,7 +19,7 @@ function run(diagram, extra = []) { const input = path.join(directory, 'candidate.waterfall.json'); const output = path.join(directory, 'candidate.html'); fs.writeFileSync(input, JSON.stringify(diagram)); - return { ...spawnSync(process.execPath, [renderer, input, output, ...extra], { cwd: directory, encoding: 'utf8' }), output }; + return { ...spawnSync(process.execPath, [renderer, input, output, ...extra], { cwd: directory, encoding: 'utf8', timeout: 10000 }), input, output }; } function layoutOf(diagram) { const result = run(diagram, ['--layout-json']); @@ -58,6 +58,48 @@ test('waterfall: bar geometry is exactly the recorded timing on one axis', () => assert.deepEqual(report.rows.map((row) => row.id), ['request', 'auth', 'inventory', 'discount', 'payment', 'save']); }); +test('waterfall: large timestamps terminate when tick increments are below floating-point resolution', () => { + const diagram = clone(small); + diagram.spans = [{ id: 'request', name: 'Request', start: 1e16, end: 1e16 + 2 }]; + const result = run(diagram, ['--layout-json']); + assert.equal(result.status, 0, result.stderr || result.error?.message); + const report = JSON.parse(result.stdout); + assert.equal(report.wall, 2); + assert.equal(report.rows[0].label, '2 ms'); + assert.ok(Number.isFinite(report.rows[0].x0)); + assert.ok(Number.isFinite(report.rows[0].x1)); + assert.ok(report.rows[0].x1 > report.rows[0].x0); +}); + +test('waterfall: unrepresentable derived timing is rejected without writing invalid geometry', () => { + for (const span of [ + { id: 'request', name: 'Request', start: 1e308, duration: 1e308 }, + { id: 'request', name: 'Request', start: 0, duration: Number.MIN_VALUE }, + ]) { + const diagram = clone(small); + diagram.spans = [span]; + const result = run(diagram); + assert.equal(result.status, 1, result.stderr || result.error?.message); + assert.match(result.stderr, /finite/); + assert.equal(fs.existsSync(result.output), false); + const cli = spawnSync(process.execPath, [path.join(skillRoot, 'bin/archify.mjs'), 'validate', 'waterfall', result.input, '--json'], { encoding: 'utf8', timeout: 10000 }); + assert.equal(cli.status, 1, cli.stderr || cli.stdout); + const receipt = JSON.parse(cli.stdout); + const diagnostic = receipt.diagnostics.find(({ code }) => code === 'waterfall/invalid-timing'); + assert.ok(diagnostic, cli.stdout); + assert.ok(diagnostic.supportedFixes.length > 0, cli.stdout); + } +}); + +test('waterfall: finite very large timing labels do not overflow while formatting', () => { + const diagram = clone(small); + diagram.spans = [{ id: 'request', name: 'Request', start: 1e307, end: 1.1e307 }]; + const result = run(diagram); + assert.equal(result.status, 0, result.stderr || result.error?.message); + const html = fs.readFileSync(result.output, 'utf8'); + assert.doesNotMatch(html, /(?:NaN|Infinity|∞)/); +}); + test('waterfall: percentages name their denominator and are never summed into the parent', () => { const html = fs.readFileSync(run(small).output, 'utf8'); assert.match(html, /data-node-id="payment"[^>]*aria-label="[^"]*400–1,050 ms · 650 ms · 54% of 1,200 ms wall-clock/);