SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ 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\configs as C;
use seekquarry\yioop\library as L;
use seekquarry\yioop\library\CrawlDaemon;
use seekquarry\yioop\library\UnitTest;
use seekquarry\yioop\models\Model;

/**
 * CrawlDaemonTest checks how CrawlDaemon builds the command line that
 * launches a background daemon, how it reads a lock file, and what a
 * daemon does on its first process check. The point 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 launched.
 *
 * @author Chris Pollett
 */
class CrawlDaemonTest extends UnitTest
{
    /**
     * $lock_path holds the path of a temporary lock file a lock case
     * writes, and is empty when no such file is outstanding.
     * @var string
     */
    public $lock_path = "";
    /**
     * setUp points CrawlDaemon at SCHEDULES, a folder of this test's own
     * in the tests folder, before each case, so the lock, origin and
     * message files the cases write are not those of the site's daemons.
     */
    public function setUp()
    {
        L\makePath(self::SCHEDULES);
        CrawlDaemon::$schedules_folder = self::SCHEDULES;
    }
    /**
     * tearDown removes any temporary lock file a lock case left behind,
     * deletes SCHEDULES and points CrawlDaemon back at the work
     * directory's schedules folder.
     */
    public function tearDown()
    {
        if ($this->lock_path !== "" && is_file($this->lock_path)) {
            unlink($this->lock_path);
        }
        (new Model())->db->unlinkRecursive(self::SCHEDULES);
        CrawlDaemon::$schedules_folder = C\SCHEDULES_DIR;
    }
    /**
     * SCHEDULES is the folder, under tests/test_files, that the cases have
     * CrawlDaemon keep its daemons' files in.
     */
    const SCHEDULES = C\TEST_DIR . "/test_files/crawl_daemon_schedules";
    /**
     * posixJobIsPlainBackgroundLaunchTestCase checks that 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' 2>&1 &",
            $job, 'the program runs in the background, reading '.
            'nothing and adding what it writes to the given file');
        $this->assertTrue(
            !str_contains($job, '>&-'),
            'the launch does not close inherited descriptors in a loop');
    }
    /**
     * windowsJobUsesPlainStartTestCase checks that 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');
    }
    /**
     * startedProgramsWriteToIndexLogTestCase checks that a start sends a
     * program's output to index.log in the log folder, rather than to
     * /dev/null, so a program that fails to load says why there.
     */
    public function startedProgramsWriteToIndexLogTestCase()
    {
        $this->assertEqual(CrawlDaemon::startOutput(),
            C\LOG_DIR . "/index.log", "a start sends a program's output " .
            "to index.log");
    }
    /**
     * indexServerUpWaitsForAnAnswerTestCase checks how a start of the web
     * server tells that the server came up, with no file kept for it: it
     * connects to the address the server answers on. An address nothing
     * answers on is not up after the wait; one a listener answers on is.
     * Before, it read IndexServerPort.txt, a file the server wrote at each
     * start, and with no such file it never saw the server come up. A
     * site that ran that version may still have the file, so the case
     * checks that the wait does not write it, not that it is absent.
     */
    public function indexServerUpWaitsForAnAnswerTestCase()
    {
        $ports_file = C\DATA_DIR . "/IndexServerPort.txt";
        $ports_before = file_exists($ports_file) ?
            filemtime($ports_file) : false;
        $listener = stream_socket_server("tcp://127.0.0.1:0");
        $address = stream_socket_get_name($listener, false);
        $this->assertTrue(
            CrawlDaemon::indexServerUp($address, 0.05, 10000),
            "an address a listener answers on is up");
        fclose($listener);
        $this->assertTrue(
            !CrawlDaemon::indexServerUp($address, 0.05, 10000),
            "an address nothing answers on is not up");
        clearstatcache();
        $this->assertEqual(file_exists($ports_file) ?
            filemtime($ports_file) : false, $ports_before,
            "no file of ports is written");
    }
    /**
     * onlyListedDaemonsMayBeInterruptedTestCase checks that 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');
    }
    /**
     * absentLockNotHeldTestCase checks that 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 = C\TEST_DIR . "/test_files/cd_lock_" .
            uniqid("", true) . ".txt";
        $this->assertTrue(
            !CrawlDaemon::lockHeldByLiveProcess($this->lock_path),
            'a lock file that is not there is not held');
    }
    /**
     * livePidLockHeldTestCase checks that a lock recording the id of a
     * process that is running is reported as held, so a running server
     * still blocks a second start.
     */
    public function livePidLockHeldTestCase()
    {
        $this->lock_path = C\TEST_DIR . "/test_files/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');
    }
    /**
     * deadPidLockNotHeldTestCase checks that 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 stopped from
     * the keyboard does not block the next start.
     */
    public function deadPidLockNotHeldTestCase()
    {
        $this->lock_path = C\TEST_DIR . "/test_files/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');
    }
    /**
     * windowsProcessIdsReadTasklistRowsTestCase checks how
     * windowsProcessIds reads the rows tasklist writes on Windows with
     * /FO CSV /NH, which isProcessAlive uses there in place of
     * posix_kill. Each row is quoted and comma-separated, with the
     * program's name and then its id, and a memory column that holds a
     * comma of its own. A row that is not a process, such as the line
     * tasklist writes when it lists nothing, gives no id.
     */
    public function windowsProcessIdsReadTasklistRowsTestCase()
    {
        $rows = ['"System Idle Process","0","Services","0","8 K"',
            '"php.exe","4312","Console","1","25,140 K"'];
        $process_ids = CrawlDaemon::windowsProcessIds($rows);
        $this->assertEqual($process_ids, [0 => true, 4312 => true],
            "each row's second field is read as a process id");
        $this->assertTrue(!isset($process_ids[25]),
            "the memory column's comma does not make an id");
        $this->assertEqual(CrawlDaemon::windowsProcessIds(
            ["INFO: No tasks are running which match the " .
            "specified criteria."]), null,
            "a list with no process rows gives no list");
    }
    /**
     * noProcessIdFreshLockHeldTestCase checks that a lock with no recorded
     * process id, as an older release wrote, falls back to its timestamp:
     * a recent one is treated as still held.
     */
    public function noProcessIdFreshLockHeldTestCase()
    {
        $this->lock_path = C\TEST_DIR . "/test_files/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');
    }
    /**
     * noProcessIdStaleLockNotHeldTestCase checks that a lock with no
     * recorded process id and an old timestamp is treated as stale, so the
     * next start is not blocked.
     */
    public function noProcessIdStaleLockNotHeldTestCase()
    {
        $this->lock_path = C\TEST_DIR . "/test_files/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');
    }
    /**
     * relayIsAmongTheDaemonsBroughtBackTestCase checks that the turn
     * server sits in the list the own web server stops and relaunches on
     * a restart, beside the media updater and the mail server, since a
     * restart that left it out orphaned the running relay rather than
     * bringing it back.
     */
    public function relayIsAmongTheDaemonsBroughtBackTestCase()
    {
        $this->assertTrue(
            in_array("TurnServer", CrawlDaemon::RESTART_DAEMONS, true),
            'the turn server is brought back on a restart');
        $this->assertTrue(
            in_array("MediaUpdater", CrawlDaemon::RESTART_DAEMONS, true),
            'the media updater is still brought back');
        $this->assertTrue(
            in_array("MailServer", CrawlDaemon::RESTART_DAEMONS, true),
            'the mail server is still brought back');
    }
    /**
     * freshDaemonWritesItsOwnLockTestCase checks that a daemon whose first
     * process check finds no lock file writes one and carries on rather
     * than stopping. The check runs in this process, as daemon DaemonTest
     * with subname Half; a check that stopped the daemon would end the
     * test run here.
     */
    public function freshDaemonWritesItsOwnLockTestCase()
    {
        $lock_file = CrawlDaemon::getLockFileName("DaemonTest", "Half");
        if (file_exists($lock_file)) {
            unlink($lock_file);
        }
        $daemon = [CrawlDaemon::$name, CrawlDaemon::$subname,
            CrawlDaemon::$mode];
        CrawlDaemon::$name = "DaemonTest";
        CrawlDaemon::$subname = "Half";
        CrawlDaemon::$mode = "daemon";
        CrawlDaemon::processHandler();
        [CrawlDaemon::$name, CrawlDaemon::$subname, CrawlDaemon::$mode] =
            $daemon;
        $this->assertTrue(true,
            "a first check with no lock does not stop the daemon");
        $this->assertTrue(file_exists($lock_file),
            "and the daemon wrote the lock itself");
        if (file_exists($lock_file)) {
            unlink($lock_file);
        }
        $origin = CrawlDaemon::getOriginFileName("DaemonTest", "Half");
        if (file_exists($origin)) {
            unlink($origin);
        }
    }
    /**
     * stopLeavesAYoungerDaemonAloneTestCase checks that a stop does not
     * end a daemon started after the stop was asked for. Stopping a
     * whole site takes long enough that another part of it can start
     * the same daemon again meanwhile, and the daemon that then goes
     * missing is the new one, which nobody asked to stop.
     */
    public function stopLeavesAYoungerDaemonAloneTestCase()
    {
        $lock_file = CrawlDaemon::getLockFileName("MailServer", "");
        $asked_at = time() - 10;
        CrawlDaemon::writeLockFile($lock_file, time());
        CrawlDaemon::stop("MailServer", "", false, $asked_at);
        $this->assertTrue(file_exists($lock_file),
            "a lock written after the stop was asked for is left");
        unlink($lock_file);
    }
    /**
     * stopEndsADaemonOlderThanItselfTestCase checks that a stop still
     * ends a daemon that was there when the stop was asked for, which
     * is every ordinary stop.
     */
    public function stopEndsADaemonOlderThanItselfTestCase()
    {
        $lock_file = CrawlDaemon::getLockFileName("MailServer", "");
        CrawlDaemon::writeLockFile($lock_file, time() - 30);
        CrawlDaemon::stop("MailServer", "", false, time());
        $this->assertFalse(file_exists($lock_file),
            "a lock older than the stop is taken away");
        @unlink(CrawlDaemon::$schedules_folder . "/MailServer" .
            CrawlDaemon::STOP_ASKED_NAME);
    }
    /**
     * daemonStartingIsNotCalledStoppedTestCase checks that a daemon
     * just started, whose lock holds a time and no process number yet,
     * is called starting rather than stopped. Calling it stopped told a
     * reader to go looking for a fault in a service that was coming up
     * perfectly well.
     */
    public function daemonStartingIsNotCalledStoppedTestCase()
    {
        $lock_file = CrawlDaemon::getLockFileName("MailServer", "");
        CrawlDaemon::writeLockFile($lock_file, time(), false);
        $this->assertEqual(CrawlDaemon::daemonState("MailServer"),
            CrawlDaemon::DAEMON_STARTING,
            "a lock with a fresh time and no number is starting");
        unlink($lock_file);
        $this->assertEqual(CrawlDaemon::daemonState("MailServer"),
            CrawlDaemon::DAEMON_STOPPED,
            "no lock at all is stopped");
    }
    /**
     * portHoldersNamesThisProcessTestCase opens a listening socket on a
     * free loopback port in this process, then checks that portHolders
     * names this process as holding it, and names no one once it is
     * closed. portHolders reads /proc, which only Linux has, so elsewhere
     * the case checks only that it finds no one.
     */
    public function portHoldersNamesThisProcessTestCase()
    {
        $listener = stream_socket_server("tcp://127.0.0.1:0");
        $port = intval(substr(strrchr(stream_socket_get_name($listener,
            false), ":"), 1));
        $holders = CrawlDaemon::portHolders($port);
        if (is_readable("/proc/net/tcp")) {
            $this->assertTrue(isset($holders[getmypid()]),
                "the process holding the port is named");
        } else {
            $this->assertEqual($holders, [],
                "without /proc no one is named");
        }
        fclose($listener);
        $this->assertEqual(CrawlDaemon::portHolders($port), [],
            "a closed port is held by no one");
    }
}