fix(sim): stop dropping sibling pin edges — only bit 0 of a multi-pin write reached the canvas

A sketch that drives several pins between yields lost every pin but the
first. The 4-bit binary counter example (the most-viewed one in the
gallery) blinked bit 0 and left bits 1-3 dark while the firmware counted
0-15 correctly on serial.

Sequence: pin 2's edge arrives first, finds no V-source (the pin was not
in outputPins when the netlist was built), self-heals via handleMcuEdge
and rebuilds. The pin 3/4/5 edges of that same iteration land while the
rebuild solve is in flight, so they queue in pendingMcuEdges. The drain
then looked them up in the loaded context, found no V_uno_3/4/5, and
dropped them. Those pins never got a source, so their LEDs kept the
build-time current (~0) forever — and since the LED part prefers SPICE
current over the digital fallback, they stayed dark for good.

The drop was deliberate: it stops the self-heal -> tick -> self-heal loop
for a pin wired to nothing. Replace the blanket drop with a per-pin
guard: heal a pin at most once per netlist, then drop as before. The
guard re-arms whenever a rebuild changes the V-source list, so a pin that
becomes drivable later still heals.

Verified in the browser on binary-counter-leds; two regression tests
cover both sides (sibling pin heals, unwired pin does not loop).
This commit is contained in:
David Montero Crespo 2026-08-16 20:02:58 +02:00
parent 11f1a7ef53
commit 5c95ade35f
2 changed files with 137 additions and 1 deletions

View File

@ -471,6 +471,95 @@ describe('handleMcuEdge (Phase 1c D1)', () => {
expect(fake.calls.loadCircuit.length).toBe(1);
expect(fake.calls.alterSource).toEqual([]);
});
it('heals a sibling pin that was classified as an output mid-solve', async () => {
// Regression (binary-counter-leds, the most-viewed gallery example):
// a sketch writing several pins between yields lost every pin but the
// first. Sequence: pin 2's edge lands first, self-heals, and rebuilds
// the netlist; pins 3-5 arrive while that solve is in flight, so they
// queue; the drain then found no V_uno_3 in the loaded context and
// DROPPED them. Those pins never got a source and stayed at their
// build-time voltage forever — bit 0 blinked, bits 1-3 stayed dark
// while the firmware counted correctly on serial.
//
// The drain must self-heal (re-queue + rebuild) like handleMcuEdge
// does, once per pin per netlist.
const fake = new FakeSolverAdapter({
vectors: { 'v(vcc_rail)': 5 },
solveDelayMs: 30,
});
__setSchedulerSolverFactoryForTests(() => fake);
// Pin 3 is NOT an MCU output at build time — it becomes one only once
// the firmware first drives it, exactly like PinManager.outputPins.
let pin3IsOutput = false;
const sim = makeSimStore({
components: [
{ id: 'r2', metadataId: 'resistor', properties: { value: '220' } },
{ id: 'r3', metadataId: 'resistor', properties: { value: '220' } },
],
wires: [
{ id: 'w2a', start: { componentId: 'uno', pinName: '2' }, end: { componentId: 'r2', pinName: '1' } },
{ id: 'w2b', start: { componentId: 'r2', pinName: '2' }, end: { componentId: 'uno', pinName: 'GND' } },
{ id: 'w3a', start: { componentId: 'uno', pinName: '3' }, end: { componentId: 'r3', pinName: '1' } },
{ id: 'w3b', start: { componentId: 'r3', 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: () =>
pin3IsOutput
? { '2': { type: 'digital', v: 0 }, '3': { type: 'digital', v: 0 } }
: { '2': { type: 'digital', v: 0 } },
},
);
startTracked(service);
// Let the initial netlist get built (without pin 3) while its solve
// is still in flight.
await new Promise((r) => setTimeout(r, 5));
pin3IsOutput = true;
void service.handleMcuEdge('uno', '2', true, 5);
void service.handleMcuEdge('uno', '3', true, 5);
// Drain gap (33 ms) + rebuild + the healed edge's own solve.
await new Promise((r) => setTimeout(r, 300));
const altered = fake.calls.alterSource.map(([name]) => name);
expect(altered).toContain('V_uno_3');
});
it('does not loop forever healing a pin that can never be sourced', async () => {
// The flip side of the heal above: an unwired GPIO never gets a
// V-source no matter how many rebuilds run. The per-pin guard must
// let it through at most once, then go back to dropping — otherwise
// heal -> tick -> heal spins the solver at 100% duty.
const fake = new FakeSolverAdapter({
vectors: { 'v(vcc_rail)': 5 },
solveDelayMs: 5,
});
__setSchedulerSolverFactoryForTests(() => fake);
const sim = makeSimStore(simpleBoardWithBoard);
const elec = makeElectricalStore();
const service = new CircuitSimulationService(
sim.port,
elec.port,
getMixedModeScheduler() as unknown as MixedModeSchedulerPort,
// Pin 7 is claimed as an output but is wired to nothing, so
// buildNetlist never emits V_uno_7.
{ collectBoardPinStates: () => ({ '7': { type: 'digital', v: 0 } }) },
);
startTracked(service);
await new Promise((r) => setTimeout(r, 20));
const before = fake.calls.solve.length;
void service.handleMcuEdge('uno', '7', true, 5);
await new Promise((r) => setTimeout(r, 400));
// A bounded number of extra solves — not one per 33 ms drain gap
// (~12 over this window) and nowhere near a runaway.
expect(fake.calls.solve.length - before).toBeLessThanOrEqual(4);
});
});
describe('CircuitSimulationService — error handling', () => {

View File

@ -150,6 +150,13 @@ export class CircuitSimulationService {
private drainTimer: ReturnType<typeof setTimeout> | null = null;
private static readonly EDGE_DRAIN_GAP_MS = 33;
/** Pins the drain has already forced one netlist rebuild for, keyed
* `${boardId}|${pinName}`. See `scheduleEdgeDrain` — this is what keeps
* the self-heal from looping forever on a pin that can never get a
* V-source (unwired GPIO). Cleared whenever a rebuild changes the
* V-source list, so a pin that becomes wired later heals again. */
private drainHealAttempted = new Set<string>();
/** Drain pendingMcuEdges after a short gap (coalescing: one timer). */
private scheduleEdgeDrain(): void {
if (this.stopped || this.drainTimer !== null) return;
@ -159,14 +166,43 @@ export class CircuitSimulationService {
const edges = Array.from(this.pendingMcuEdges.values());
this.pendingMcuEdges.clear();
const ctx = this.loadedContext;
let needsRebuild = false;
for (const edge of edges) {
const pinKey = `${edge.boardId}|${edge.pinName}`;
const expected = `v_${sanitizeSpiceId(edge.boardId)}_${sanitizeSpiceId(edge.pinName)}`.toLowerCase();
const hasSource = ctx?.voltageSources.some(
(vs) => vs.toLowerCase() === expected,
);
if (!hasSource) continue;
if (!hasSource) {
// Same self-heal `handleMcuEdge` does for a first edge: the pin
// wasn't in `outputPins` when this netlist was built, so there is
// no V-source to alter and `alterSource` would be a silent no-op.
// Re-queue and rebuild instead of dropping the edge.
//
// Dropping was deliberate (it stops the self-heal → tick →
// self-heal loop for a pin that is wired to nothing), but it also
// silently killed pins that only LOOK unsourced because they were
// classified as outputs after this netlist was built. That is the
// common case for any sketch driving several pins between yields:
// the first pin's edge triggers the rebuild, every sibling edge
// lands mid-solve, and the drain then threw them away — so those
// pins stayed at their build-time voltage forever. Symptom: the
// 4-bit binary counter example blinked bit 0 and left bits 1-3
// dark while the firmware counted correctly on serial.
//
// The loop guard is now per-pin instead of blanket: heal a given
// pin at most once per netlist, then fall back to dropping.
if (this.drainHealAttempted.has(pinKey)) continue;
this.drainHealAttempted.add(pinKey);
this.pendingMcuEdges.set(pinKey, edge);
needsRebuild = true;
continue;
}
this.drainHealAttempted.delete(pinKey);
void this.handleMcuEdge(edge.boardId, edge.pinName, edge.state, edge.vcc);
}
// One rebuild for the whole drain, not one per edge.
if (needsRebuild) void this.tick();
}, CircuitSimulationService.EDGE_DRAIN_GAP_MS);
}
@ -198,6 +234,7 @@ export class CircuitSimulationService {
this.stopped = true;
this.pending = false;
this.pendingMcuEdges.clear();
this.drainHealAttempted.clear();
if (this.drainTimer !== null) {
clearTimeout(this.drainTimer);
this.drainTimer = null;
@ -347,6 +384,16 @@ export class CircuitSimulationService {
// Cache the load context so handleMcuEdge can publish without
// re-running buildInputFromStore + buildNetlist.
//
// A rebuild that changed the V-source list is a new topology as far as
// the drain's per-pin heal guard is concerned: re-arm it so a pin that
// has just become drivable (newly wired, or newly classified as an
// output) can heal again instead of staying permanently written off.
const sourcesChanged =
this.loadedContext === null ||
this.loadedContext.voltageSources.length !== voltageSources.length ||
this.loadedContext.voltageSources.some((vs, i) => vs !== voltageSources[i]);
if (sourcesChanged) this.drainHealAttempted.clear();
this.loadedContext = {
pinNetMap,
nets,