diff --git a/frontend/src/__tests__/chipbus-buskernel.test.ts b/frontend/src/__tests__/chipbus-buskernel.test.ts new file mode 100644 index 00000000..d9e2b658 --- /dev/null +++ b/frontend/src/__tests__/chipbus-buskernel.test.ts @@ -0,0 +1,86 @@ +/** + * Phase 2 — synchronous settle kernel (project/multichip-bus/). Drives the + * delta-cycle settle loop with a real PinManager and listeners standing in for + * chips (a chip = a watcher that, on its input net, drives an output net — the + * same shape busNets+ChipRuntime produce). Covers: multi-hop settle to a fixed + * point, settle-before-read, no deep recursion on long chains, and the + * oscillation cap. + */ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { PinManager } from '../simulation/PinManager'; +import { publishNetLevel, resetBusKernel } from '../simulation/customChips/busKernel'; + +describe('busKernel — delta-cycle settle', () => { + let pm: PinManager; + beforeEach(() => { + resetBusKernel(); + pm = new PinManager(); + }); + afterEach(() => { + resetBusKernel(); + vi.restoreAllMocks(); + }); + + it('settles a multi-hop combinational chain to its fixed point', () => { + const A = 1000; + const B = 1001; + const C = 1002; + // "chip" 1: B follows A. "chip" 2: C = NOT B. + pm.onPinChange(A, (_p, v) => publishNetLevel(pm, B, v)); + pm.onPinChange(B, (_p, v) => publishNetLevel(pm, C, !v)); + + publishNetLevel(pm, A, true); + + expect(pm.getPinState(A)).toBe(true); + expect(pm.getPinState(B)).toBe(true); + expect(pm.getPinState(C)).toBe(false); + }); + + it('settle-before-read: a driven net is settled by the time the publish returns', () => { + const ADDR = 2000; + const DATA = 2001; + // "memory": DATA mirrors ADDR (combinational). Models a ROM driving the data + // bus in reaction to the address/strobe within the same bus cycle. + pm.onPinChange(ADDR, (_p, v) => publishNetLevel(pm, DATA, v)); + + publishNetLevel(pm, ADDR, true); + // A synchronous in-cycle read here (as a CPU chip would do) sees settled data. + expect(pm.getPinState(DATA)).toBe(true); + }); + + it('handles a very long chain without recursing (no stack overflow)', () => { + const N = 5000; + for (let i = 0; i < N; i++) { + const from = 3000 + i; + const to = 3000 + i + 1; + pm.onPinChange(from, (_p, v) => publishNetLevel(pm, to, v)); + } + publishNetLevel(pm, 3000, true); + expect(pm.getPinState(3000 + N)).toBe(true); // value walked the whole chain + }); + + it('caps a zero-delay oscillation and warns instead of hanging', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const OSC = 4000; + // A ring oscillator: every settle flips the net, which re-triggers forever. + pm.onPinChange(OSC, (_p, v) => publishNetLevel(pm, OSC, !v)); + + publishNetLevel(pm, OSC, true); // must RETURN (cap trips), not hang + + expect(warn).toHaveBeenCalledTimes(1); + expect(String(warn.mock.calls[0][0])).toContain('did not converge'); + }); + + it('coalesces multiple drives of one net within a delta to the latest', () => { + const N = 5000; + let fires = 0; + pm.onPinChange(N, () => { + fires++; + }); + // Same net published twice before any settle delta applies it: the watcher + // should see exactly one (latest) value, not two. + publishNetLevel(pm, N, true); + expect(pm.getPinState(N)).toBe(true); + expect(fires).toBe(1); + }); +}); diff --git a/frontend/src/simulation/customChips/busKernel.ts b/frontend/src/simulation/customChips/busKernel.ts new file mode 100644 index 00000000..dc9167a2 --- /dev/null +++ b/frontend/src/simulation/customChips/busKernel.ts @@ -0,0 +1,88 @@ +/** + * Synchronous settle kernel for chip-to-chip bus nets — Phase 2 of the + * multi-chip digital bus track (project/multichip-bus/). + * + * THE PROBLEM (root cause B, 00-problem-analysis.md section 3): a CPU bus cycle + * runs synchronously inside one tickTimers call — drive address + strobe, then + * `vx_pin_read` the data bus in the SAME C call. For that read to return the + * byte the memory chip drove, the memory chip must have reacted BEFORE the read. + * Phase 0/1 gave shared keys + driver resolution, but applying each net change + * by firing PinManager listeners immediately recurses: chip A's write -> chip B's + * watch -> chip B's write -> ... One JS frame per hop, so a deep glue chain is + * deep recursion and a combinational loop (a ring oscillator) overflows the stack + * and kills the tab. + * + * THE FIX (01-how-proteus-works.md section 2.4 + 4.2, Option B): a delta-cycle + * settle loop. A net change is recorded, not applied recursively; `settle()` + * drains the pending set in batches (deltas), applying each batch and letting the + * driven chips re-dirty the next, until no net changes (fixed point) or the + * iteration cap trips. Two-phase: a drive lands in `pending` and is APPLIED to + * the PinManager on the next delta, so a chip evaluating mid-settle reads the + * last-stable net values, never a half-updated net. Because the first drive of a + * cycle settles to its fixed point synchronously before control returns to the + * chip's C code, the subsequent in-cycle `vx_pin_read` sees settled data — + * settle-before-read without a new chip-side API. + */ + +interface PinManagerLike { + triggerPinChange(pin: number, state: boolean, source?: 'mcu' | 'external'): void; +} + +// Pending net level changes for the current settle pass: netKey -> resolved +// boolean. A Map coalesces multiple drives of one net within a delta to the +// latest value (glitch suppression within zero time). +const pending = new Map(); +let settling = false; +let pm: PinManagerLike | null = null; + +// A combinational loop with no stable state (e.g. a zero-delay inverter ring) +// would settle forever; cap the delta count and report it instead of hanging. +const DELTA_CAP = 10000; + +/** + * Record that a bus net resolved to `level` and ensure the fabric settles. + * Called by busNets after every re-resolution. If a settle is already in + * progress (we are inside a watcher that drove another net), just enqueue — + * the running loop will apply it on the next delta (this is what turns the + * recursive cascade into a bounded iteration). + */ +export function publishNetLevel(pinManager: PinManagerLike, netKey: number, level: boolean): void { + pm = pinManager; + pending.set(netKey, level); + if (!settling) settle(); +} + +function settle(): void { + settling = true; + let deltas = 0; + try { + while (pending.size > 0) { + if (++deltas > DELTA_CAP) { + console.warn( + `[chipbus] settle did not converge after ${DELTA_CAP} delta cycles — ` + + `combinational loop / oscillation? Bailing to keep the UI responsive.`, + ); + pending.clear(); + break; + } + // PHASE A->B: snapshot this delta's changes and clear pending, THEN apply + // them. Applying fires watchers whose drives re-resolve nets and enqueue + // into the now-empty `pending` for the NEXT delta — so no chip observes a + // net that is mid-update within its own evaluation. + const batch = [...pending.entries()]; + pending.clear(); + for (const [netKey, level] of batch) { + pm!.triggerPinChange(netKey, level); + } + } + } finally { + settling = false; + } +} + +/** Test seam: clear all settle state. */ +export function resetBusKernel(): void { + pending.clear(); + settling = false; + pm = null; +} diff --git a/frontend/src/simulation/customChips/busNets.ts b/frontend/src/simulation/customChips/busNets.ts index d95b1629..00bc5981 100644 --- a/frontend/src/simulation/customChips/busNets.ts +++ b/frontend/src/simulation/customChips/busNets.ts @@ -15,6 +15,7 @@ * synthetic pins keep the legacy direct PinManager path untouched. */ import { resolveNet, resolvedToBool, type Drive } from './busLogic'; +import { publishNetLevel, resetBusKernel } from './busKernel'; interface PinManagerLike { triggerPinChange(pin: number, state: boolean, source?: 'mcu' | 'external'): void; @@ -41,8 +42,10 @@ function recompute(pm: PinManagerLike, netKey: number): void { inContention.delete(netKey); } - // PinManager is boolean; push the projected level (only a driven 1 is high). - pm.triggerPinChange(netKey, resolvedToBool(resolved)); + // PinManager is boolean; push the projected level (only a driven 1 is high) + // through the settle kernel so the change propagates as a bounded delta-cycle + // pass rather than a recursive cascade. + publishNetLevel(pm, netKey, resolvedToBool(resolved)); } /** Set (or replace) one chip pin's contribution to a bus net and re-resolve. */ @@ -76,8 +79,9 @@ export function clearBusDriversForChip(pm: PinManagerLike, componentId: string): } } -/** Test seam: wipe all bus-net driver state. */ +/** Test seam: wipe all bus-net driver state (and the settle kernel). */ export function resetBusNets(): void { nets.clear(); inContention.clear(); + resetBusKernel(); }