fix(perf): un-freeze the editor during fast-toggling simulations (ESP32 clock)
Running a multiplexed 4-digit 7-segment clock on ESP32/QEMU froze the browser for minutes after Run — evaluate probes waited 40-90 s, and before the first fixes the sim WebSocket eventually died (code 1006) with the page never recovering. CPU-profiled on staging; four compounding per-GPIO-edge costs, in profile order: updateComponentState minted a new components array per edge ------------------------------------------------------------ The store setter rebuilt `components` (and one properties object) on EVERY edge even when the state didn't change. The breadboard is direct-wired to 13 board pins, so segment toggles produced thousands of store sets per second; every subscriber re-rendered each time, and the canvas subscription effect (deps: [components, ...]) re-subscribed all pin listeners in a loop. Now a no-op guard returns prevState unchanged, and breadboards are treated as self-managed (they have no visual on/off state to echo). CompilationConsole re-rendered every log line per editor render ---------------------------------------------------------------- The post-compile console holds hundreds of lines; each render called Date.toLocaleTimeString per line (~0.2 ms each — it builds a fresh Intl formatter every call). Profile: 162 s of self time in LogLine over a 337 s window, in ~150 ms tasks. LogLine is now memoized (entries are immutable), timestamps go through one shared Intl.DateTimeFormat, and the console itself is React.memo'd against parent re-renders. Per-edge full SPICE re-solves ------------------------------ PinManager requested a FULL netlist rebuild+solve on every 'mcu' edge. Now only the edge that newly classifies a pin as MCU-output triggers the rebuild (that's what emits the pin's V-source); steady-state updates flow through connectMcuEdgesToService's per-pin coalesced alterSource path. The start.ts resolve hook is trailing-throttled (33 ms) for the other per-edge callers (RP2040, custom chips), the service's pending-edge queue drains on a 33 ms gap timer instead of replaying back-to-back, and new edges arriving inside the gap queue instead of soloing a solve. STM32 / Pi reverse pin-name mappings added to connectMcuEdgesToService so those boards keep fine-grained updates now that the full-tick storm is gone (PA0/PC13-style and GPIO-style names never matched before). wokwi-7segment re-rendered per segment write --------------------------------------------- element.values now flushes at most every 8 ms per display (trailing write guaranteed), instead of re-rendering the 32-shape SVG per edge. Also: CLN (colon) pin support for 7-segment clock faces — wired CLN now drives colon/colonValue in both the attachEvents path and the QEMU onPinStateChange path; it was silently ignored, so clock colons never lit. Verified on staging with the failing project: main-thread probes drop from 40-90 s waits (324 long tasks, 52.6 s blocked in 150 s) to 5-11 ms (2 long tasks, 179 ms), display shows 12:00 with the colon blinking at 1 Hz from the first seconds after Run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
a000fba154
commit
701042fa22
|
|
@ -396,6 +396,63 @@ describe('handleMcuEdge (Phase 1c D1)', () => {
|
|||
expect(fake.calls.alterSource).toEqual([['V_uno_9', 5]]);
|
||||
});
|
||||
|
||||
it('bounds solve rate under a sustained edge storm (multiplexed display)', async () => {
|
||||
// Regression: a 4-digit 7-segment clock over QEMU keeps ~13 pins hot
|
||||
// (thousands of GPIO edges/second). Replaying queued edges IMMEDIATELY
|
||||
// after each solve ran the solver at 100% duty with no idle gap — the
|
||||
// main thread starved for minutes until the sim WebSocket dropped.
|
||||
// The drain timer must space solves out: over a ~200 ms storm window
|
||||
// the solve count stays bounded (~1 per 33 ms gap), nowhere near the
|
||||
// one-solve-per-edge fire hose.
|
||||
const fake = new FakeSolverAdapter({
|
||||
vectors: { 'v(vcc_rail)': 5 },
|
||||
solveDelayMs: 2,
|
||||
});
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
const sim = makeSimStore({
|
||||
components: [
|
||||
{ id: 'rb', metadataId: 'resistor', properties: { value: '1k' } },
|
||||
],
|
||||
wires: [
|
||||
{
|
||||
id: 'w1',
|
||||
start: { componentId: 'uno', pinName: '9' },
|
||||
end: { componentId: 'rb', pinName: '1' },
|
||||
},
|
||||
{
|
||||
id: 'w2',
|
||||
start: { componentId: 'rb', pinName: '2' },
|
||||
end: { componentId: 'uno', pinName: 'GND' },
|
||||
},
|
||||
],
|
||||
boards: [{ id: 'uno', boardKind: 'arduino-uno' }],
|
||||
});
|
||||
const elec = makeElectricalStore();
|
||||
const service = new CircuitSimulationService(
|
||||
sim.port,
|
||||
elec.port,
|
||||
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
|
||||
{ collectBoardPinStates: () => ({ '9': { type: 'digital', v: 0 } }) },
|
||||
);
|
||||
startTracked(service);
|
||||
await new Promise((r) => setTimeout(r, 30)); // let the initial solve land
|
||||
|
||||
// Storm: toggle the pin every 2 ms for 200 ms (~100 edges).
|
||||
let state = false;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
state = !state;
|
||||
void service.handleMcuEdge('uno', '9', state, 5);
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 80)); // trailing drain
|
||||
|
||||
// 100 edges in ~200 ms with a 33 ms drain gap → ~7 solves + initial.
|
||||
// Generous ceiling; the pre-fix behaviour was 1 solve per edge (100+).
|
||||
const total = fake.calls.solve.length;
|
||||
expect(total).toBeGreaterThanOrEqual(2); // it DID keep solving
|
||||
expect(total).toBeLessThanOrEqual(30);
|
||||
});
|
||||
|
||||
it('kicks a full tick when no circuit has been loaded yet', async () => {
|
||||
const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 } });
|
||||
__setSchedulerSolverFactoryForTests(() => fake);
|
||||
|
|
|
|||
|
|
@ -556,6 +556,33 @@ describe('7segment — attachEvents', () => {
|
|||
cleanup();
|
||||
expect(unsubMock).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it('CLN pin drives colon + colonValue (attachEvents and onPinStateChange)', () => {
|
||||
const logic = PartSimulationRegistry.get('7segment')!;
|
||||
|
||||
// attachEvents path (a clock face wired to a local-sim board).
|
||||
const el = makeElement({ values: new Array(8).fill(0), colon: false, colonValue: false });
|
||||
const sim = makeSimulator();
|
||||
let clnCallback!: (pin: number, state: boolean) => void;
|
||||
sim.pinManager.onPinChange.mockImplementation(
|
||||
(pin: number, cb: (pin: number, state: boolean) => void) => {
|
||||
if (pin === 10) clnCallback = cb;
|
||||
return () => {};
|
||||
},
|
||||
);
|
||||
logic.attachEvents!(el, sim as any, pinMap({ A: 2, CLN: 10 }));
|
||||
clnCallback(10, true);
|
||||
expect((el as any).colon).toBe(true);
|
||||
expect((el as any).colonValue).toBe(true);
|
||||
clnCallback(10, false);
|
||||
expect((el as any).colonValue).toBe(false);
|
||||
|
||||
// onPinStateChange path (QEMU-backed boards dispatch by pin name).
|
||||
const el2 = makeElement({ values: new Array(8).fill(0), colon: false, colonValue: false });
|
||||
logic.onPinStateChange!('CLN', true, el2);
|
||||
expect((el2 as any).colon).toBe(true);
|
||||
expect((el2 as any).colonValue).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── RGB LED ──────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -44,7 +44,10 @@ interface CompilationConsoleProps {
|
|||
onClear: () => void;
|
||||
}
|
||||
|
||||
export const CompilationConsole: React.FC<CompilationConsoleProps> = ({
|
||||
// Memoized: the console lives inside EditorPage, which re-renders on every
|
||||
// simulator-store change (component drags, pin updates, etc.). Without memo,
|
||||
// each of those re-rendered every log line even though `logs` was untouched.
|
||||
export const CompilationConsole: React.FC<CompilationConsoleProps> = React.memo(({
|
||||
isOpen,
|
||||
onClose,
|
||||
logs,
|
||||
|
|
@ -216,24 +219,32 @@ export const CompilationConsole: React.FC<CompilationConsoleProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
CompilationConsole.displayName = 'CompilationConsole';
|
||||
|
||||
const LogLine: React.FC<{ log: CompilationLog }> = ({ log }) => (
|
||||
// Shared formatter: `Date.toLocaleTimeString(...)` builds a fresh Intl
|
||||
// formatter on every call (~0.1-0.3 ms). With hundreds of log lines and the
|
||||
// console re-rendering on every editor state change, that alone produced
|
||||
// ~150 ms render tasks — a major slice of the frozen-browser-after-Run bug.
|
||||
const TIME_FORMAT = new Intl.DateTimeFormat('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
// Memoized: a log entry is immutable once appended, so a line never needs to
|
||||
// re-render — appends only mount NEW lines instead of re-rendering all.
|
||||
const LogLine = React.memo<{ log: CompilationLog }>(({ log }) => (
|
||||
<div style={styles.logLine}>
|
||||
<span style={styles.timestamp}>
|
||||
{log.timestamp.toLocaleTimeString('en-US', {
|
||||
hour12: false,
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})}
|
||||
</span>
|
||||
<span style={styles.timestamp}>{TIME_FORMAT.format(log.timestamp)}</span>
|
||||
<span style={{ ...styles.logMessage, color: logColor(log.type) }}>
|
||||
{log.type === 'core-install' && <span style={styles.coreTag}>CORE </span>}
|
||||
{log.message}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
));
|
||||
LogLine.displayName = 'LogLine';
|
||||
|
||||
function statusColor(status: 'error' | 'success' | 'running'): string {
|
||||
return status === 'error' ? '#ef5350' : status === 'success' ? '#66bb6a' : '#9aa0a6';
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { PinOverlay } from './PinOverlay';
|
|||
import { SeatedPinMarkers } from './SeatedPinMarkers';
|
||||
import { calculatePinPosition } from '../../utils/pinPositionCalculator';
|
||||
import { isBoardComponent, boardPinToNumber } from '../../utils/boardPinMapping';
|
||||
import { isBreadboard } from '../../utils/breadboardNets';
|
||||
import { autoWireColor, WIRE_KEY_COLORS, expandOrthogonalPoints } from '../../utils/wireUtils';
|
||||
import {
|
||||
isAutoVerticalPart,
|
||||
|
|
@ -1112,7 +1113,13 @@ export const SimulatorCanvas = ({ headerSlot }: SimulatorCanvasProps = {}) => {
|
|||
// current and future SPICE mapper immune to that feedback loop.
|
||||
const logic = PartSimulationRegistry.get(component.metadataId);
|
||||
const spiceOwned = isSpiceMapped(component.metadataId);
|
||||
const hasSelfManagedVisuals = !!(logic && logic.attachEvents) || spiceOwned;
|
||||
// Breadboards have no visual on/off state, but they ARE direct-wired to
|
||||
// many board pins (one wire per strip → GPIO). Writing properties.state
|
||||
// per edge minted a new components array thousands of times per second
|
||||
// on multiplexed sketches — pure churn that re-rendered the whole
|
||||
// editor. Treat them as self-managed: skip the generic state echo.
|
||||
const hasSelfManagedVisuals =
|
||||
!!(logic && logic.attachEvents) || spiceOwned || isBreadboard(component.metadataId);
|
||||
|
||||
// Generic GND check: for wire-connected output components that don't manage
|
||||
// their own state, require at least one GND wire before activating.
|
||||
|
|
|
|||
|
|
@ -146,9 +146,19 @@ export class PinManager {
|
|||
* Used by RP2040Simulator which has individual GPIO listeners instead of PORT registers.
|
||||
*/
|
||||
triggerPinChange(pin: number, state: boolean, source: 'mcu' | 'external' = 'external'): void {
|
||||
// A full-netlist re-solve is only needed when this edge RE-CLASSIFIES the
|
||||
// pin (first MCU write → the netlist must grow a V-source for it). Once
|
||||
// the pin is a known output, per-edge voltage updates flow through
|
||||
// connectMcuEdgesToService (per-pin coalesced alterSource — no rebuild).
|
||||
// Requesting a full tick on EVERY edge froze the browser on multiplexed
|
||||
// circuits: a 7-segment clock over QEMU emits thousands of GPIO edges per
|
||||
// second, and back-to-back rebuild+solve+publish cycles starved the main
|
||||
// thread until the sim WebSocket timed out.
|
||||
const newlyClassified = source === 'mcu' && !this.outputPins.has(pin);
|
||||
const current = this.pinStates.get(pin);
|
||||
if (current === state) {
|
||||
if (source === 'mcu') this.outputPins.add(pin);
|
||||
if (newlyClassified) requestElectricalResolve();
|
||||
return;
|
||||
}
|
||||
this.pinStates.set(pin, state);
|
||||
|
|
@ -157,16 +167,12 @@ export class PinManager {
|
|||
if (callbacks) {
|
||||
callbacks.forEach((cb) => cb(pin, state));
|
||||
}
|
||||
// An MCU output edge changes the circuit: request a SPICE re-solve so the
|
||||
// analog parts on this net (LED brightness, etc.) update. WS-backed boards
|
||||
// (ESP32 / STM32 / Raspberry Pi) reach the electrical sim ONLY through here
|
||||
// — previously they never triggered a re-solve, so a resistor-less LED
|
||||
// stayed at its first solved brightness until unrelated activity (e.g.
|
||||
// serial output) forced a solve. AVR / RP2040 already resolve at their own
|
||||
// toggle sites. Gated to 'mcu' so the solver's own input feedback
|
||||
// (triggerPinChange with the default 'external' source) can't create a
|
||||
// solve loop; the hook coalesces overlapping ticks so per-edge is cheap.
|
||||
if (source === 'mcu') requestElectricalResolve();
|
||||
// WS-backed boards (ESP32 / STM32 / Raspberry Pi) reach the electrical sim
|
||||
// ONLY through here; the first write per pin triggers the rebuild that
|
||||
// emits its V-source, after which connectMcuEdgesToService owns updates.
|
||||
// Gated to 'mcu' so the solver's own input feedback (source 'external')
|
||||
// can't create a solve loop.
|
||||
if (newlyClassified) requestElectricalResolve();
|
||||
}
|
||||
|
||||
/** Pins the MCU has actively driven this session. */
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ interface SevenSegState {
|
|||
segments: number[]; // length 8
|
||||
digitValues: number[][]; // [digit][seg]; flattened into element.values
|
||||
digitEnabled: boolean[]; // length = digits
|
||||
lastFlushMs: number; // wall-clock of the last element.values write
|
||||
flushTimer: ReturnType<typeof setTimeout> | null; // trailing write when throttled
|
||||
}
|
||||
|
||||
const sevenSegState = new WeakMap<HTMLElement, SevenSegState>();
|
||||
|
|
@ -99,19 +101,45 @@ function get7SegState(element: HTMLElement): SevenSegState {
|
|||
segments: [0, 0, 0, 0, 0, 0, 0, 0],
|
||||
digitValues: Array.from({ length: digits }, () => [0, 0, 0, 0, 0, 0, 0, 0]),
|
||||
digitEnabled: Array(digits).fill(false),
|
||||
lastFlushMs: 0,
|
||||
flushTimer: null,
|
||||
};
|
||||
sevenSegState.set(element, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function flush7SegValues(element: HTMLElement) {
|
||||
const s = get7SegState(element);
|
||||
/** Minimum gap between element.values writes. Each write re-renders the
|
||||
* wokwi SVG (lit template with up to 32 segment shapes); a multiplexed
|
||||
* clock over QEMU produces 1500-3000 segment edges per second, and writing
|
||||
* per edge saturated the main thread for minutes after Run. 8 ms (~125 Hz)
|
||||
* is far above both the display refresh a human can perceive and the 60 Hz
|
||||
* the canvas paints at; the trailing timer guarantees the final state is
|
||||
* never dropped. */
|
||||
const SEVEN_SEG_FLUSH_GAP_MS = 8;
|
||||
|
||||
function write7SegValues(element: HTMLElement, s: SevenSegState) {
|
||||
const flat: number[] = [];
|
||||
for (let d = 0; d < s.digits; d++) flat.push(...s.digitValues[d]);
|
||||
(element as unknown as { values: number[] }).values = flat;
|
||||
}
|
||||
|
||||
function flush7SegValues(element: HTMLElement) {
|
||||
const s = get7SegState(element);
|
||||
const now = Date.now();
|
||||
if (now - s.lastFlushMs >= SEVEN_SEG_FLUSH_GAP_MS) {
|
||||
s.lastFlushMs = now;
|
||||
write7SegValues(element, s);
|
||||
return;
|
||||
}
|
||||
if (s.flushTimer !== null) return; // trailing write already scheduled
|
||||
s.flushTimer = setTimeout(() => {
|
||||
s.flushTimer = null;
|
||||
s.lastFlushMs = Date.now();
|
||||
write7SegValues(element, s);
|
||||
}, SEVEN_SEG_FLUSH_GAP_MS - (now - s.lastFlushMs));
|
||||
}
|
||||
|
||||
function handle7SegSegment(element: HTMLElement, segIdx: number, state: boolean) {
|
||||
const s = get7SegState(element);
|
||||
s.segments[segIdx] = state ? 1 : 0;
|
||||
|
|
@ -396,6 +424,32 @@ PartSimulationRegistry.register('7segment', {
|
|||
}
|
||||
}
|
||||
|
||||
// CLN (colon, clock-style displays): drives the element's colonValue.
|
||||
// Also flip `colon` on so the two dots render at all — wokwi-7segment
|
||||
// hides them unless clock mode is enabled, and a wired CLN pin is the
|
||||
// clearest signal the user wants a clock face.
|
||||
{
|
||||
const setColon = (state: boolean) => {
|
||||
const el = element as unknown as { colon: boolean; colonValue: boolean };
|
||||
el.colon = true;
|
||||
el.colonValue = state;
|
||||
};
|
||||
if (useResolver) {
|
||||
const resolver = getPinResolver!('CLN');
|
||||
if (resolver) {
|
||||
setColon(resolver.getCurrentState() === 'HIGH');
|
||||
unsubscribers.push(resolver.onChange((state) => setColon(state === 'HIGH')));
|
||||
}
|
||||
} else {
|
||||
const arduinoPin = getArduinoPinHelper('CLN');
|
||||
if (arduinoPin !== null) {
|
||||
unsubscribers.push(
|
||||
pinManager.onPinChange(arduinoPin, (_: number, state: boolean) => setColon(state)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => unsubscribers.forEach((u) => u());
|
||||
},
|
||||
// Called by SimulatorCanvas for boards without a local simulator (e.g.
|
||||
|
|
@ -415,6 +469,12 @@ PartSimulationRegistry.register('7segment', {
|
|||
handle7SegDigit(element, 0, state);
|
||||
return;
|
||||
}
|
||||
if (upper === 'CLN') {
|
||||
const el = element as unknown as { colon: boolean; colonValue: boolean };
|
||||
el.colon = true;
|
||||
el.colonValue = state;
|
||||
return;
|
||||
}
|
||||
const dm = upper.match(/^DIG(\d+)$/);
|
||||
if (dm) {
|
||||
handle7SegDigit(element, parseInt(dm[1], 10) - 1, state);
|
||||
|
|
|
|||
|
|
@ -141,6 +141,35 @@ export class CircuitSimulationService {
|
|||
* keep re-scheduling solves against a disposed scheduler. */
|
||||
private stopped = false;
|
||||
|
||||
/** Trailing timer for draining `pendingMcuEdges`. Replaying queued
|
||||
* edges IMMEDIATELY after a solve creates a back-to-back solve loop
|
||||
* under sustained toggling (a multiplexed display keeps 8-13 pins
|
||||
* hot, so the queue never empties) — the solver runs at 100% duty
|
||||
* and the UI starves. One drain per gap keeps last-state-wins per
|
||||
* pin while bounding total solve rate. */
|
||||
private drainTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private static readonly EDGE_DRAIN_GAP_MS = 33;
|
||||
|
||||
/** Drain pendingMcuEdges after a short gap (coalescing: one timer). */
|
||||
private scheduleEdgeDrain(): void {
|
||||
if (this.stopped || this.drainTimer !== null) return;
|
||||
this.drainTimer = setTimeout(() => {
|
||||
this.drainTimer = null;
|
||||
if (this.stopped) return;
|
||||
const edges = Array.from(this.pendingMcuEdges.values());
|
||||
this.pendingMcuEdges.clear();
|
||||
const ctx = this.loadedContext;
|
||||
for (const edge of edges) {
|
||||
const expected = `v_${sanitizeSpiceId(edge.boardId)}_${sanitizeSpiceId(edge.pinName)}`.toLowerCase();
|
||||
const hasSource = ctx?.voltageSources.some(
|
||||
(vs) => vs.toLowerCase() === expected,
|
||||
);
|
||||
if (!hasSource) continue;
|
||||
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
|
||||
}
|
||||
}, CircuitSimulationService.EDGE_DRAIN_GAP_MS);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly simStore: SimulatorStorePort,
|
||||
private readonly electricalStore: ElectricalStorePort,
|
||||
|
|
@ -169,6 +198,10 @@ export class CircuitSimulationService {
|
|||
this.stopped = true;
|
||||
this.pending = false;
|
||||
this.pendingMcuEdges.clear();
|
||||
if (this.drainTimer !== null) {
|
||||
clearTimeout(this.drainTimer);
|
||||
this.drainTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Run one solve cycle, coalescing concurrent triggers. */
|
||||
|
|
@ -200,23 +233,11 @@ export class CircuitSimulationService {
|
|||
this.pending = false;
|
||||
void this.tick();
|
||||
} else if (this.pendingMcuEdges.size > 0) {
|
||||
const edges = Array.from(this.pendingMcuEdges.values());
|
||||
this.pendingMcuEdges.clear();
|
||||
const ctx = this.loadedContext;
|
||||
for (const edge of edges) {
|
||||
// If the rebuild we just completed still didn't emit a
|
||||
// V-source for this pin (e.g. the pin isn't wired into
|
||||
// any net), replaying via handleMcuEdge would self-heal
|
||||
// again → re-tick → loop forever. Drop the edge instead;
|
||||
// a future canvas change (e.g. user adds the wire) will
|
||||
// pick it up via the normal subscription tick.
|
||||
const expected = `v_${sanitizeSpiceId(edge.boardId)}_${sanitizeSpiceId(edge.pinName)}`.toLowerCase();
|
||||
const hasSource = ctx?.voltageSources.some(
|
||||
(vs) => vs.toLowerCase() === expected,
|
||||
);
|
||||
if (!hasSource) continue;
|
||||
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
|
||||
}
|
||||
// Deferred drain (not an immediate replay): the drain itself
|
||||
// re-checks each pin against the freshly-rebuilt V-source list,
|
||||
// dropping edges for pins that still aren't wired into any net —
|
||||
// replaying those would self-heal again → re-tick → loop forever.
|
||||
this.scheduleEdgeDrain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -236,7 +257,12 @@ export class CircuitSimulationService {
|
|||
async handleMcuEdge(boardId: string, pinName: string, state: boolean, vcc: number): Promise<void> {
|
||||
if (this.stopped) return;
|
||||
const pinKey = `${boardId}|${pinName}`;
|
||||
if (this.inFlight) {
|
||||
// Queue while a solve is in flight OR while the drain gap timer is
|
||||
// armed. Without the second condition, every edge landing in the gap
|
||||
// between solves would start an immediate solve of its own and the
|
||||
// gap would only apply to the queued leftovers — under a sustained
|
||||
// storm that's still ~1 solve per 2 edges instead of 1 per gap.
|
||||
if (this.inFlight || this.drainTimer !== null) {
|
||||
this.pendingMcuEdges.set(pinKey, { boardId, pinName, state, vcc });
|
||||
return;
|
||||
}
|
||||
|
|
@ -275,11 +301,7 @@ export class CircuitSimulationService {
|
|||
this.pending = false;
|
||||
void this.tick();
|
||||
} else if (this.pendingMcuEdges.size > 0) {
|
||||
const edges = Array.from(this.pendingMcuEdges.values());
|
||||
this.pendingMcuEdges.clear();
|
||||
for (const edge of edges) {
|
||||
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
|
||||
}
|
||||
this.scheduleEdgeDrain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import {
|
|||
useSimulatorStore,
|
||||
getBoardPinManager,
|
||||
} from '../../store/useSimulatorStore';
|
||||
import { stm32LinearToPinName } from '../Stm32Bridge';
|
||||
import { isStm32BoardKind, isPiBoardKind } from '../../types/board';
|
||||
import { useElectricalStore } from '../../store/useElectricalStore';
|
||||
import { BOARD_PIN_GROUPS } from './boardPinGroups';
|
||||
import type { CircuitSimulationService } from './CircuitSimulationService';
|
||||
|
|
@ -84,6 +86,19 @@ export function connectMcuEdgesToService(service: CircuitSimulationService): ()
|
|||
if (boardKind.startsWith('esp32')) {
|
||||
return `GPIO${arduinoPin}`;
|
||||
}
|
||||
// STM32 wires reference port-style names (PA0 / PC13); its PinManager is
|
||||
// keyed on the linear pin index. Without this reverse mapping the MCU-edge
|
||||
// listener never attaches ("13" ≠ "PC13") — previously masked because
|
||||
// PinManager requested a full re-solve on EVERY mcu edge; now that the
|
||||
// full tick only fires on first classification, this fine-grained path
|
||||
// must actually cover STM32.
|
||||
if (isStm32BoardKind(boardKind)) {
|
||||
return stm32LinearToPinName(arduinoPin);
|
||||
}
|
||||
// Raspberry Pi (Linux boards) wires use GPIO-style names like ESP32.
|
||||
if (isPiBoardKind(boardKind)) {
|
||||
return `GPIO${arduinoPin}`;
|
||||
}
|
||||
// ATtiny85 wires reference port-style names (PB0..PB5), matching the
|
||||
// netlist pin names from collectPinStates. Without this, the reverse
|
||||
// mapping returns "1" instead of "PB1", so the MCU-edge listener is
|
||||
|
|
|
|||
|
|
@ -88,12 +88,30 @@ export function startSimulation(): () => void {
|
|||
const unsubChipIn = connectChipInputsToSolve();
|
||||
const unsubEdges = connectMcuEdgesToService(service);
|
||||
|
||||
// Let custom chips request a re-solve when they toggle an output pin, so
|
||||
// their SPICE voltage sources (emitted by the custom-chip mapper) are
|
||||
// refreshed and LEDs / analog parts on the chip's nets update. The service
|
||||
// coalesces overlapping ticks, so frequent chip toggles are cheap.
|
||||
// Let custom chips / WS boards request a re-solve when they toggle an
|
||||
// output pin. Trailing-throttled: callers of this hook are PER-EDGE sites
|
||||
// (PinManager, RP2040Simulator, ChipRuntime) that can fire thousands of
|
||||
// times per second under fast toggling (multiplexed displays, bit-banged
|
||||
// protocols). The service's own inFlight coalescing only merges OVERLAPPING
|
||||
// ticks — under a sustained edge stream it still runs back-to-back full
|
||||
// rebuild+solve cycles with no idle gap, which starves the main thread.
|
||||
// One trailing tick per window keeps the last state without the storm.
|
||||
const RESOLVE_THROTTLE_MS = 33;
|
||||
let lastResolveAt = 0;
|
||||
let trailingResolve: ReturnType<typeof setTimeout> | null = null;
|
||||
setElectricalResolveHook(() => {
|
||||
void service.tick();
|
||||
const now = Date.now();
|
||||
if (now - lastResolveAt >= RESOLVE_THROTTLE_MS) {
|
||||
lastResolveAt = now;
|
||||
void service.tick();
|
||||
return;
|
||||
}
|
||||
if (trailingResolve !== null) return;
|
||||
trailingResolve = setTimeout(() => {
|
||||
trailingResolve = null;
|
||||
lastResolveAt = Date.now();
|
||||
void service.tick();
|
||||
}, RESOLVE_THROTTLE_MS - (now - lastResolveAt));
|
||||
});
|
||||
|
||||
// Phase 1d #16 — debug helper. Call `__spiceDebug()` from DevTools
|
||||
|
|
@ -139,6 +157,10 @@ export function startSimulation(): () => void {
|
|||
|
||||
return () => {
|
||||
setElectricalResolveHook(null);
|
||||
if (trailingResolve !== null) {
|
||||
clearTimeout(trailingResolve);
|
||||
trailingResolve = null;
|
||||
}
|
||||
unsubService();
|
||||
unsubAdc();
|
||||
unsubDigitalIn();
|
||||
|
|
|
|||
|
|
@ -2547,11 +2547,22 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
|
|||
},
|
||||
|
||||
updateComponentState: (id, state) => {
|
||||
set((prevState) => ({
|
||||
components: prevState.components.map((c) =>
|
||||
c.id === id ? { ...c, properties: { ...c.properties, state, value: state } } : c,
|
||||
),
|
||||
}));
|
||||
set((prevState) => {
|
||||
// No-op guard: this runs per GPIO edge for wire-connected components.
|
||||
// Unconditionally minting a new components array re-rendered every
|
||||
// subscriber (canvas, editor page, console) thousands of times per
|
||||
// second on a fast-toggling sketch — the main cause of the frozen
|
||||
// browser on the ESP32 multiplexed-clock projects.
|
||||
const comp = prevState.components.find((c) => c.id === id);
|
||||
if (!comp || (comp.properties.state === state && comp.properties.value === state)) {
|
||||
return prevState;
|
||||
}
|
||||
return {
|
||||
components: prevState.components.map((c) =>
|
||||
c.id === id ? { ...c, properties: { ...c.properties, state, value: state } } : c,
|
||||
),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
handleComponentEvent: (_componentId, _eventName, _data) => {},
|
||||
|
|
|
|||
Loading…
Reference in New Issue