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:
parent
23516247a6
commit
3137c40a90
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue