<?php
/**
* SeekQuarry/Yioop --
* Open Source Pure PHP Search Engine, Crawler, and Indexer
*
* Copyright (C) 2009 - 2026 Chris Pollett chris@pollett.org
*
* LICENSE:
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see
* <https://www.gnu.org/licenses/>.
*
* END LICENSE
*
* @author Chris Pollett chris@pollett.org
* @license https://www.gnu.org/licenses/ GPL3
* @link https://www.seekquarry.com/
* @copyright 2009 - 2026
* @filesource
*/
namespace seekquarry\yioop\tests;
use seekquarry\yioop\library\CrawlDaemon;
use seekquarry\yioop\library\UnitTest;
/**
* Checks how CrawlDaemon builds the shell command that launches a
* background daemon. The point being guarded is that, on systems that
* launch through a shell, the command closes the file descriptors the
* daemon inherited from its launcher before the program starts. A web
* server launches its background daemons; if a daemon kept the server's
* listening sockets open, those ports would stay in use until the daemon
* exited, and a server restart could not rebind them. The construction
* is a plain string built from its inputs, so it is checked directly
* with no process actually launched.
*
* @author Chris Pollett
*/
class CrawlDaemonTest extends UnitTest
{
/**
* Path of a temporary lock file a lock-liveness case writes, empty
* when no such file is outstanding.
* @var string
*/
public $lock_path = "";
/**
* Sets up the test; nothing is needed here.
*/
public function setUp()
{
}
/**
* Removes any temporary lock file a lock-liveness case left behind.
*/
public function tearDown()
{
if ($this->lock_path !== "" && is_file($this->lock_path)) {
unlink($this->lock_path);
}
}
/**
* On a non-Windows host the launch command runs the program in
* the background, taking no input and sending output where asked.
* It must not wrap the program in a descriptor-closing shell
* loop: that loop also closed the launching shell's own working
* descriptors and killed the daemon before it could start.
*/
public function posixJobIsPlainBackgroundLaunchTestCase()
{
$job = CrawlDaemon::composeBackgroundJob(
"php /tmp/Daemon.php child", "/tmp/out.log", false);
$this->assertEqual(
"php /tmp/Daemon.php child < /dev/null > /tmp/out.log &",
$job, 'the program runs in the background, reading '.
'nothing and writing to the given file');
$this->assertTrue(
!str_contains($job, '>&-'),
'the launch does not close inherited descriptors in a loop');
}
/**
* Reports that forking is available exactly when all three of the
* process-control functions it depends on are present, so the
* launcher only takes the fork path on a host that can carry it
* out and otherwise falls back to a background shell command.
*/
public function canForkLaunchMatchesFunctionPresenceTestCase()
{
$expected = function_exists("pcntl_fork")
&& function_exists("pcntl_exec")
&& function_exists("posix_setsid");
$this->assertEqual($expected, CrawlDaemon::canForkLaunch(),
'forking is reported available only when every '.
'process-control function it needs is present');
}
/**
* On Windows the child is started without the launcher's socket
* handles, so the command is the plain background-start line with
* no descriptor closing.
*/
public function windowsJobUsesPlainStartTestCase()
{
$job = CrawlDaemon::composeBackgroundJob(
"php /tmp/Daemon.php child", "/tmp/out.log", true);
$this->assertEqual("start /B php /tmp/Daemon.php child ",
$job, 'Windows uses a plain background start line');
$this->assertTrue(
!str_contains($job, '>&-'),
'Windows does not close descriptors, none are inherited');
}
/**
* Only daemons listed as safe to cut off may be interrupted with
* a signal on stop; a daemon that writes long-lived index data is
* never signalled mid-job, and the listed one may be signalled
* only where the signal-sending function is present.
*/
public function onlyListedDaemonsMayBeInterruptedTestCase()
{
$this->assertTrue(
!CrawlDaemon::mayInterruptOnStop("QueueServer"),
'a queue server is never interrupted mid-job');
$this->assertTrue(
!CrawlDaemon::mayInterruptOnStop("Fetcher"),
'a fetcher is never interrupted mid-job');
$this->assertEqual(
function_exists("posix_kill"),
CrawlDaemon::mayInterruptOnStop("MediaUpdater"),
'MediaUpdater may be interrupted where signalling exists');
}
/**
* A lock file that does not exist is held by no one, so a fresh
* start is never blocked by a missing lock.
*/
public function absentLockNotHeldTestCase()
{
$this->lock_path = sys_get_temp_dir() . "/cd_lock_" .
uniqid("", true) . ".txt";
$this->assertTrue(
!CrawlDaemon::lockHeldByLiveProcess($this->lock_path),
'a lock file that is not there is not held');
}
/**
* A lock recording the id of a process that is actually running is
* reported as held, so a real running server still blocks a second
* start.
*/
public function livePidLockHeldTestCase()
{
$this->lock_path = sys_get_temp_dir() . "/cd_lock_" .
uniqid("", true) . ".txt";
file_put_contents($this->lock_path, time() . " " . getmypid());
$this->assertTrue(
CrawlDaemon::lockHeldByLiveProcess($this->lock_path),
'a lock with a live process id is held');
}
/**
* A lock recording the id of a process that has ended is reported
* as not held even when its timestamp is recent, so a server that
* crashed or was Ctrl-C'd does not block the next start.
*/
public function deadPidLockNotHeldTestCase()
{
$this->lock_path = sys_get_temp_dir() . "/cd_lock_" .
uniqid("", true) . ".txt";
$dead_process_id = 2147483646;
file_put_contents($this->lock_path,
time() . " " . $dead_process_id);
$this->assertTrue(
!CrawlDaemon::lockHeldByLiveProcess($this->lock_path),
'a fresh lock whose process has ended is not held');
}
/**
* A lock with no recorded process id (an older release) falls back
* to its timestamp: a recent one is treated as still held.
*/
public function noProcessIdFreshLockHeldTestCase()
{
$this->lock_path = sys_get_temp_dir() . "/cd_lock_" .
uniqid("", true) . ".txt";
file_put_contents($this->lock_path, (string)time());
$this->assertTrue(
CrawlDaemon::lockHeldByLiveProcess($this->lock_path),
'a recent lock with no process id is treated as held');
}
/**
* A lock with no recorded process id and an old timestamp falls
* back to being treated as stale, so the next start is not blocked.
*/
public function noProcessIdStaleLockNotHeldTestCase()
{
$this->lock_path = sys_get_temp_dir() . "/cd_lock_" .
uniqid("", true) . ".txt";
file_put_contents($this->lock_path, (string)(time() - 100000));
$this->assertTrue(
!CrawlDaemon::lockHeldByLiveProcess($this->lock_path),
'an old lock with no process id is treated as stale');
}
}