Files
XC_VM/tests/Unit/MainClockRollbackTest.php
T
Claude 09e9525974 feat(cluster): NodeLease consults xcvm_core's compiled lease verdict
Phase 9 (plan 9.8): where the node's xcvm_core offers it
(method_exists cluster_lease_state), NodeLease asks the extension first,
every 2 s like the agent's file. It hands the extension the agent's lease
(agent.json, exact signed bytes) when it is newer by iat than the one held,
and does not offer a refused one again. A `live` or `expired` answer
decides (verdict `source: extension`), judged on the extension's anchor on
MAIN's clock, which never moves back; `none`, an older extension or one that
throws falls back to the agent's file, which still serves on every doubt
(`source: agent`). On the node the same verdict makes license_valid() true,
so LicenseGate lets it use fanout.

Tests: LeaseVerdictCacheTest, TimeAnchorMonotoneTest and
MainClockRollbackTest (extension absent, a fake in its place), and an
end-to-end case in ClusterExtensionIntegrationTest against a real
test-hooks xcvm_core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rdv7hXd8RWhLHoj5Ma2ZqY
2026-09-28 23:10:29 +00:00

63 lines
2.5 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Cluster\NodeLease;
use XcVm\Tests\Support\NodeLeaseFixture;
/**
* A MAIN whose clock was set back cannot lengthen a node's lease (ADR 0004,
* Phase 9). MAIN's own extension refuses to issue with its clock more than
* 5 minutes behind its high-water; a lease it issued anyway carries an older
* `iat`, which the node's extension refuses as stale and which never pulls its
* anchor back (xcvm_core's MainClockRollback tests). Here: the node's PHP
* follows the compiled verdict over the agent's file, whose anchor re-bases on
* whatever MAIN last said — and without the extension, the agent's file is
* all there is (the fallback's documented limit).
*/
final class MainClockRollbackTest extends TestCase {
use NodeLeaseFixture;
protected function setUp(): void {
$this->leaseSetUp();
}
protected function tearDown(): void {
$this->leaseTearDown();
}
public function testTheCompiledVerdictOutranksAnAgentAnchorPulledBack(): void {
$this->withExtension();
$rNow = time();
// MAIN's clock went back two hours: the agent re-anchored on it, and by
// that anchor the lease has an hour left.
$this->agentFile(3600, 0, ($rNow - 7200) * 1000);
// The extension's anchor did not move back: the lease ended 5 s ago.
$this->rCompiledState = self::compiled('expired', $rNow - 5, $rNow, $rNow - 3600);
$rVerdict = NodeLease::verdict();
$this->assertSame([NodeLease::DRAINING, 'extension'], [$rVerdict['state'], $rVerdict['source']]);
}
public function testAnOlderLeaseFromARolledBackMainIsNotOffered(): void {
$this->withExtension();
$rNow = time();
// The agent kept a lease MAIN issued after its clock went back: older than
// the one the extension holds.
$this->agentLease($rNow - 7200);
$this->rCompiledState = self::compiled('live', $rNow + 600, $rNow, $rNow - 60);
$this->assertSame(NodeLease::SERVING, NodeLease::state());
$this->assertSame(['cluster_lease_state'], $this->asked(), 'never handed to the extension');
}
public function testWithoutTheExtensionTheAgentsAnchorDecides(): void {
// Extension absent: the fallback. The agent's anchor is where MAIN last
// put it; a rolled-back MAIN moves it, which is why the compiled verdict
// is preferred wherever it exists.
$rNow = time();
$this->agentFile(3600, 0, ($rNow - 7200) * 1000);
$rVerdict = NodeLease::verdict();
$this->assertSame([NodeLease::SERVING, 'agent'], [$rVerdict['state'], $rVerdict['source']]);
$this->agentFile(-5, 0, $rNow * 1000);
$this->assertSame(NodeLease::DRAINING, NodeLease::state());
}
}