From c8a47eeee549bc6f75464c1ea64c7290f7e0af31 Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Tue, 27 Oct 2026 23:28:27 -0400 Subject: [PATCH 1/3] Add reloadconfig command for portable profiles Ambassadors can run reloadconfig to reread the portable profile and environment file without shell access. The command writes a one-use reload.request marker in the profile state directory and stops the bot; control.php start relaunches it after rechecking the profile, preflight and source. A stale marker is cleared at start and a pending stop request wins. Outside portable mode the command reports that it is unavailable. Co-Authored-By: Claude Opus 5.5 --- docs/windows-test1714.md | 4 ++ scripts/windows/control.php | 26 ++++++++--- src/Civ13/Civ13.php | 6 +++ .../MessageCommand/Commands/ReloadConfig.php | 46 +++++++++++++++++++ src/Civ13/MessageServiceManager.php | 1 + src/Civ13/PortableProcess.php | 14 ++++++ src/Civ13/PortableProfile.php | 1 + tests/windows/process.php | 7 ++- 8 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 src/Civ13/MessageCommand/Commands/ReloadConfig.php 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..7f0f12fa 100644 --- a/scripts/windows/control.php +++ b/scripts/windows/control.php @@ -21,11 +21,25 @@ } \Civ13\DefaultBranchGuard::verify($profile['root']); if (is_file($profile['state_dir'].'/stop.request')) { throw new RuntimeException('Unresolved stop request; inspect previous instance before starting'); } + PortableProcess::consumeReload($profile['state_dir']); // A stale request must not cause a later relaunch. 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. Recheck + // the profile and source first, so a broken edit stops here instead of looping. + $reload=PortableProcess::consumeReload($profile['state_dir']) && !is_file($profile['state_dir'].'/stop.request'); + 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..2de6df27 --- /dev/null +++ b/src/Civ13/MessageCommand/Commands/ReloadConfig.php @@ -0,0 +1,46 @@ + + * + * 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.'); + } + try { + PortableProcess::requestReload($this->civ13->portable_state_dir); + } catch (\Throwable $error) { + return $this->civ13->reply($message, 'Configuration reload could not be requested: '.$error->getMessage()); + } + + return $this->civ13->reply($message, 'Reloading configuration; the bot will reconnect shortly.') + ->then(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); } From 81daa9c2c080d5a27adddf103e827fb46b58a65d Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Tue, 27 Oct 2026 23:35:50 -0400 Subject: [PATCH 2/3] Keep reload requests safe across concurrent start, stop and failed replies - control.php start takes the instance lock before clearing a stale reload marker, so a second start refuses instead of deleting a live instance's pending reload. - The bot's stop.request handler cancels any pending reload before stopping, so the supervisor never relaunches after a stop. - reloadconfig refuses while a stop is pending and stops the bot whether or not the Discord reply succeeds, so the marker cannot linger. Addresses CodeRabbit review on #35. Co-Authored-By: Claude Opus 5.5 --- bot.php | 2 ++ scripts/windows/control.php | 6 +++++- src/Civ13/MessageCommand/Commands/ReloadConfig.php | 6 +++++- 3 files changed, 12 insertions(+), 2 deletions(-) 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/scripts/windows/control.php b/scripts/windows/control.php index 7f0f12fa..d1689be8 100644 --- a/scripts/windows/control.php +++ b/scripts/windows/control.php @@ -21,7 +21,11 @@ } \Civ13\DefaultBranchGuard::verify($profile['root']); if (is_file($profile['state_dir'].'/stop.request')) { throw new RuntimeException('Unresolved stop request; inspect previous instance before starting'); } - PortableProcess::consumeReload($profile['state_dir']); // A stale request must not cause a later relaunch. + // 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]); do { $pipes=[]; diff --git a/src/Civ13/MessageCommand/Commands/ReloadConfig.php b/src/Civ13/MessageCommand/Commands/ReloadConfig.php index 2de6df27..e94b9b54 100644 --- a/src/Civ13/MessageCommand/Commands/ReloadConfig.php +++ b/src/Civ13/MessageCommand/Commands/ReloadConfig.php @@ -34,13 +34,17 @@ public function __invoke(Message $message, string $command, array $message_filte 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.') - ->then(fn () => $this->civ13->stop()); + ->finally(fn () => $this->civ13->stop()); } } From db6872d1245a7eb2ba5fe41c5b0faeebb376885e Mon Sep 17 00:00:00 2001 From: Valithor Obsidion Date: Tue, 27 Oct 2026 23:56:01 -0400 Subject: [PATCH 3/3] Hold the instance lock across the post-exit reload decision After the child exits, control.php now takes the instance lock before consuming reload.request and checking stop.request, so a concurrent start cannot take the marker in between. If another instance already holds the lock, this supervisor stands down instead of relaunching. Addresses the remaining CodeRabbit comment on #35. Co-Authored-By: Claude Opus 5.5 --- scripts/windows/control.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/scripts/windows/control.php b/scripts/windows/control.php index d1689be8..f0458fd8 100644 --- a/scripts/windows/control.php +++ b/scripts/windows/control.php @@ -34,9 +34,13 @@ 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. Recheck - // the profile and source first, so a broken edit stops here instead of looping. - $reload=PortableProcess::consumeReload($profile['state_dir']) && !is_file($profile['state_dir'].'/stop.request'); + // 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));