From 8164b156ca5a77e78447a094c4ba1c69c27a783e Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Wed, 14 Oct 2026 11:58:38 +0600 Subject: [PATCH 1/2] (feat) deliver domain notifications only after a transaction commits --- .../Console/Commands/MakeSignalCommand.php | 128 +++++ src/Phaseolies/Database/Database.php | 108 ++++- src/Phaseolies/Helpers/helpers.php | 19 + src/Phaseolies/Launchers/FacadeLauncher.php | 5 + .../Signals/Attributes/DeliverOnSettle.php | 12 + src/Phaseolies/Signals/SignalBus.php | 315 ++++++++++++ src/Phaseolies/Support/Facades/Signal.php | 24 + tests/Console/CommandBehaviorCoverageTest.php | 98 ++++ tests/Signals/SettledSignalTest.php | 452 ++++++++++++++++++ tests/Signals/SignalBusTest.php | 190 ++++++++ 10 files changed, 1346 insertions(+), 5 deletions(-) create mode 100644 src/Phaseolies/Console/Commands/MakeSignalCommand.php create mode 100644 src/Phaseolies/Signals/Attributes/DeliverOnSettle.php create mode 100644 src/Phaseolies/Signals/SignalBus.php create mode 100644 src/Phaseolies/Support/Facades/Signal.php create mode 100644 tests/Signals/SettledSignalTest.php create mode 100644 tests/Signals/SignalBusTest.php diff --git a/src/Phaseolies/Console/Commands/MakeSignalCommand.php b/src/Phaseolies/Console/Commands/MakeSignalCommand.php new file mode 100644 index 00000000..bdeaa9af --- /dev/null +++ b/src/Phaseolies/Console/Commands/MakeSignalCommand.php @@ -0,0 +1,128 @@ +executeWithTiming(function () { + [$name, $parts, $className] = $this->splitGeneratedName((string) $this->argument('name')); + $connection = $this->option('connection'); + $connection = is_string($connection) && $connection !== '' ? $connection : null; + + if ($className === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $className)) { + $this->displayError('The signal name must be a valid class name.'); + return Command::FAILURE; + } + + if ($connection !== null && !preg_match('/^[A-Za-z0-9_.\-]+$/', $connection)) { + $this->displayError('The connection name may only contain letters, numbers, dots, dashes and underscores.'); + return Command::FAILURE; + } + + $namespace = 'App\\Signals' . (count($parts) > 0 ? '\\' . implode('\\', $parts) : ''); + $filePath = $this->generatedFilePath('src/Signals', $name); + + // Check if signal already exists + if (file_exists($filePath)) { + $this->displayError('Signal already exists at:'); + $this->line('' . $this->relativePath($filePath) . ''); + return Command::FAILURE; + } + + // Create directory if needed + $directoryPath = dirname($filePath); + if (!is_dir($directoryPath)) { + mkdir($directoryPath, 0755, true); + } + + // A connection only makes sense for a signal that waits for a transaction + $settle = (bool) $this->option('settle') || $connection !== null; + + // Generate and save signal class + $content = $this->generateSignalContent($namespace, $className, $settle, $connection); + file_put_contents($filePath, $content); + + $this->displaySuccess('Signal created successfully'); + $this->line('📁 File: ' . $this->relativePath($filePath) . ''); + $this->newLine(); + $this->line('📌 Signal Class: ' . $className . ''); + + if ($settle) { + $this->newLine(); + $this->line('⏳ Delivery: after the transaction commits' + . ($connection !== null ? " (connection: {$connection})" : '') . ''); + } + + return Command::SUCCESS; + }); + } + + /** + * Generate signal class content. + * + * @param string $namespace + * @param string $className + * @param bool $settle + * @param string|null $connection + * @return string + */ + protected function generateSignalContent( + string $namespace, + string $className, + bool $settle = false, + ?string $connection = null + ): string { + $import = ''; + $attribute = ''; + + if ($settle) { + $import = "\nuse Phaseolies\\Signals\\Attributes\\DeliverOnSettle;\n"; + $attribute = $connection !== null + ? "#[DeliverOnSettle(connection: '{$connection}')]\n" + : "#[DeliverOnSettle]\n"; + } + + return <<>> + */ + protected static $pendingSignals = []; + /** * The driver instances for each connection * @@ -200,12 +207,15 @@ public function commit(): void { $pdo = $this->getPdo(); $connection = $this->connection ?? config('database.default'); + $level = static::$transactions[$connection] ?? 0; - if (static::$transactions[$connection] === 1) { + if ($level === 1) { $pdo->commit(); } - static::$transactions[$connection] = max(0, static::$transactions[$connection] - 1); + static::$transactions[$connection] = max(0, $level - 1); + + static::settleSignals($connection, $level); } /** @@ -218,14 +228,99 @@ public function rollBack(): void { $pdo = $this->getPdo(); $connection = $this->connection ?? config('database.default'); + $level = static::$transactions[$connection] ?? 0; + + // Dropped first, so a rollback that itself fails can never leak them. + static::discardSignals($connection, $level); - if (static::$transactions[$connection] === 1) { + if ($level === 1) { $pdo->rollBack(); } else { - $pdo->exec("ROLLBACK TO SAVEPOINT trans" . static::$transactions[$connection]); + $pdo->exec("ROLLBACK TO SAVEPOINT trans" . $level); + } + + static::$transactions[$connection] = max(0, $level - 1); + } + + /** + * Hold a signal until the open transaction on a connection settles + * + * @param \Phaseolies\Signals\SignalBus $bus + * @param object $signal + * @param string|null $connection + * @return bool + */ + public static function stageSignal(\Phaseolies\Signals\SignalBus $bus, object $signal, ?string $connection = null): bool + { + $connection = $connection ?: config('database.default'); + $level = static::$transactions[$connection] ?? 0; + + if ($level === 0) { + return false; + } + + static::$pendingSignals[$connection][$level][] = [$bus, $signal]; + + return true; + } + + /** + * Hand the signals of a committed level to the parent, or deliver them at the outermost level + * + * @param string $connection + * @param int $level + * @return void + */ + protected static function settleSignals(string $connection, int $level): void + { + $signals = static::$pendingSignals[$connection][$level] ?? null; + + if ($signals === null) { + return; + } + + unset(static::$pendingSignals[$connection][$level]); + + if ($level > 1) { + static::$pendingSignals[$connection][$level - 1] = array_merge( + static::$pendingSignals[$connection][$level - 1] ?? [], + $signals + ); + + return; + } + + if (empty(static::$pendingSignals[$connection])) { + unset(static::$pendingSignals[$connection]); + } + + foreach ($signals as [$bus, $signal]) { + $bus->settle($signal); } + } - static::$transactions[$connection] = max(0, static::$transactions[$connection] - 1); + /** + * Drop the signals held by a level and every level inside it + * + * @param string $connection + * @param int $level + * @return void + */ + protected static function discardSignals(string $connection, int $level): void + { + if (!isset(static::$pendingSignals[$connection])) { + return; + } + + foreach (array_keys(static::$pendingSignals[$connection]) as $held) { + if ($held >= $level) { + unset(static::$pendingSignals[$connection][$held]); + } + } + + if (empty(static::$pendingSignals[$connection])) { + unset(static::$pendingSignals[$connection]); + } } /** @@ -631,6 +726,8 @@ public function disconnect(?string $connection = null): bool static::$transactions[$connection] = 0; } + unset(static::$pendingSignals[$connection]); + return true; } catch (\Throwable $e) { if (function_exists('error_log')) { @@ -714,5 +811,6 @@ public function cleanupAllConnections(): void static::$connections = []; static::$drivers = []; static::$transactions = []; + static::$pendingSignals = []; } } diff --git a/src/Phaseolies/Helpers/helpers.php b/src/Phaseolies/Helpers/helpers.php index 4b1bdb73..a42bf2f6 100644 --- a/src/Phaseolies/Helpers/helpers.php +++ b/src/Phaseolies/Helpers/helpers.php @@ -90,6 +90,25 @@ function defer(callable $callback) } } +if (!function_exists('signal')) { + /** + * Get the signal bus, or emit a signal when one is given + * + * @param object|null $signal + * @return \Phaseolies\Signals\SignalBus|void + */ + function signal(?object $signal = null) + { + $bus = app('signal'); + + if ($signal === null) { + return $bus; + } + + $bus->emit($signal); + } +} + if (!function_exists('request')) { /** * Creates a new request instance to handle HTTP requests. diff --git a/src/Phaseolies/Launchers/FacadeLauncher.php b/src/Phaseolies/Launchers/FacadeLauncher.php index 6cc2dbf7..1094ae8a 100644 --- a/src/Phaseolies/Launchers/FacadeLauncher.php +++ b/src/Phaseolies/Launchers/FacadeLauncher.php @@ -7,6 +7,7 @@ use Phaseolies\Support\ViteManager; use Phaseolies\Support\StringService; use Phaseolies\Support\Storage\StorageFileService; +use Phaseolies\Signals\SignalBus; use Phaseolies\Support\Session; use Phaseolies\Support\Mail\MailService; use Phaseolies\Support\LoggerService; @@ -105,6 +106,10 @@ public function register() // This handles db related jobs $this->app->singleton('db', Database::class); + // Bind the 'signal' service to a singleton instance of the SignalBus class. + // This handles signals that can wait for a database transaction to settle + $this->app->singleton('signal', SignalBus::class); + // Bind the 'app' service to a singleton instance of the Application class. // This handles app related jobs $this->app->singleton('app', Application::class); diff --git a/src/Phaseolies/Signals/Attributes/DeliverOnSettle.php b/src/Phaseolies/Signals/Attributes/DeliverOnSettle.php new file mode 100644 index 00000000..08b979d5 --- /dev/null +++ b/src/Phaseolies/Signals/Attributes/DeliverOnSettle.php @@ -0,0 +1,12 @@ +> + */ + protected array $listeners = []; + + /** + * Ordered listener lists already resolved for a concrete signal class + * + * @var array> + */ + protected array $resolved = []; + + /** + * Cached #[DeliverOnSettle] lookups, false when a class does not opt in + * + * @var array + */ + protected array $settleRules = []; + + /** + * Registration counter that keeps equal priorities in registration order + * + * @var int + */ + protected int $order = 0; + + /** + * Custom reporter for listener failures that happen after a commit + * + * @var callable|null + */ + protected $failureReporter = null; + + /** + * Register a listener for a signal class or interface. + * + * @param string $signal + * @param callable|array|string $listener + * @param int $priority + * @return self + * @throws \InvalidArgumentException + */ + public function on(string $signal, callable|array|string $listener, int $priority = 0): self + { + $this->assertListener($listener); + + $this->listeners[ltrim($signal, '\\')][] = [ + 'priority' => $priority, + 'order' => $this->order++, + 'listener' => $listener, + ]; + + $this->resolved = []; + + return $this; + } + + /** + * Remove every listener of a signal, or all listeners when none is given. + * + * @param string|null $signal + * @return self + */ + public function forget(?string $signal = null): self + { + if ($signal === null) { + $this->listeners = []; + } else { + unset($this->listeners[ltrim($signal, '\\')]); + } + + $this->resolved = []; + + return $this; + } + + /** + * Determine if any listener would receive the given signal. + * + * @param object|string $signal + * @return bool + */ + public function hasListeners(object|string $signal): bool + { + return $this->listenersFor(is_object($signal) ? $signal::class : ltrim($signal, '\\')) !== []; + } + + /** + * Emit a signal. + * + * @param object $signal + * @return void + */ + public function emit(object $signal): void + { + $rule = $this->settleRule($signal::class); + + if ($rule !== false && Database::stageSignal($this, $signal, $rule->connection)) { + return; + } + + $this->deliver($signal); + } + + /** + * Emit a signal right away, ignoring #[DeliverOnSettle] and any open transaction. + * + * @param object $signal + * @return void + */ + public function emitNow(object $signal): void + { + $this->deliver($signal); + } + + /** + * Emit a signal once the open transaction on a connection settles. + * + * @param object $signal + * @param string|null $connection + * @return bool + */ + public function emitOnSettle(object $signal, ?string $connection = null): bool + { + if (Database::stageSignal($this, $signal, $connection)) { + return true; + } + + $this->deliver($signal); + + return false; + } + + /** + * Deliver a signal whose transaction has just committed. + * + * @param object $signal + * @return void + * @internal Called by the database layer + */ + public function settle(object $signal): void + { + foreach ($this->listenersFor($signal::class) as $listener) { + try { + $this->invoke($listener, $signal); + } catch (\Throwable $failure) { + $this->reportFailure($failure, $signal); + } + } + } + + /** + * Set a custom reporter for listener failures that happen after a commit. + * + * @param callable(\Throwable, object): void|null $reporter + * @return self + */ + public function reportFailuresUsing(?callable $reporter): self + { + $this->failureReporter = $reporter; + + return $this; + } + + /** + * Run every listener for a signal, letting the first exception propagate. + * + * @param object $signal + * @return void + */ + protected function deliver(object $signal): void + { + foreach ($this->listenersFor($signal::class) as $listener) { + $this->invoke($listener, $signal); + } + } + + /** + * Get the ordered listeners for a concrete signal class. + * + * @param string $class + * @return array + */ + protected function listenersFor(string $class): array + { + if (isset($this->resolved[$class])) { + return $this->resolved[$class]; + } + + $entries = []; + + foreach ([$class, ...array_values(class_parents($class) ?: []), ...array_values(class_implements($class) ?: [])] as $name) { + foreach ($this->listeners[$name] ?? [] as $entry) { + $entries[] = $entry; + } + } + + usort($entries, static fn(array $a, array $b): int => [$b['priority'], $a['order']] <=> [$a['priority'], $b['order']]); + + return $this->resolved[$class] = array_column($entries, 'listener'); + } + + /** + * Read the #[DeliverOnSettle] rule of a signal class once. + * + * @param string $class + * @return DeliverOnSettle|false + */ + protected function settleRule(string $class): DeliverOnSettle|false + { + if (!isset($this->settleRules[$class])) { + $attributes = (new \ReflectionClass($class))->getAttributes(DeliverOnSettle::class); + + $this->settleRules[$class] = $attributes ? $attributes[0]->newInstance() : false; + } + + return $this->settleRules[$class]; + } + + /** + * Call a single listener with the signal. + * + * @param mixed $listener + * @param object $signal + * @return void + */ + protected function invoke(mixed $listener, object $signal): void + { + if (is_string($listener) && !is_callable($listener)) { + $listener = app()->make($listener); + } elseif (is_array($listener) && is_string($listener[0])) { + $listener = [app()->make($listener[0]), $listener[1]]; + } + + $listener($signal); + } + + /** + * Make sure a listener can be called later. + * + * @param mixed $listener + * @return void + * @throws \InvalidArgumentException + */ + protected function assertListener(mixed $listener): void + { + $valid = match (true) { + is_string($listener) => is_callable($listener) || (class_exists($listener) && method_exists($listener, '__invoke')), + is_array($listener) => count($listener) === 2 + && (is_object($listener[0]) || (is_string($listener[0]) && class_exists($listener[0]))) + && is_string($listener[1]) + && method_exists($listener[0], $listener[1]), + default => is_callable($listener), + }; + + if (!$valid) { + throw new \InvalidArgumentException( + 'A signal listener must be a callable, a [class, method] pair or an invokable class name.' + ); + } + } + + /** + * Report a listener failure without ever throwing. + * + * @param \Throwable $failure + * @param object $signal + * @return void + */ + protected function reportFailure(\Throwable $failure, object $signal): void + { + $reported = false; + + try { + if ($this->failureReporter !== null) { + ($this->failureReporter)($failure, $signal); + $reported = true; + } elseif (app()->has('log')) { + app('log')->error(sprintf('Signal listener failed for %s: %s', $signal::class, $failure->getMessage()), [ + 'exception' => $failure, + 'signal' => $signal::class, + ]); + $reported = true; + } + } catch (\Throwable) { + // Reporting must never break the remaining listeners. + } + + if (!$reported) { + try { + error_log(sprintf( + 'Signal listener failed for %s: %s: %s in %s:%d', + $signal::class, + get_class($failure), + $failure->getMessage(), + $failure->getFile(), + $failure->getLine() + )); + } catch (\Throwable) { + // Nothing left to try. + } + } + } +} diff --git a/src/Phaseolies/Support/Facades/Signal.php b/src/Phaseolies/Support/Facades/Signal.php new file mode 100644 index 00000000..6d5388a4 --- /dev/null +++ b/src/Phaseolies/Support/Facades/Signal.php @@ -0,0 +1,24 @@ +assertContains('Command created successfully', $command->capturedSuccesses); } + public function testMakeSignalCommandCreatesAPlainSignalClass(): void + { + $command = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeArguments['name'] = 'Billing\\InvoicePaid'; + + $result = $command->handle(); + $file = Env::path('src/Signals/Billing/InvoicePaid.php'); + $contents = (string) file_get_contents($file); + + $this->assertSame(0, $result); + $this->assertStringContainsString('namespace App\\Signals\\Billing;', $contents); + $this->assertStringContainsString('class InvoicePaid', $contents); + $this->assertStringNotContainsString('DeliverOnSettle', $contents); + $this->assertContains('Signal created successfully', $command->capturedSuccesses); + $this->assertSame(0, $this->lintFile($file)); + } + + public function testMakeSignalCommandAddsTheSettleAttribute(): void + { + $command = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeArguments['name'] = 'OrderPlaced'; + $command->fakeOptions = ['settle' => true, 'connection' => null]; + + $result = $command->handle(); + $file = Env::path('src/Signals/OrderPlaced.php'); + $contents = (string) file_get_contents($file); + + $this->assertSame(0, $result); + $this->assertStringContainsString('use Phaseolies\\Signals\\Attributes\\DeliverOnSettle;', $contents); + $this->assertStringContainsString("#[DeliverOnSettle]\nclass OrderPlaced", $contents); + $this->assertSame(0, $this->lintFile($file)); + } + + public function testMakeSignalCommandConnectionImpliesSettle(): void + { + $command = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeArguments['name'] = 'ReportStored'; + $command->fakeOptions = ['settle' => false, 'connection' => 'analytics']; + + $result = $command->handle(); + $file = Env::path('src/Signals/ReportStored.php'); + + $this->assertSame(0, $result); + $this->assertStringContainsString( + "#[DeliverOnSettle(connection: 'analytics')]\nclass ReportStored", + (string) file_get_contents($file) + ); + $this->assertSame(0, $this->lintFile($file)); + } + + public function testMakeSignalCommandRefusesToOverwriteOrAcceptUnsafeInput(): void + { + $command = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeArguments['name'] = 'OrderPlaced'; + $this->assertSame(0, $command->handle()); + + $file = Env::path('src/Signals/OrderPlaced.php'); + $original = (string) file_get_contents($file); + $this->assertSame(1, $command->handle()); + $this->assertSame($original, (string) file_get_contents($file)); + + $bad = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + }; + $bad->fakeArguments['name'] = 'Evil'; + $bad->fakeOptions = ['connection' => "x')]\nclass Pwned {} //"]; + $this->assertSame(1, $bad->handle()); + $this->assertFileDoesNotExist(Env::path('src/Signals/Evil.php')); + + $badName = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + }; + $badName->fakeArguments['name'] = 'Not-A-Class'; + $this->assertSame(1, $badName->handle()); + } + + private function lintFile(string $file): int + { + exec(PHP_BINARY . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $status); + + return $status; + } + public function testMakeAuthCommandGeneratesExpectedFilesAndRoutes(): void { $command = new class extends MakeAuthCommand diff --git a/tests/Signals/SettledSignalTest.php b/tests/Signals/SettledSignalTest.php new file mode 100644 index 00000000..5a784e45 --- /dev/null +++ b/tests/Signals/SettledSignalTest.php @@ -0,0 +1,452 @@ + */ + private array $seen = []; + + protected function setUp(): void + { + $this->bus = new SignalBus(); + $this->seen = []; + + $this->setStatic('connections', [ + 'primary' => new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]), + 'analytics' => new PDO('sqlite::memory:', null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]), + ]); + $this->setStatic('drivers', []); + $this->setStatic('transactions', []); + $this->setStatic('pendingSignals', []); + + $this->primary = new Database('primary'); + $this->analytics = new Database('analytics'); + + $this->bus->on(PrimaryPaid::class, fn(PrimaryPaid $s) => $this->seen[] = "paid:{$s->id}"); + $this->bus->on(AnalyticsStored::class, fn(AnalyticsStored $s) => $this->seen[] = "stored:{$s->id}"); + $this->bus->on(PlainSignal::class, fn(PlainSignal $s) => $this->seen[] = "plain:{$s->id}"); + } + + protected function tearDown(): void + { + $this->setStatic('connections', []); + $this->setStatic('drivers', []); + $this->setStatic('transactions', []); + $this->setStatic('pendingSignals', []); + } + + private function setStatic(string $property, mixed $value): void + { + $reflection = new \ReflectionProperty(Database::class, $property); + $reflection->setValue(null, $value); + } + + private function pending(): array + { + return (new \ReflectionProperty(Database::class, 'pendingSignals'))->getValue(); + } + + public function testSignalRunsOnceAfterTheOutermostCommit(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid()); + + $this->assertSame([], $this->seen); + + $this->primary->commit(); + + $this->assertSame(['paid:p-1'], $this->seen); + $this->assertSame([], $this->pending()); + } + + public function testSignalIsNotDeliveredAgainByLaterTransactions(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid()); + $this->primary->commit(); + + $this->primary->beginTransaction(); + $this->primary->commit(); + + $this->assertSame(['paid:p-1'], $this->seen); + } + + public function testRollbackDiscardsPendingSignals(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid()); + $this->primary->rollBack(); + + $this->assertSame([], $this->seen); + $this->assertSame([], $this->pending()); + + $this->primary->beginTransaction(); + $this->primary->commit(); + + $this->assertSame([], $this->seen); + } + + public function testTransactionClosureCommitsAndDeliversAfterTheResultIsKnown(): void + { + $result = $this->primary->transaction(function () { + $this->bus->emit(new PrimaryPaid()); + $this->assertSame([], $this->seen); + + return 'done'; + }); + + $this->assertSame('done', $result); + $this->assertSame(['paid:p-1'], $this->seen); + } + + public function testTransactionClosureThatThrowsDiscardsSignals(): void + { + try { + $this->primary->transaction(function () { + $this->bus->emit(new PrimaryPaid()); + + throw new \RuntimeException('boom'); + }); + $this->fail('Expected exception was not thrown.'); + } catch (\RuntimeException) { + } + + $this->assertSame([], $this->seen); + $this->assertSame([], $this->pending()); + $this->assertSame(0, Database::transactionLevel('primary')); + } + + public function testRetriedAttemptsOnlyDeliverTheSignalsOfTheSuccessfulAttempt(): void + { + $attempt = 0; + + $this->primary->transaction(function () use (&$attempt) { + $attempt++; + $this->bus->emit(new PrimaryPaid("attempt-{$attempt}")); + + if ($attempt < 3) { + throw new \RuntimeException('retry'); + } + }, 3); + + $this->assertSame(['paid:attempt-3'], $this->seen); + } + + public function testNestedCommitsWaitForTheOutermostCommit(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('outer')); + + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('inner')); + $this->primary->commit(); + + $this->assertSame([], $this->seen); + + $this->primary->commit(); + + $this->assertSame(['paid:outer', 'paid:inner'], $this->seen); + } + + public function testSignalsKeepTheirChronologicalOrderAcrossNestedLevels(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('1')); + + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('2')); + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('3')); + $this->primary->commit(); + $this->bus->emit(new PrimaryPaid('4')); + $this->primary->commit(); + + $this->bus->emit(new PrimaryPaid('5')); + $this->primary->commit(); + + $this->assertSame(['paid:1', 'paid:2', 'paid:3', 'paid:4', 'paid:5'], $this->seen); + } + + public function testInnerRollbackDiscardsOnlyTheInnerSignals(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('outer-before')); + + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('inner')); + $this->primary->rollBack(); + + $this->bus->emit(new PrimaryPaid('outer-after')); + $this->primary->commit(); + + $this->assertSame(['paid:outer-before', 'paid:outer-after'], $this->seen); + } + + public function testInnerRollbackDiscardsSignalsOfCommittedLevelsInsideIt(): void + { + $this->primary->beginTransaction(); + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('level-2')); + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('level-3')); + $this->primary->commit(); + $this->primary->rollBack(); + $this->primary->commit(); + + $this->assertSame([], $this->seen); + $this->assertSame([], $this->pending()); + } + + public function testOuterRollbackDiscardsSignalsOfCommittedInnerLevels(): void + { + $this->primary->beginTransaction(); + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('inner')); + $this->primary->commit(); + $this->primary->rollBack(); + + $this->assertSame([], $this->seen); + $this->assertSame([], $this->pending()); + } + + public function testNestedTransactionClosureThatFailsIsCaughtByTheOuterOne(): void + { + $this->primary->transaction(function () { + $this->bus->emit(new PrimaryPaid('outer')); + + try { + $this->primary->transaction(function () { + $this->bus->emit(new PrimaryPaid('inner')); + + throw new \RuntimeException('inner failed'); + }); + } catch (\RuntimeException) { + } + }); + + $this->assertSame(['paid:outer'], $this->seen); + } + + public function testConnectionsSettleIndependently(): void + { + $this->primary->beginTransaction(); + $this->analytics->beginTransaction(); + + $this->bus->emit(new PrimaryPaid()); + $this->bus->emit(new AnalyticsStored()); + + $this->analytics->commit(); + $this->assertSame(['stored:a-1'], $this->seen); + + $this->primary->rollBack(); + $this->assertSame(['stored:a-1'], $this->seen); + $this->assertSame([], $this->pending()); + } + + public function testSignalBoundToAConnectionIgnoresTransactionsOnOthers(): void + { + $this->analytics->beginTransaction(); + $this->bus->emit(new PrimaryPaid()); + + // primary has no open transaction, so nothing waits. + $this->assertSame(['paid:p-1'], $this->seen); + + $this->analytics->rollBack(); + $this->assertSame(['paid:p-1'], $this->seen); + } + + public function testEmitOnSettleBindsAnyPlainSignalToAConnection(): void + { + $this->primary->beginTransaction(); + + $this->assertTrue($this->bus->emitOnSettle(new PlainSignal(), 'primary')); + $this->assertSame([], $this->seen); + + $this->primary->commit(); + + $this->assertSame(['plain:x-1'], $this->seen); + } + + public function testPlainEmitIsNeverHeldByATransaction(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PlainSignal()); + + $this->assertSame(['plain:x-1'], $this->seen); + + $this->primary->rollBack(); + $this->assertSame(['plain:x-1'], $this->seen); + } + + public function testEmitNowIgnoresSettleRulesAndOpenTransactions(): void + { + $this->primary->beginTransaction(); + $this->bus->emitNow(new PrimaryPaid()); + + $this->assertSame(['paid:p-1'], $this->seen); + + $this->primary->rollBack(); + } + + public function testSignalOutsideATransactionIsDeliveredImmediately(): void + { + $this->bus->emit(new PrimaryPaid()); + + $this->assertSame(['paid:p-1'], $this->seen); + } + + public function testListenerFailureNeverBreaksTheCommitAndLaterListenersStillRun(): void + { + $reported = []; + $this->bus->reportFailuresUsing(function (\Throwable $failure, object $signal) use (&$reported) { + $reported[] = [$failure->getMessage(), $signal::class]; + }); + $this->bus->on(PrimaryPaid::class, function () { throw new \RuntimeException('listener failed'); }, 10); + + $result = $this->primary->transaction(function () { + $this->bus->emit(new PrimaryPaid('a')); + $this->bus->emit(new PrimaryPaid('b')); + + return 'committed'; + }); + + $this->assertSame('committed', $result); + $this->assertSame(0, Database::transactionLevel('primary')); + $this->assertSame(['paid:a', 'paid:b'], $this->seen); + $this->assertSame([['listener failed', PrimaryPaid::class], ['listener failed', PrimaryPaid::class]], $reported); + } + + public function testListenerFailureFallsBackToErrorLogWhenNoReporterOrLogIsAvailable(): void + { + $this->bus->on(PrimaryPaid::class, function () { throw new \RuntimeException('listener failed'); }, 10); + + $file = tempnam(sys_get_temp_dir(), 'signal-log'); + $previous = ini_set('error_log', $file); + + try { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid()); + $this->primary->commit(); + + $output = (string) file_get_contents($file); + } finally { + ini_set('error_log', $previous === false ? '' : $previous); + @unlink($file); + } + + $this->assertStringContainsString('Signal listener failed for ' . PrimaryPaid::class . ': RuntimeException: listener failed', $output); + $this->assertSame(['paid:p-1'], $this->seen); + } + + public function testThrowingReporterFallsBackToErrorLogAndDoesNotBreakTheCommit(): void + { + $this->bus->reportFailuresUsing(function () { throw new \LogicException('reporter broke'); }); + $this->bus->on(PrimaryPaid::class, function () { throw new \RuntimeException('listener failed'); }, 10); + + $file = tempnam(sys_get_temp_dir(), 'signal-log'); + $previous = ini_set('error_log', $file); + + try { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid()); + $this->primary->commit(); + + $output = (string) file_get_contents($file); + } finally { + ini_set('error_log', $previous === false ? '' : $previous); + @unlink($file); + } + + $this->assertStringContainsString('listener failed', $output); + $this->assertSame(['paid:p-1'], $this->seen); + $this->assertSame(0, Database::transactionLevel('primary')); + } + + public function testListenersMayOpenTheirOwnTransactionsWhileSettling(): void + { + $this->bus->on(PrimaryPaid::class, function (PrimaryPaid $signal) { + if ($signal->id !== 'first') { + return; + } + + // The outer transaction is already closed, so this is a fresh one. + $this->assertSame(0, Database::transactionLevel('primary')); + + $this->primary->transaction(function () { + $this->bus->emit(new PrimaryPaid('from-listener')); + $this->seen[] = 'listener-inside-tx'; + }); + }, 10); + + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('first')); + $this->primary->commit(); + + $this->assertSame(['listener-inside-tx', 'paid:from-listener', 'paid:first'], $this->seen); + $this->assertSame([], $this->pending()); + } + + public function testDisconnectingDropsPendingSignals(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid()); + + $this->primary->disconnect('primary'); + + $this->assertSame([], $this->pending()); + $this->assertSame([], $this->seen); + } + + public function testSignalsOfTwoBusesAreDeliveredByTheirOwnBus(): void + { + $other = new SignalBus(); + $otherSeen = []; + $other->on(PrimaryPaid::class, function () use (&$otherSeen) { $otherSeen[] = 'other'; }); + + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid()); + $other->emit(new PrimaryPaid()); + $this->primary->commit(); + + $this->assertSame(['paid:p-1'], $this->seen); + $this->assertSame(['other'], $otherSeen); + } + + public function testSignalClassRuleIsReadOnce(): void + { + $this->primary->beginTransaction(); + $this->bus->emit(new PrimaryPaid('1')); + $this->bus->emit(new PrimaryPaid('2')); + $this->primary->commit(); + + $this->assertSame(['paid:1', 'paid:2'], $this->seen); + } +} diff --git a/tests/Signals/SignalBusTest.php b/tests/Signals/SignalBusTest.php new file mode 100644 index 00000000..5c1af9e1 --- /dev/null +++ b/tests/Signals/SignalBusTest.php @@ -0,0 +1,190 @@ +id]; + } + + public function handle(OrderSignal $signal): void + { + self::$seen[] = ['handle', $signal->id]; + } +} + +final class SignalBusTest extends TestCase +{ + private SignalBus $bus; + + protected function setUp(): void + { + $this->bus = new SignalBus(); + InvokableListener::$seen = []; + } + + public function testHigherPriorityListenersRunFirst(): void + { + $order = []; + + $this->bus->on(OrderSignal::class, function () use (&$order) { $order[] = 'low'; }, -5); + $this->bus->on(OrderSignal::class, function () use (&$order) { $order[] = 'high'; }, 10); + $this->bus->on(OrderSignal::class, function () use (&$order) { $order[] = 'normal'; }); + + $this->bus->emit(new OrderSignal()); + + $this->assertSame(['high', 'normal', 'low'], $order); + } + + public function testEqualPrioritiesRunInRegistrationOrder(): void + { + $order = []; + + foreach (['a', 'b', 'c'] as $name) { + $this->bus->on(OrderSignal::class, function () use (&$order, $name) { $order[] = $name; }, 3); + } + + $this->bus->emit(new OrderSignal()); + + $this->assertSame(['a', 'b', 'c'], $order); + } + + public function testListenersOfParentClassesAndInterfacesReceiveTheSignal(): void + { + $order = []; + + $this->bus->on(ShippedOrderSignal::class, function () use (&$order) { $order[] = 'class'; }, 1); + $this->bus->on(OrderSignal::class, function () use (&$order) { $order[] = 'parent'; }, 2); + $this->bus->on(TracksOrders::class, function () use (&$order) { $order[] = 'interface'; }, 3); + + $this->bus->emit(new ShippedOrderSignal()); + $this->assertSame(['interface', 'parent', 'class'], $order); + + $order = []; + $this->bus->emit(new OrderSignal()); + $this->assertSame(['interface', 'parent'], $order); + } + + public function testListenerRegisteredLaterIsPickedUpAfterTheFirstEmit(): void + { + $order = []; + + $this->bus->on(OrderSignal::class, function () use (&$order) { $order[] = 'first'; }); + $this->bus->emit(new OrderSignal()); + + $this->bus->on(OrderSignal::class, function () use (&$order) { $order[] = 'second'; }); + $this->bus->emit(new OrderSignal()); + + $this->assertSame(['first', 'first', 'second'], $order); + } + + public function testInvokableClassAndClassMethodListenersAreResolved(): void + { + $this->bus->on(OrderSignal::class, InvokableListener::class, 2); + $this->bus->on(OrderSignal::class, [InvokableListener::class, 'handle'], 1); + + $this->bus->emit(new OrderSignal('o-9')); + + $this->assertSame([['invoke', 'o-9'], ['handle', 'o-9']], InvokableListener::$seen); + } + + public function testObjectMethodListenerIsCalled(): void + { + $listener = new InvokableListener(); + $this->bus->on(OrderSignal::class, [$listener, 'handle']); + + $this->bus->emit(new OrderSignal('o-2')); + + $this->assertSame([['handle', 'o-2']], InvokableListener::$seen); + } + + public function testInvalidListenersAreRejectedWhenRegistered(): void + { + foreach (['not_a_function_or_class', [InvokableListener::class, 'missing'], [1, 2], ['Nope\\Missing', 'x']] as $bad) { + try { + $this->bus->on(OrderSignal::class, $bad); + $this->fail('Expected an InvalidArgumentException for ' . json_encode($bad)); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('signal listener', $e->getMessage()); + } + } + } + + public function testImmediateEmitPropagatesTheListenerExceptionAndStops(): void + { + $ran = []; + + $this->bus->on(OrderSignal::class, function () { throw new \RuntimeException('listener failed'); }, 2); + $this->bus->on(OrderSignal::class, function () use (&$ran) { $ran[] = 'later'; }, 1); + + try { + $this->bus->emit(new OrderSignal()); + $this->fail('The listener exception should propagate.'); + } catch (\RuntimeException $e) { + $this->assertSame('listener failed', $e->getMessage()); + } + + $this->assertSame([], $ran); + } + + public function testEmitWithoutListenersIsANoop(): void + { + $this->bus->emit(new OrderSignal()); + $this->assertFalse($this->bus->hasListeners(OrderSignal::class)); + } + + public function testForgetRemovesListeners(): void + { + $ran = 0; + $this->bus->on(OrderSignal::class, function () use (&$ran) { $ran++; }); + $this->bus->on(TracksOrders::class, function () use (&$ran) { $ran++; }); + + $this->assertTrue($this->bus->hasListeners(new OrderSignal())); + + $this->bus->forget(OrderSignal::class); + $this->bus->emit(new OrderSignal()); + $this->assertSame(1, $ran); + + $this->bus->forget(); + $this->bus->emit(new OrderSignal()); + $this->assertSame(1, $ran); + $this->assertFalse($this->bus->hasListeners(OrderSignal::class)); + } + + public function testLeadingBackslashInSignalNamesIsIgnored(): void + { + $ran = 0; + $this->bus->on('\\' . OrderSignal::class, function () use (&$ran) { $ran++; }); + + $this->bus->emit(new OrderSignal()); + + $this->assertSame(1, $ran); + } + + public function testOutsideATransactionEmitOnSettleDeliversImmediately(): void + { + $ran = 0; + $this->bus->on(OrderSignal::class, function () use (&$ran) { $ran++; }); + + $held = $this->bus->emitOnSettle(new OrderSignal(), 'no-such-connection'); + + $this->assertFalse($held); + $this->assertSame(1, $ran); + } +} From 9fe7685d1389c05047053c3758ced053016456d3 Mon Sep 17 00:00:00 2001 From: Arif Hoque Date: Thu, 15 Oct 2026 13:18:23 +0600 Subject: [PATCH 2/2] fix: signal command path issue: --- .../Console/Commands/MakeSignalCommand.php | 2 +- src/Phaseolies/Console/Schedule/Command.php | 30 ++++++++ tests/Console/CommandBehaviorCoverageTest.php | 69 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/src/Phaseolies/Console/Commands/MakeSignalCommand.php b/src/Phaseolies/Console/Commands/MakeSignalCommand.php index bdeaa9af..65a3c3d8 100644 --- a/src/Phaseolies/Console/Commands/MakeSignalCommand.php +++ b/src/Phaseolies/Console/Commands/MakeSignalCommand.php @@ -32,7 +32,7 @@ public function handle(): int $connection = $this->option('connection'); $connection = is_string($connection) && $connection !== '' ? $connection : null; - if ($className === '' || !preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $className)) { + if ($className === '' || $this->generatedNameError($name) !== null) { $this->displayError('The signal name must be a valid class name.'); return Command::FAILURE; } diff --git a/src/Phaseolies/Console/Schedule/Command.php b/src/Phaseolies/Console/Schedule/Command.php index 6176baca..a409d065 100644 --- a/src/Phaseolies/Console/Schedule/Command.php +++ b/src/Phaseolies/Console/Schedule/Command.php @@ -474,6 +474,31 @@ protected function normalizeGeneratedName(string $name): string return trim($normalized, '/'); } + /** + * Check every segment of a generated class/path name + * + * @param string $name + * @return string|null + */ + protected function generatedNameError(string $name): ?string + { + $normalized = $this->normalizeGeneratedName($name); + if ($normalized === '') { + return 'The name must not be empty.'; + } + + foreach (explode('/', $normalized) as $segment) { + if ($segment === '.' || $segment === '..') { + return 'The name must not contain "." or ".." segments.'; + } + if (!preg_match('/^[A-Za-z_][A-Za-z0-9_]*$/', $segment)) { + return 'Every part of the name must be a valid class or namespace name; "' . $segment . '" is not.'; + } + } + + return null; + } + /** * Split a generated class/path name into normalized name, path parts and final class name. * @@ -502,6 +527,11 @@ protected function generatedFilePath(string $baseDirectory, string $name, string $normalizedName = $this->normalizeGeneratedName($name); $relativePath = trim($baseDirectory, '/\\'); + // Whatever the command validated, a generated file never leaves its directory. + if (in_array('..', explode('/', $normalizedName), true) || in_array('.', explode('/', $normalizedName), true)) { + throw new \InvalidArgumentException('A generated name must not contain "." or ".." segments.'); + } + if ($normalizedName !== '') { $relativePath .= '/' . $normalizedName; } diff --git a/tests/Console/CommandBehaviorCoverageTest.php b/tests/Console/CommandBehaviorCoverageTest.php index c1897506..ee15df56 100644 --- a/tests/Console/CommandBehaviorCoverageTest.php +++ b/tests/Console/CommandBehaviorCoverageTest.php @@ -173,6 +173,75 @@ public function testMakeSignalCommandRefusesToOverwriteOrAcceptUnsafeInput(): vo $this->assertSame(1, $badName->handle()); } + /** + * @return iterable + */ + public static function unsafeSignalNames(): iterable + { + yield 'a namespace segment that is not an identifier' => ['Admin/123Invalid/OrderPaid']; + yield 'a segment with a dash' => ['Bad-Seg/OrderPaid']; + yield 'a dot segment' => ['Billing/./OrderPaid']; + yield 'a parent segment inside' => ['Billing/../OrderPaid']; + yield 'climbing out of the signals directory' => ['../Escaped']; + yield 'climbing out of the project' => ['../../outside/OrderPaid']; + yield 'backslashes do not get around it' => ['..\\..\\outside\\OrderPaid']; + yield 'only slashes' => ['///']; + } + + #[DataProvider('unsafeSignalNames')] + public function testMakeSignalCommandRejectsEveryUnsafeNameSegment(string $name): void + { + $command = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeArguments['name'] = $name; + + $this->assertSame(1, $command->handle()); + $this->assertNotEmpty($command->capturedErrors); + foreach (['src/Signals/Admin', 'src/Signals/Bad-Seg', 'src/Signals/Billing', 'src/Escaped.php', 'src/OrderPaid.php', 'outside', 'src/Signals/OrderPaid.php'] as $path) { + $this->assertFileDoesNotExist(Env::path($path), "nothing may be written for \"$name\" ($path)"); + } + } + + public function testMakeSignalCommandAcceptsValidNestedNamespaces(): void + { + $command = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + }; + $command->fakeArguments['name'] = 'Admin\\Billing_2/OrderPaid'; + + $this->assertSame(0, $command->handle()); + $file = Env::path('src/Signals/Admin/Billing_2/OrderPaid.php'); + $this->assertFileExists($file); + $this->assertStringContainsString('namespace App\\Signals\\Admin\\Billing_2;', (string) file_get_contents($file)); + $this->assertSame(0, $this->lintFile($file)); + } + + public function testGeneratedFilePathNeverLeavesItsDirectory(): void + { + $command = new class extends MakeSignalCommand + { + use InteractsWithFakeCommandIO; + + public function path(string $name): string + { + return $this->generatedFilePath('src/Signals', $name); + } + }; + + $this->assertStringEndsWith('src/Signals/Billing/OrderPaid.php', str_replace('\\', '/', $command->path('Billing/OrderPaid'))); + foreach (['../Escaped', 'Billing/../../Escaped', './OrderPaid'] as $bad) { + try { + $command->path($bad); + $this->fail("\"$bad\" should have been refused"); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('".." segments', $e->getMessage()); + } + } + } + private function lintFile(string $file): int { exec(PHP_BINARY . ' -l ' . escapeshellarg($file) . ' 2>&1', $output, $status);