Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions bot.php
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,8 @@ function loadEnv(string $filePath, bool $explicit = false): array
if ($portable !== null) {
Loop::addPeriodicTimer(1, static function () use (&$civ13, $portable): void {
if (is_file($portable['state_dir'].'/stop.request')) {
// A stop cancels any pending reloadconfig, so the supervisor does not relaunch.
\Civ13\PortableProcess::consumeReload($portable['state_dir']);
unlink($portable['state_dir'].'/stop.request');
if ($civ13 !== null) { $civ13->stop(); } else { Loop::stop(); }
}
Expand Down
4 changes: 4 additions & 0 deletions docs/windows-test1714.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ Logs append to state/output.log, stdout.log and stderr.log. Protect this directo
with operator-only filesystem permissions and apply an external retention policy.
Restart commands exit gracefully in portable mode; the operator relaunches the
foreground supervisor. No self-spawning sudo/nohup path is used.
The Ambassador `reloadconfig` chat command writes a one-use `reload.request`
marker and stops the bot; `control.php start` then relaunches it, rereading the
profile and environment file after rechecking the profile and source. A stale
marker is cleared at start, and a pending stop request takes precedence.

Portable construction skips public-IP discovery, so startup does not depend on
ipecho.net or grant that discovered address local trust. Linux defaults are unchanged.
Expand Down
34 changes: 28 additions & 6 deletions scripts/windows/control.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,33 @@
}
\Civ13\DefaultBranchGuard::verify($profile['root']);
if (is_file($profile['state_dir'].'/stop.request')) { throw new RuntimeException('Unresolved stop request; inspect previous instance before starting'); }
// Clear a stale reload request only when no instance holds the lock; a live
// instance's pending reload must survive a second start attempt.
$lock=PortableProcess::lock($profile['state_dir']);
PortableProcess::consumeReload($profile['state_dir']);
fclose($lock);
putenv('CIVILIZATIONBOT_PROFILE='.$argv[2]);
$pipes=[];
$process=PortableProcess::start(PortableProcess::argv(PHP_BINARY,$profile['root']),$profile['state_dir'],static function($args,$descriptors,$cwd,$options) use (&$pipes) { return proc_open($args,$descriptors,$pipes,$cwd,null,$options); });
if (!is_resource($process)) { throw new RuntimeException('Child process creation failed'); }
fclose($pipes[0]);
// Foreground supervision avoids unowned background PHP copies; Ctrl+C is not a game stop.
exit(proc_close($process));
do {
$pipes=[];
$process=PortableProcess::start(PortableProcess::argv(PHP_BINARY,$profile['root']),$profile['state_dir'],static function($args,$descriptors,$cwd,$options) use (&$pipes) { return proc_open($args,$descriptors,$pipes,$cwd,null,$options); });
if (!is_resource($process)) { throw new RuntimeException('Child process creation failed'); }
fclose($pipes[0]);
// Foreground supervision avoids unowned background PHP copies; Ctrl+C is not a game stop.
$exit=proc_close($process);
// reloadconfig: relaunch so the child rereads the profile and environment. Hold the
// instance lock while deciding, so a concurrent start cannot take the marker; if
// another instance already owns the lock, it is the live bot and this one stands down.
try { $lock=PortableProcess::lock($profile['state_dir']); } catch (RuntimeException) { $lock=null; }
$reload=$lock!==null && PortableProcess::consumeReload($profile['state_dir']) && !is_file($profile['state_dir'].'/stop.request');
if ($lock!==null) { fclose($lock); }
// Recheck the profile and source first, so a broken edit stops here instead of looping.
if ($reload) {
$profile=PortableProfile::load($argv[2]);
PortableProfile::assertRoot($profile, dirname(__DIR__, 2));
$errors=PortableProfile::preflight($profile,extension_loaded(...));
if ($errors!==[]) { throw new RuntimeException('Reload refused: '.implode('; ',$errors)); }
\Civ13\DefaultBranchGuard::verify($profile['root']);
}
} while ($reload);
exit($exit);
} catch (Throwable $error) { fwrite(STDERR,'Portable control failed: '.$error->getMessage().PHP_EOL); exit(1); }
6 changes: 6 additions & 0 deletions src/Civ13/Civ13.php
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ class Civ13
public const array faction_admins = ['Faction Organizer'];

public readonly string $gitdir;
/** Portable profile state directory; empty outside portable mode. */
public string $portable_state_dir = '';
/** @var array<string|null> */
public readonly array $faction_ids;

Expand Down Expand Up @@ -280,6 +282,7 @@ public function __construct(
// Set the file that the object was constructed in
$this->constructed_file = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 1)[0]['file'];
$this->restartPolicy = new RestartPolicy($options['restart_mode'] ?? 'legacy');
$this->portable_state_dir = $options['portable_state_dir'] ?? '';

$this->logger = $options['logger'] ?? $this->discord->getLogger() ?? new Logger(self::class, [new StreamHandler('php://stdout', Level::Debug)]);
$options = $this->resolveOptions($options);
Expand Down Expand Up @@ -500,6 +503,7 @@ private function resolveOptions(array &$options = []): array
'moderate',
'ooc_badwords',
'owner_id',
'portable_state_dir',
'rules',
'restart_mode',
'civ13_server_settings',
Expand Down Expand Up @@ -545,6 +549,7 @@ private function resolveOptions(array &$options = []): array
'moderate' => true,
'ooc_badwords' => [],
'owner_id' => '196253985072611328',
'portable_state_dir' => '',
'rules' => 'civ13.com slash rules',
'restart_mode' => 'legacy',
'civ13_server_settings' => [],
Expand All @@ -560,6 +565,7 @@ private function resolveOptions(array &$options = []): array
->setAllowedTypes('welcome_message', 'string')
->setAllowedTypes('safehouse_enabled', 'bool')
->setAllowedTypes('safehouse_gateway', ['null', \Civ13\Safehouse\AsyncGateway::class])
->setAllowedTypes('portable_state_dir', 'string')
->setAllowedTypes('restart_mode', 'string')
->setAllowedValues('restart_mode', ['legacy', 'supervised'])
->setAllowedTypes('logger', ['null', Logger::class])
Expand Down
50 changes: 50 additions & 0 deletions src/Civ13/MessageCommand/Commands/ReloadConfig.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

/*
* This file is a part of the Civilizationbot project.
*
* Copyright (c) 2021-present Valithor Obsidion <valithor@civ13.org>
*
* This file is subject to the MIT license that is bundled
* with this source code in the LICENSE.md file.
*/

namespace Civ13\MessageCommand\Commands;

use Civ13\MessageCommand\Civ13MessageCommand;
use Civ13\PortableProcess;
use Discord\Parts\Channel\Message;
use React\Promise\PromiseInterface;

/**
* Handles the "reloadconfig" command.
*
* Portable profiles only: asks the foreground control.php supervisor to relaunch
* the bot, which rereads the profile and environment file, then stops gracefully.
*/
class ReloadConfig extends Civ13MessageCommand
{
/**
* @inheritDoc
*/
public function __invoke(Message $message, string $command, array $message_filtered): PromiseInterface
{
if ($this->civ13->portable_state_dir === '') {
return $this->civ13->reply($message, 'Configuration reload is only available for portable profiles.');
}
if (is_file($this->civ13->portable_state_dir.'/stop.request')) {
return $this->civ13->reply($message, 'A stop is pending; configuration reload was not requested.');
}
try {
PortableProcess::requestReload($this->civ13->portable_state_dir);
} catch (\Throwable $error) {
return $this->civ13->reply($message, 'Configuration reload could not be requested: '.$error->getMessage());
}

// Stop even if the reply fails, so the reload marker never outlives this request.
return $this->civ13->reply($message, 'Reloading configuration; the bot will reconnect shortly.')
->finally(fn () => $this->civ13->stop());
}
}
1 change: 1 addition & 0 deletions src/Civ13/MessageServiceManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,7 @@ private function __generateGlobalMessageCommands(): void
->offsetSet('togglerelaymethod', new Commands\RelayMethodToggle($this->civ13), ['Ambassador'])
->offsetSet('listrounds', new Commands\ListRounds($this->civ13), ['Ambassador'])
->offsetSet('updateadmins', new Commands\AdminListUpdate($this->civ13), ['Ambassador'])
->offsetSet('reloadconfig', new Commands\ReloadConfig($this->civ13), ['Ambassador'])
->offsetSet('pullrepo', new Commands\PullCivRepository($this->civ13), ['Ambassador'])
->offsetSet('byondage', new Commands\ByondAge($this->civ13), ['Ambassador'])
->offsetSet('listverified', new Commands\ListVerified($this->civ13), ['Admin'])
Expand Down
14 changes: 14 additions & 0 deletions src/Civ13/PortableProcess.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,18 @@ public static function requestStop(string $state): void
if ($stream === false) { throw new \RuntimeException('Stop already requested or state unavailable'); }
fclose($stream);
}

/** Ask the foreground supervisor to relaunch after this child exits, rereading the profile. */
public static function requestReload(string $state): void
{
$stream = @fopen($state.'/reload.request', 'x');
if ($stream === false) { throw new \RuntimeException('Reload already requested or state unavailable'); }
fclose($stream);
}

/** One-use: true only if a reload was requested and the marker was removed. */
public static function consumeReload(string $state): bool
{
return is_file($state.'/reload.request') && unlink($state.'/reload.request');
}
}
1 change: 1 addition & 0 deletions src/Civ13/PortableProfile.php
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ public static function runtimeOptions(array $profile): array
}
$options['gitdir'] = $profile['gitdir'];
$options['filecache_path'] = $profile['state_dir'].'/json/';
$options['portable_state_dir'] = $profile['state_dir'];
$options['discover_external_ip'] = false;
$options['verify_url'] = '';
$options['verification_read_only'] = false;
Expand Down
7 changes: 6 additions & 1 deletion tests/windows/process.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,12 @@
if ($exit !== 23 || is_file($dir.'/stop.request')) { throw new RuntimeException('Child exit/stop marker mismatch'); }
if (!str_contains(file_get_contents($dir.'/stdout.log'), 'stdout fixture') || !str_contains(file_get_contents($dir.'/stderr.log'), 'stderr fixture')) { throw new RuntimeException('Child log redirection failed'); }
$lock = PortableProcess::lock($dir); fclose($lock);
echo "PASS real PHP child: literal argv, spaced path, log redirection, cross-process locking, stop and exit status\n";
if (PortableProcess::consumeReload($dir)) { throw new RuntimeException('Reload consumed without a request'); }
PortableProcess::requestReload($dir);
try { PortableProcess::requestReload($dir); throw new LogicException('Duplicate reload request accepted'); }
catch (RuntimeException $error) { if ($error->getMessage() !== 'Reload already requested or state unavailable') { throw $error; } }
if (!PortableProcess::consumeReload($dir) || PortableProcess::consumeReload($dir) || is_file($dir.'/reload.request')) { throw new RuntimeException('Reload marker is not one-use'); }
echo "PASS real PHP child: literal argv, spaced path, log redirection, cross-process locking, stop, one-use reload marker and exit status\n";
} finally {
// Only the exact child handle created by this test can be terminated on failure.
if (is_resource($process)) { proc_terminate($process); proc_close($process); }
Expand Down
Loading