Files
XC_VM/tests/Unit/CronLockIdStableTest.php
T
Claude e25b4bcbf5 fix(cron): stop deriving cron lock ids from live_streaming_pass
Cron locks were CRONS_TMP_PATH . md5(generateUniqueCode(live_streaming_pass)
. class), so rotating the viewer-token secret (the cluster plan rotates it
at cutover and again at lockdown) renamed every lock and let a second
instance of each cron start beside one still running.

ProcessManager::cronLockPath() names the lock from the class alone; CronTrait,
RootSignalsCronJob and server:diagnose use it. For the upgrade itself,
exitIfCronLockHeld() honours a live, fresh lock under the previous name, so
an instance started by the old release is not doubled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Es8UhyPS8MNjuYTagqfGWi
2026-09-25 09:04:26 +00:00

58 lines
2.2 KiB
PHP

<?php
use PHPUnit\Framework\TestCase;
use XcVm\Core\Process\ProcessManager;
/**
* Cron lock ids no longer depend on live_streaming_pass. The old name hashed
* the secret, so rotating it (the cluster plan rotates it twice) let a second
* instance of every cron start beside one still running under the old name.
*/
final class CronLockIdStableTest extends TestCase {
private string $rDir;
protected function setUp(): void {
if (!defined('CRONS_TMP_PATH')) {
define('CRONS_TMP_PATH', sys_get_temp_dir() . '/xcvm_crons_' . getmypid() . '/');
}
$this->rDir = CRONS_TMP_PATH;
@mkdir($this->rDir, 0775, true);
}
protected function tearDown(): void {
foreach (glob($this->rDir . '*') ?: [] as $rFile) {
@unlink($rFile);
}
}
public function testLockPathIsStablePerClassAndIgnoresTheSecret(): void {
$rA = ProcessManager::cronLockPath('XcVm\\Cli\\CronJobs\\ServersCronJob');
$this->assertSame($rA, ProcessManager::cronLockPath('XcVm\\Cli\\CronJobs\\ServersCronJob'));
$this->assertNotSame($rA, ProcessManager::cronLockPath('XcVm\\Cli\\CronJobs\\CacheCronJob'));
$this->assertStringStartsWith(CRONS_TMP_PATH, $rA);
$this->assertNotSame(
ProcessManager::legacyCronLockPath('X', 'secret-one'),
ProcessManager::legacyCronLockPath('X', 'secret-two'),
'the legacy name is exactly what a rotation used to break'
);
}
public function testNoCronLockIsDerivedFromTheStreamingSecret(): void {
$rRoot = dirname(__DIR__, 2) . '/src/';
foreach (['Cli/CronTrait.php', 'Cli/CronJobs/RootSignalsCronJob.php', 'Cli/Commands/ServerDiagnoseCommand.php'] as $rFile) {
$rSource = (string) file_get_contents($rRoot . $rFile);
$this->assertStringContainsString('ProcessManager::cronLockPath(', $rSource, $rFile);
$this->assertStringNotContainsString("md5(Encryption::generateUniqueCode(SettingsManager::get('live_streaming_pass')", $rSource, $rFile);
}
}
public function testAStaleOrMissingLegacyLockDoesNotBlock(): void {
$rLegacy = ProcessManager::legacyCronLockPath('X', 'pass');
ProcessManager::exitIfCronLockHeld($rLegacy); // absent: returns
file_put_contents($rLegacy, '999999999'); // pid that cannot exist
ProcessManager::exitIfCronLockHeld($rLegacy);
$this->addToAssertionCount(1);
}
}