feat(chipbus): Phase 2 - synchronous settle kernel (settle-before-read)

Fixes root cause B: a CPU bus cycle drives address+strobe then reads the
data bus in the same tickTimers call, so the memory chip must react before
the read. Phase 0/1 applied each net change by firing PinManager listeners
immediately, which recurses one JS frame per hop - deep glue chains are
deep recursion and a combinational loop overflows the stack.

- busKernel.ts: a delta-cycle settle loop. A net change is recorded in a
  pending set, not applied recursively; settle() drains it in batches
  (deltas), applying each and letting the driven chips re-dirty the next,
  until a fixed point or DELTA_CAP trips (oscillation -> warn, not hang).
  Two-phase: a drive lands in pending and is applied on the next delta, so
  a chip evaluating mid-settle reads last-stable nets. The first drive of a
  cycle settles synchronously before returning to the chip's C code, so the
  in-cycle vx_pin_read sees settled data.
- busNets: publishes resolved levels through the kernel instead of calling
  triggerPinChange directly.

Tests (chipbus-buskernel): multi-hop chain settles; settle-before-read; a
5000-hop chain settles without stack overflow; a ring oscillator trips the
cap and warns instead of hanging. The two-real-chip integration still
exchanges 0xA5 through the kernel. Full suite 2079 pass flag-off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Montero Crespo 2026-06-05 02:36:54 -03:00
parent 23516247a6
commit 3137c40a90
3 changed files with 181 additions and 3 deletions

View File

@ -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);
});
});

View File

@ -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<number, boolean>();
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;
}

View File

@ -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();
}