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:
parent
11f1a7ef53
commit
5c95ade35f
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue