diff --git a/composer.json b/composer.json index f0295eb..abf8830 100644 --- a/composer.json +++ b/composer.json @@ -67,7 +67,7 @@ "./vendor/bin/php-cs-fixer fix --allow-risky=yes" ], "pstan": [ - "./vendor/bin/phpstan analyse" + "./vendor/bin/phpstan analyse --memory-limit=2G" ], "test": [ "./vendor/bin/pest" diff --git a/src/Http/Middleware/NightwatchMiddleware.php b/src/Http/Middleware/NightwatchMiddleware.php index 9ef8493..3f1f28c 100644 --- a/src/Http/Middleware/NightwatchMiddleware.php +++ b/src/Http/Middleware/NightwatchMiddleware.php @@ -4,6 +4,7 @@ namespace Saloon\Laravel\Http\Middleware; +use Saloon\Laravel\Saloon; use Saloon\Http\PendingRequest; use Saloon\Http\Senders\GuzzleSender; use Saloon\Contracts\RequestMiddleware; @@ -17,18 +18,19 @@ public function __invoke(PendingRequest $pendingRequest): void { $sender = $pendingRequest->getConnector()->sender(); - // Check if Nightwatch is installed - if (! class_exists('Laravel\Nightwatch\Facades\Nightwatch')) { - return; - } + // Check if we're using the Guzzle Sender, Nightwatch is installed and + // if the middleware hasn't been registered yet. - // Check if we're using GuzzleSender - if ($sender instanceof GuzzleSender === false) { + if ( + class_exists('Laravel\Nightwatch\Facades\Nightwatch') === false + || $sender instanceof GuzzleSender === false + || isset(Saloon::$registeredSenders[$senderId = spl_object_id($sender)]['nightwatch']) === true + ) { return; } $sender->addMiddleware(\Laravel\Nightwatch\Facades\Nightwatch::guzzleMiddleware(), 'nightwatch'); + Saloon::$registeredSenders[$senderId]['nightwatch'] = true; } - } diff --git a/src/Saloon.php b/src/Saloon.php index 40c9672..909c635 100644 --- a/src/Saloon.php +++ b/src/Saloon.php @@ -17,6 +17,13 @@ class Saloon */ public static bool $registeredDefaults = false; + /** + * Define sender IDs that have been used before + * + * @var array> + */ + public static array $registeredSenders = []; + /** * Determines if requests should be recorded. */ diff --git a/src/SaloonServiceProvider.php b/src/SaloonServiceProvider.php index 44ee8ed..e66780c 100644 --- a/src/SaloonServiceProvider.php +++ b/src/SaloonServiceProvider.php @@ -72,6 +72,12 @@ public function boot(): void // Destroy global mock client to prevent leaky tests BaseMockClient::destroyGlobal(); + + // Clear registered senders to prevent Octane memory leaks + + $this->app->terminating(function () { + Saloon::$registeredSenders = []; + }); } /** diff --git a/tests/Feature/NightwatchMiddlewareTest.php b/tests/Feature/NightwatchMiddlewareTest.php index 0547206..0974a8c 100644 --- a/tests/Feature/NightwatchMiddlewareTest.php +++ b/tests/Feature/NightwatchMiddlewareTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use Saloon\Http\PendingRequest; +use Saloon\Http\Senders\GuzzleSender; use Saloon\Laravel\Tests\Fixtures\Requests\UserRequest; use Saloon\Laravel\Http\Middleware\NightwatchMiddleware; use Saloon\Laravel\Tests\Fixtures\Connectors\TestConnector; @@ -32,3 +33,46 @@ $middleware($pendingRequest); })->not->toThrow(Exception::class); }); + +test('nightwatch middleware is only registered once on the handler stack for long-lived connectors', function () { + if (! class_exists('Laravel\Nightwatch\Facades\Nightwatch')) { + require_once __DIR__ . '/../Fixtures/Middleware/NightwatchMock.php'; + } + + $connector = TestConnector::make(); + $pendingRequest = new PendingRequest($connector, new UserRequest()); + + $sender = $connector->sender(); + $this->assertInstanceOf(GuzzleSender::class, $sender); + + $handlerStack = $sender->getHandlerStack(); + + $middleware = new NightwatchMiddleware(); + + // Simulate multiple requests being sent + $middleware($pendingRequest); + $middleware($pendingRequest); + + /* + * Handler stack __toString() renders middleware as in > and out <. + * Example: + * > 5) Name: 'http_errors', Function: callable(00000000000004130000000000000000) + * > 4) Name: 'allow_redirects', Function: callable(00000000000004120000000000000000) + * > 3) Name: 'cookies', Function: callable(00000000000004110000000000000000) + * > 2) Name: 'prepare_body', Function: callable(00000000000004100000000000000000) + * > 1) Name: 'nightwatch', Function: callable(00000000000004440000000000000000) + * < 0) Handler: callable(00000000000004170000000000000000) + * < 1) Name: 'nightwatch', Function: callable(00000000000004440000000000000000) + * < 2) Name: 'prepare_body', Function: callable(00000000000004100000000000000000) + * < 3) Name: 'cookies', Function: callable(00000000000004110000000000000000) + * < 4) Name: 'allow_redirects', Function: callable(00000000000004120000000000000000) + * < 5) Name: 'http_errors', Function: callable(00000000000004130000000000000000) + * + * Count only the ">" section to avoid double counting + */ + $stackString = (string) $handlerStack; + $reverseSection = explode('<', $stackString)[0] ?? ''; + $nightwatchCount = mb_substr_count($reverseSection, 'Name: \'nightwatch\''); + + expect($nightwatchCount)->toBe(1); +}); diff --git a/tests/Fixtures/Middleware/NightwatchMock.php b/tests/Fixtures/Middleware/NightwatchMock.php new file mode 100644 index 0000000..0a4bf1c --- /dev/null +++ b/tests/Fixtures/Middleware/NightwatchMock.php @@ -0,0 +1,22 @@ +