diff --git a/bot.php b/bot.php index 82da2fc8..ecfe7686 100644 --- a/bot.php +++ b/bot.php @@ -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(); } } diff --git a/docs/windows-test1714.md b/docs/windows-test1714.md index 5c4be59f..b6b60d53 100644 --- a/docs/windows-test1714.md +++ b/docs/windows-test1714.md @@ -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. diff --git a/scripts/windows/control.php b/scripts/windows/control.php index 3c6b8aad..f0458fd8 100644 --- a/scripts/windows/control.php +++ b/scripts/windows/control.php @@ -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); } diff --git a/src/Civ13/Civ13.php b/src/Civ13/Civ13.php index 8adb8df3..61e940b1 100644 --- a/src/Civ13/Civ13.php +++ b/src/Civ13/Civ13.php @@ -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 */ public readonly array $faction_ids; @@ -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); @@ -500,6 +503,7 @@ private function resolveOptions(array &$options = []): array 'moderate', 'ooc_badwords', 'owner_id', + 'portable_state_dir', 'rules', 'restart_mode', 'civ13_server_settings', @@ -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' => [], @@ -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]) diff --git a/src/Civ13/MessageCommand/Commands/ReloadConfig.php b/src/Civ13/MessageCommand/Commands/ReloadConfig.php new file mode 100644 index 00000000..e94b9b54 --- /dev/null +++ b/src/Civ13/MessageCommand/Commands/ReloadConfig.php @@ -0,0 +1,50 @@ + + * + * 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()); + } +} diff --git a/src/Civ13/MessageServiceManager.php b/src/Civ13/MessageServiceManager.php index de7758c5..2e2b6ae3 100644 --- a/src/Civ13/MessageServiceManager.php +++ b/src/Civ13/MessageServiceManager.php @@ -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']) diff --git a/src/Civ13/PortableProcess.php b/src/Civ13/PortableProcess.php index 90fee1be..55d3c18c 100644 --- a/src/Civ13/PortableProcess.php +++ b/src/Civ13/PortableProcess.php @@ -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'); + } } diff --git a/src/Civ13/PortableProfile.php b/src/Civ13/PortableProfile.php index 5bb042c4..7ee8375a 100644 --- a/src/Civ13/PortableProfile.php +++ b/src/Civ13/PortableProfile.php @@ -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; diff --git a/tests/windows/process.php b/tests/windows/process.php index cf16326e..8e899161 100644 --- a/tests/windows/process.php +++ b/tests/windows/process.php @@ -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); }