Skip to content
Open
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
10 changes: 10 additions & 0 deletions src/Helpers/MiddlewarePipeline.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ public function __construct()
$this->fatalPipeline = new Pipeline;
}

/**
* Deep-clone internal pipelines so cloned requests do not share pipe state.
*/
public function __clone(): void
{
$this->requestPipeline = clone $this->requestPipeline;
$this->responsePipeline = clone $this->responsePipeline;
$this->fatalPipeline = clone $this->fatalPipeline;
}

/**
* Add a middleware before the request is sent
*
Expand Down
30 changes: 30 additions & 0 deletions src/Http/Request.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,36 @@ public function getMethod(): Method
return $this->method;
}

/**
* Ensure cloned requests do not share mutable object state (query, headers, etc.).
*
* Without this, a shallow clone reuses the same {@see \Saloon\Repositories\ArrayStore}
* instances when they were initialized before cloning, which breaks concurrent pools
* (e.g. paginated requests mutating the same query bag).
*/
public function __clone(): void
{
if (isset($this->query)) {
$this->query = clone $this->query;
}

if (isset($this->headers)) {
$this->headers = clone $this->headers;
}

if (isset($this->config)) {
$this->config = clone $this->config;
}

if (isset($this->delay)) {
$this->delay = clone $this->delay;
}

if (isset($this->middlewarePipeline)) {
$this->middlewarePipeline = clone $this->middlewarePipeline;
}
}

/**
* Define the endpoint for the request.
*/
Expand Down
86 changes: 86 additions & 0 deletions tests/Unit/RequestCloneTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

use Saloon\Http\Response;
use Saloon\Http\Faking\MockClient;
use Saloon\Http\Faking\MockResponse;
use Saloon\Tests\Fixtures\Requests\UserRequest;
use Saloon\Tests\Fixtures\Connectors\TestConnector;

test('cloning a request after query() is initialized gives independent query bags', function () {
$original = new UserRequest;
$original->query()->add('key', 'value');

$a = clone $original;
$b = clone $original;

$a->query()->add('page', 1);
$b->query()->add('page', 2);

expect($a->query()->get('page'))->toBe(1);
expect($b->query()->get('page'))->toBe(2);
expect($original->query()->get('page'))->toBeNull();
expect($original->query()->get('key'))->toBe('value');
});

test('cloning a request after headers config and delay are initialized gives independent stores', function () {
$original = new UserRequest;
$original->headers()->add('X-Test', 'one');
$original->config()->add('timeout', 10);
$original->delay()->set(5);

$clone = clone $original;

$clone->headers()->add('X-Test', 'two');
$clone->config()->add('timeout', 20);
$clone->delay()->set(15);

expect($original->headers()->get('X-Test'))->toBe('one');
expect($clone->headers()->get('X-Test'))->toBe('two');
expect($original->config()->get('timeout'))->toBe(10);
expect($clone->config()->get('timeout'))->toBe(20);
expect($original->delay()->get())->toBe(5);
expect($clone->delay()->get())->toBe(15);
});

test('cloning a request after middleware is initialized gives independent pipelines', function () {
$original = new UserRequest;
$original->middleware()->onResponse(static fn (Response $response): Response => $response);

$clone = clone $original;

expect($original->middleware())->not->toBe($clone->middleware());
});

test('concurrent pool sends with cloned requests do not share query mutation', function () {
$sequence = [];
for ($i = 0; $i < 10; $i++) {
$sequence[] = MockResponse::make(['ok' => true]);
}

$connector = new TestConnector;
$connector->withMockClient(new MockClient($sequence));

$base = new UserRequest;
$base->query()->add('key', 'value');

$requests = [];
for ($i = 1; $i <= 10; $i++) {
$r = clone $base;
$r->query()->add('page', $i);
$requests[] = $r;
}

$pagesSeen = [];

$pool = $connector->pool($requests, 5);
$pool->withResponseHandler(function (Response $response) use (&$pagesSeen): void {
$pagesSeen[] = (int) $response->getRequest()->query()->get('page');
});

$pool->send()->wait();

expect($pagesSeen)->toHaveCount(10);
expect(array_unique($pagesSeen))->toHaveCount(10);
});
Loading