Test: abort-deadlock regression guard

Two reproducers for the post-abort residue deadlock fixed in
generalFunctions state.js. The direct test forces the FSM into
'accelerating' (mimicking MGC's per-tick abortActiveMovements that
intentionally leaves the pump parked to avoid a bounce loop) and
issues a fresh setpoint — without the fix, currentPosition freezes
and delayedMove holds the new target forever; with the fix, residue
unparks and the move executes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rene De Ren
2026-05-08 11:20:17 +02:00
parent ecd5a4864b
commit 5a8113a9d1

View File

@@ -0,0 +1,164 @@
// Reproducer: pump's state machine deadlocks in 'accelerating' under
// rapid setpoint retargeting.
//
// The demo flow drives MGC to call `abortActiveMovements` on every
// handleInput. If a movement aborts mid-flight, state.moveTo's catch
// block keeps the FSM in 'accelerating' (avoids a bounce loop). Any
// NEXT setpoint then hits state.moveTo's early-return at the top:
//
// if (this.stateManager.getCurrentState() !== "operational") {
// this.delayedMove = targetPosition;
// return; // ← never moves
// }
//
// `delayedMove` only fires from the SUCCESS branch of an active
// moveTo, which can't run because state is stuck. Result: pump's
// currentPosition freezes; ctrl.predicted keeps updating (set inside
// calcCtrl regardless of whether setpoint actually moves) so the
// dashboard shows non-zero ctrl% but the editor badge stays at 0.
const test = require('node:test');
const assert = require('node:assert/strict');
const Machine = require('../../src/specificClass');
const { POSITIONS } = require('generalFunctions');
const stateConfig = {
general: { logging: { enabled: false, logLevel: 'error' } },
state: { current: 'idle' },
movement: { mode: 'staticspeed', speed: 10, maxSpeed: 100, interval: 50 },
// Match demo's slow ramp.
time: { starting: 0, warmingup: 0, stopping: 0, coolingdown: 0 },
};
function machineConfig() {
return {
general: { id: 'p1', name: 'p1', unit: 'm3/h',
logging: { enabled: false, logLevel: 'error' } },
functionality: { softwareType: 'machine', role: 'rotationaldevicecontroller' },
asset: { category: 'pump', type: 'centrifugal',
model: 'hidrostal-H05K-S03R', supplier: 'hidrostal' },
mode: {
current: 'auto',
allowedActions: { auto: ['execsequence', 'execmovement', 'flowmovement', 'statuscheck'] },
allowedSources: { auto: ['parent', 'GUI'] },
},
sequences: {
startup: ['starting', 'warmingup', 'operational'],
shutdown: ['stopping', 'coolingdown', 'idle'],
emergencystop: ['emergencystop', 'off'],
},
};
}
function makeMachineOperational() {
const m = new Machine(machineConfig(), stateConfig);
m.updateMeasuredPressure(0, 'upstream',
{ timestamp: Date.now(), unit: 'mbar', childName: 'up', childId: 'up-1' });
m.updateMeasuredPressure(1100, 'downstream',
{ timestamp: Date.now(), unit: 'mbar', childName: 'dn', childId: 'dn-1' });
return m;
}
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
test('parking deadlock: state stuck in accelerating swallows new setpoints', async () => {
// Direct reproducer of state.moveTo's early-return path. Force the
// FSM into 'accelerating' (the post-abort residue), then issue a new
// setpoint. The early-return at state.js:68 saves delayedMove and
// returns; delayedMove never fires because nothing transitions back
// to operational.
const m = makeMachineOperational();
await m.handleInput('parent', 'execsequence', 'startup');
for (let i = 0; i < 50 && m.state.getCurrentState() !== 'operational'; i++) await sleep(20);
assert.equal(m.state.getCurrentState(), 'operational');
// Force state to 'accelerating' (mimic the post-abort residue) by
// poking the underlying stateManager directly. This bypasses the
// race conditions and isolates the early-return branch.
await m.state.stateManager.transitionTo('accelerating');
assert.equal(m.state.getCurrentState(), 'accelerating');
const positionBefore = m.state.getCurrentPosition();
// Issue a fresh setpoint (what MGC's optimalControl would do).
await m.handleInput('parent', 'flowmovement', 200);
await sleep(800); // generous — at speed=10 u/s, 8 units in 0.8s.
const positionAfter = m.state.getCurrentPosition();
const stateFinal = m.state.getCurrentState();
console.log({
positionBefore, positionAfter,
stateFinal,
delayedMove: m.state.delayedMove,
delta: (positionAfter - positionBefore).toFixed(3),
});
assert.ok(positionAfter - positionBefore > 1,
`[BUG] currentPosition stuck at ${positionBefore.toFixed(2)} — moveTo's early-return at state.js:68 swallowed the setpoint. ` +
`delayedMove=${m.state.delayedMove} state=${stateFinal}`);
});
test('chain deadlock: aborted move + new setpoint freezes position (race-condition path)', async () => {
// Deterministic reproducer of the deadlock the user observed live in
// Node-RED. Key invariant being asserted: AFTER a routine abort, a
// subsequent setpoint MUST eventually move the pump toward the new
// target. Today it freezes because state.moveTo's early-return at
// the top stores the target in `delayedMove` but `delayedMove` only
// fires from inside an active moveTo's success branch — and there
// is none, since state stays in 'accelerating'.
const m = makeMachineOperational();
await m.handleInput('parent', 'execsequence', 'startup');
for (let i = 0; i < 50 && m.state.getCurrentState() !== 'operational'; i++) await sleep(20);
assert.equal(m.state.getCurrentState(), 'operational');
// Step 1: kick off a long traversal to position 80. Speed=10, so this
// takes ~8 s. We need it to be reliably in 'accelerating' when we abort.
m.setpoint(80); // not awaited
// movementManager interval is 50ms; wait two ticks so position has
// demonstrably advanced and state is firmly in 'accelerating'.
await sleep(150);
assert.equal(m.state.getCurrentState(), 'accelerating',
`precondition: pump should be accelerating mid-traversal; got ${m.state.getCurrentState()}`);
const positionDuringMove = m.state.getCurrentPosition();
assert.ok(positionDuringMove > 0 && positionDuringMove < 80,
`precondition: pump should be mid-traversal, got ${positionDuringMove}`);
// Step 2: routine abort, exactly what MGC's abortActiveMovements does.
m.abortMovement('routine retarget');
// Wait for the abort signal to propagate through the setInterval.
await sleep(120);
const stateAfterAbort = m.state.getCurrentState();
const positionAfterAbort = m.state.getCurrentPosition();
// Step 3: a fresh setpoint — what MGC's optimalControl issues next.
// Use a target DIFFERENT from current position so the early-return
// `targetPosition === currentPosition` doesn't apply.
await m.handleInput('parent', 'flowmovement', 200); // m³/h → distinct ctrl%
// Give it half a second, plenty of time for movement to advance at
// speed=10 u/s if it actually proceeds.
await sleep(500);
const stateFinal = m.state.getCurrentState();
const positionFinal = m.state.getCurrentPosition();
console.log({
positionDuringMove,
stateAfterAbort, positionAfterAbort,
stateFinal, positionFinal,
delayedMove: m.state?.delayedMove,
delta: (positionFinal - positionAfterAbort).toFixed(3),
});
// The bug: position stays parked exactly where the abort left it.
// Either the FSM is still in 'accelerating' (so moveTo's top-level
// early-return stored the new setpoint in delayedMove and bailed), or
// both — state stuck AND delayedMove holding the new target. After
// the fix, position should advance toward the new setpoint.
assert.ok(positionFinal - positionAfterAbort > 1,
`[BUG] currentPosition frozen at ${positionAfterAbort.toFixed(2)} — moveTo's early-return swallowed the new setpoint, ` +
`delayedMove=${m.state?.delayedMove}, finalState=${stateFinal}`);
});