/ tests / CrawlDaemonTest.php
<?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');
    }
}
X