feat(rotation): X-Boot-Reason: cold forces a resync regardless of schedule
CI / test (push) Has been cancelled
CI / test (push) Has been cancelled
Cold-boot polls (firmware sends X-Boot-Reason: cold on UNDEFINED wakeup cause) are treated as a deliberate "force a refresh" gesture from the user — unplug → replug to re-pull whatever the web app queued. Timer wakes still respect the wakeTimes schedule, so the schedule-gated semantics aren't undermined. Test: a cold-boot poll between scheduled wake times advances the rotation and writes a fresh DeviceImageHistory row, while an otherwise-identical timer-wake poll returns 304 without rotating. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -83,14 +83,18 @@ class DeviceImageController extends AbstractController
|
|||||||
// the second flush triggers a second publish to keep it accurate.
|
// the second flush triggers a second publish to keep it accurate.
|
||||||
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
|
$this->mercure->publishDevice((int) $device->getId(), $this->serializer->serialize($device));
|
||||||
|
|
||||||
// Locked image bypasses rotation entirely. Otherwise, only advance the
|
// Locked image bypasses rotation entirely. Otherwise, advance only
|
||||||
// rotation when the device's configured schedule says it's due —
|
// when the device's configured schedule says it's due — except a
|
||||||
// off-schedule polls (boot, button-mash, scheduler-driven status check)
|
// cold-boot poll (X-Boot-Reason: cold) is treated as a deliberate
|
||||||
// hand back the device's current image without rotating, so the user
|
// user-driven force-refresh: unplug → replug → fresh rotation,
|
||||||
// doesn't see surprise refreshes between configured wake times.
|
// regardless of wakeTimes. Timer wakes stay schedule-gated, so users
|
||||||
|
// don't see surprise refreshes between configured slots.
|
||||||
|
$bootReason = strtolower((string) $request->headers->get('X-Boot-Reason', ''));
|
||||||
|
$forceResync = ($bootReason === 'cold');
|
||||||
|
|
||||||
if ($device->getLockedImage() !== null) {
|
if ($device->getLockedImage() !== null) {
|
||||||
$image = $device->getLockedImage();
|
$image = $device->getLockedImage();
|
||||||
} elseif ($this->rotation->isDue($device)) {
|
} elseif ($forceResync || $this->rotation->isDue($device)) {
|
||||||
$image = $this->rotation->advance($device);
|
$image = $this->rotation->advance($device);
|
||||||
} else {
|
} else {
|
||||||
$image = $device->getCurrentImage();
|
$image = $device->getCurrentImage();
|
||||||
|
|||||||
@@ -376,6 +376,63 @@ class DeviceImageControllerTest extends AppWebTestCase
|
|||||||
$this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs);
|
$this->assertLessThanOrEqual(8 * 60 * 60 * 1000, $intervalMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FORCE-RESYNC FEATURE: a cold-boot poll (X-Boot-Reason: cold) MUST
|
||||||
|
// advance the rotation even when the schedule says we're not due. This
|
||||||
|
// is documented behavior — unplug-and-replug = manual refresh — and
|
||||||
|
// this test exists specifically to keep the feature from regressing.
|
||||||
|
// See feedback_force_resync_via_powercycle.md in agent memory.
|
||||||
|
public function test_cold_boot_force_resyncs_off_schedule(): void
|
||||||
|
{
|
||||||
|
$setup = $this->createTestSetup();
|
||||||
|
$device = $setup['device'];
|
||||||
|
|
||||||
|
// Establish a current image, then configure wakeTimes such that the
|
||||||
|
// most-recent-past slot has already been served — schedule says
|
||||||
|
// we're NOT due for another rotation right now.
|
||||||
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image');
|
||||||
|
$this->assertResponseStatusCodeSame(200);
|
||||||
|
$imageId = $this->client->getResponse()->headers->get('X-Image-Id');
|
||||||
|
$device->setWakeTimes([0])->setTimezone('UTC');
|
||||||
|
$this->em()->flush();
|
||||||
|
|
||||||
|
// Sanity-check: a *timer* wake at this point would 304 (no rotation).
|
||||||
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
||||||
|
'HTTP_X_Boot_Reason' => 'timer',
|
||||||
|
'HTTP_X_Current_Image_Id' => $imageId,
|
||||||
|
]);
|
||||||
|
$this->assertResponseStatusCodeSame(304, 'timer wake must respect the schedule');
|
||||||
|
|
||||||
|
// The actual feature test: a cold-boot poll force-rotates regardless.
|
||||||
|
// It returns 200 (or 304 if the rotation happened to land on the same
|
||||||
|
// image again — depends on pool size). The proof is that it ran
|
||||||
|
// through the rotation path, which we verify by checking that a new
|
||||||
|
// history row was written.
|
||||||
|
$beforeCount = $this->countHistory($device);
|
||||||
|
$this->client->request('GET', '/api/device/' . self::MAC . '/image', [], [], [
|
||||||
|
'HTTP_X_Boot_Reason' => 'cold',
|
||||||
|
'HTTP_X_Current_Image_Id' => $imageId,
|
||||||
|
]);
|
||||||
|
$afterCount = $this->countHistory($device);
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
$beforeCount + 1,
|
||||||
|
$afterCount,
|
||||||
|
'cold-boot poll must advance the rotation (write a fresh history row) even off-schedule',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function countHistory(\App\Entity\Device $device): int
|
||||||
|
{
|
||||||
|
return (int) $this->em()
|
||||||
|
->createQueryBuilder()
|
||||||
|
->select('COUNT(h.id)')
|
||||||
|
->from(\App\Entity\DeviceImageHistory::class, 'h')
|
||||||
|
->where('h.device = :d')
|
||||||
|
->setParameter('d', $device)
|
||||||
|
->getQuery()
|
||||||
|
->getSingleScalarResult();
|
||||||
|
}
|
||||||
|
|
||||||
// Off-schedule poll (wakeTimes set, but the most-recent past slot has
|
// Off-schedule poll (wakeTimes set, but the most-recent past slot has
|
||||||
// already been served): the controller MUST NOT advance the rotation.
|
// already been served): the controller MUST NOT advance the rotation.
|
||||||
// It returns 304 (device's X-Current-Image-Id matches the existing
|
// It returns 304 (device's X-Current-Image-Id matches the existing
|
||||||
|
|||||||
Reference in New Issue
Block a user