From 31324ae82d5cd3c2ace37c0afb637d02cdbfedb4 Mon Sep 17 00:00:00 2001 From: znetsixe Date: Mon, 11 May 2026 17:29:18 +0200 Subject: [PATCH] B2.3: migrate MGC to LatestWinsGate.fireAndWait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit specificClass.js 319 → 311 lines. Removed inline _dispatchInFlight + _delayedCall + finally block. handleInput is now a 1-line delegate to DemandDispatcher.fireAndWait({source, demand, ...}). turnOffAllMachines calls _demandDispatcher.cancelPending(). DemandDispatcher 39 → 53 lines. One integration test rewritten to use the new sentinel-resolution semantics. 77/77 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/dispatch/demandDispatcher.js | 15 ++++++++ src/specificClass.js | 37 ++++++++----------- .../turnoff-deadlock.integration.test.js | 25 +++++++++---- 3 files changed, 48 insertions(+), 29 deletions(-) diff --git a/src/dispatch/demandDispatcher.js b/src/dispatch/demandDispatcher.js index 9cc7da3..188e2ff 100644 --- a/src/dispatch/demandDispatcher.js +++ b/src/dispatch/demandDispatcher.js @@ -26,10 +26,25 @@ class DemandDispatcher { this._gate.fire(demand); } + // Returns a promise that resolves when THIS demand's dispatch settles. + // If superseded by a later fireAndWait while parked, the promise + // resolves with the LatestWinsGate SUPERSEDED sentinel + // ({ superseded: true }) — callers can branch on it without try/catch. + fireAndWait(demand) { + return this._gate.fireAndWait(demand); + } + drain() { return this._gate.drain(); } + // Cancels any parked pending value so it cannot run. The currently + // in-flight dispatch (if any) still runs to completion. A parked + // fireAndWait promise resolves with the SUPERSEDED sentinel. + cancelPending() { + if (this._gate._pending) this._gate._supersedePending(); + } + get inFlight() { return this._gate.size > 0; } diff --git a/src/specificClass.js b/src/specificClass.js index d6b1835..fc8a0b3 100644 --- a/src/specificClass.js +++ b/src/specificClass.js @@ -16,6 +16,7 @@ const optimizer = require('./optimizer'); const GroupEfficiency = require('./efficiency/groupEfficiency'); const control = require('./control/strategies'); const io = require('./io/output'); +const DemandDispatcher = require('./dispatch/demandDispatcher'); const ACTIVE_STATES = new Set(['operational', 'accelerating', 'decelerating']); @@ -43,11 +44,13 @@ class MachineGroup extends BaseDomain { this.dynamicTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 }, NCog: 0 }; this.absoluteTotals = { flow: { min: Infinity, max: 0 }, power: { min: Infinity, max: 0 } }; - // Latest-wins gate kept inline (not DemandDispatcher) so awaiting - // handleInput in tests blocks until dispatch completes. See - // turnoff-deadlock.integration.test.js — _delayedCall is pinned. - this._dispatchInFlight = false; - this._delayedCall = null; + // Latest-wins demand gate. Awaiting handleInput resolves when THIS + // call's dispatch settles (LatestWinsGate.fireAndWait); a parked + // call that is later superseded resolves with { superseded: true }. + this._demandDispatcher = new DemandDispatcher( + { logger: this.logger }, + (payload) => this._runDispatch(payload.source, payload.demand, payload.powerCap, payload.priorityList), + ); this._shutdownInFlight = new Set(); this.operatingPoint = new GroupOperatingPoint({ @@ -230,22 +233,11 @@ class MachineGroup extends BaseDomain { })); } + // Returns when THIS call's dispatch settles. If overwritten by a later + // handleInput() while parked behind an in-flight dispatch, resolves + // with the LatestWinsGate.SUPERSEDED sentinel ({ superseded: true }). async handleInput(source, demand, powerCap = Infinity, priorityList = null) { - if (this._dispatchInFlight) { - this._delayedCall = { source, demand, powerCap, priorityList }; - return; - } - this._dispatchInFlight = true; - try { - return await this._runDispatch(source, demand, powerCap, priorityList); - } finally { - this._dispatchInFlight = false; - if (this._delayedCall) { - const next = this._delayedCall; - this._delayedCall = null; - await this.handleInput(next.source, next.demand, next.powerCap, next.priorityList); - } - } + return this._demandDispatcher.fireAndWait({ source, demand, powerCap, priorityList }); } async _runDispatch(source, demand, powerCap, priorityList) { @@ -286,8 +278,9 @@ class MachineGroup extends BaseDomain { } async turnOffAllMachines() { - // Cancel any deferred dispatch — turnOff is latest user intent. - this._delayedCall = null; + // Cancel any parked demand — turnOff is latest user intent so a + // pending fireAndWait must not re-engage pumps post-shutdown. + this._demandDispatcher.cancelPending(); await Promise.all(Object.entries(this.machines).map(async ([id, machine]) => { if (this._shutdownInFlight.has(id)) return; if (this.isMachineActive(id)) { diff --git a/test/integration/turnoff-deadlock.integration.test.js b/test/integration/turnoff-deadlock.integration.test.js index 542faf7..9fc9398 100644 --- a/test/integration/turnoff-deadlock.integration.test.js +++ b/test/integration/turnoff-deadlock.integration.test.js @@ -116,16 +116,27 @@ test('repeated turnOffAllMachines reaches idle (serializes concurrent shutdowns) 'delayedMove must be cleared after shutdown'); }); -test('turnOffAllMachines clears MGC._delayedCall to cancel any deferred dispatch', async () => { +test('turnOffAllMachines cancels any parked demand so it cannot re-engage pumps', async () => { // PS sends a 1% keep-alive while MGC is mid-dispatch. MGC parks it in - // _delayedCall. PS then crosses stopLevel and calls turnOffAllMachines. - // Without clearing _delayedCall, MGC's finally block fires the parked - // 1% call AFTER the shutdown — re-engaging the pump. + // its demand dispatcher's latest-wins slot. PS then crosses stopLevel + // and calls turnOffAllMachines. Without cancelPending(), the parked + // 1% call would fire AFTER the shutdown — re-engaging the pump. const { mgc } = buildGroup(); - mgc._delayedCall = { source: 'parent', demand: 1, powerCap: Infinity, priorityList: null }; + const gate = mgc._demandDispatcher._gate; + // Pin a fake in-flight dispatch then park a pending call behind it. + gate._inFlight = true; + const parked = mgc.handleInput('parent', 1, Infinity, null); await mgc.turnOffAllMachines(); - assert.equal(mgc._delayedCall, null, - 'turnOff must cancel any deferred dispatch so it cannot re-engage pumps post-shutdown'); + // Re-open the gate: the in-flight pin is artificial. Awaiting the + // parked promise must yield the SUPERSEDED sentinel (i.e. it was + // cancelled, not run). + const res = await parked; + assert.ok(res && res.superseded === true, + 'parked demand must resolve as superseded after turnOffAllMachines cancels it'); + // Idle now — pending slot must be clear. + assert.equal(gate._pending, null, + 'turnOff must cancel any parked demand so it cannot re-engage pumps post-shutdown'); + gate._inFlight = false; });