diff --git a/test/integration/abort-deadlock.integration.test.js b/test/integration/abort-deadlock.integration.test.js new file mode 100644 index 0000000..a4b14d5 --- /dev/null +++ b/test/integration/abort-deadlock.integration.test.js @@ -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}`); +});