Skip to content
Merged
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
4 changes: 4 additions & 0 deletions packages/pulp/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to `mapsight/pulp` are documented here.

## Unreleased

### Fixed

- Remove the temp file created by `srcHttp` `sink => true` when the last path-backed `File` is released, including clones. A failed request removes it immediately. A caller-supplied sink path is left in place.

## 1.2.0 - 2026-09-02

### Added
Expand Down
4 changes: 3 additions & 1 deletion packages/pulp/Pulp.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ public static function srcFile(
/**
* ## Options
* * `sink`: `true` writes the body to a temp file and emits a path-backed
* `File` (use this for large responses). A string path writes there.
* `File` (use this for large responses). The temp file is removed when
* the last `File` that shares it is released, including clones. A string
* path writes there and is left in place.
* (default: `null` — load the body as a string)
* * `client`: Guzzle `Client` instance. Tests inject a mock handler here.
* (default: `new Client()`)
Expand Down
2 changes: 1 addition & 1 deletion packages/pulp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Pulp::start()
->run();
```

- `sink => true` writes the body to a temp file and emits a path-backed `File`, so a ~100 MB response does not have to live as a PHP string. Pass a path string to choose the file.
- `sink => true` writes the body to a temp file and emits a path-backed `File`, so a ~100 MB response does not have to live as a PHP string. The temp file is removed when the last `File` that shares it is released, including clones from `split`, `shadow`, and `results`. Pass a path string to keep the body at a path you manage.
- `client` injects a Guzzle `Client` (tests use a mock handler).
- `successStatuses` throws unless the status is in the list. Pair with Guzzle `http_errors => false` when 304/204 are first-class.
- The file also carries `httpStatus`, `httpLastModified`, `httpEtag`, and `httpType`.
Expand Down
14 changes: 13 additions & 1 deletion packages/pulp/src/File.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ class File
private array $___data = [];
private ?string $___contentPath = null;

/**
* Shared by clones. The path is removed when the last File releases it.
*/
private ?SharedTempPath $___tempPath = null;

/**
* File constructor.
*/
Expand All @@ -26,7 +31,11 @@ public function __construct(public $fileName, ?string $srcFileName = null)
$this->srcFileName = empty($srcFileName) ? $this->fileName : $srcFileName;
}

public static function fromPath(string $path, ?string $fileName = null): File
/**
* @param bool $unlinkOnDestruct Remove `$path` when the last File that shares
* it is released, including clones. Caller-supplied paths stay.
*/
public static function fromPath(string $path, ?string $fileName = null, bool $unlinkOnDestruct = false): File
{
$file = new File(
$fileName ?: $path,
Expand All @@ -38,6 +47,9 @@ public static function fromPath(string $path, ?string $fileName = null): File
}

$file->___contentPath = $path;
if ($unlinkOnDestruct) {
$file->___tempPath = new SharedTempPath($path);
}

$stats = @stat($path);
if ($stats !== false) {
Expand Down
20 changes: 20 additions & 0 deletions packages/pulp/src/SharedTempPath.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace OpenMapsight\pulp;

/**
* Deletes a temp path when the last reference is released.
*
* File clones share one instance, so a shallow clone does not unlink early.
*/
final class SharedTempPath
{
public function __construct(private readonly string $path) {}

public function __destruct()
{
@unlink($this->path);
}
}
26 changes: 25 additions & 1 deletion packages/pulp/src/SrcHttpHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@

class SrcHttpHandler extends AbstractHandler
{
/** Temp file created for `sink => true`. Null once a File owns it, or after it is removed. */
private ?string $ephemeralSinkPath = null;

protected function getConstructorParamDefs(): array
{
return ['method', 'uri', 'guzzleOptions', 'aliasFileName', 'options'];
Expand Down Expand Up @@ -39,7 +42,13 @@ public function onEnd(): void
$this->assertSuccessStatus($statusCode);

if ($sinkPath !== null) {
$tmpFile = File::fromPath($sinkPath, $this->cp->aliasFileName);
$tmpFile = File::fromPath(
$sinkPath,
$this->cp->aliasFileName,
$this->ephemeralSinkPath !== null,
);
// File removes this on release. A caller-supplied path is not ephemeral.
$this->ephemeralSinkPath = null;
} else {
$tmpFile = new File($this->cp->aliasFileName);
$tmpFile->content = (string) $res->getBody();
Expand All @@ -64,6 +73,8 @@ public function onEnd(): void
} else {
throw $err;
}
} finally {
$this->discardEphemeralSink();
}

if ($file instanceof File) {
Expand Down Expand Up @@ -91,6 +102,8 @@ private function resolveSinkPath(): ?string
throw new RuntimeException('Unable to create HTTP sink temp file');
}

$this->ephemeralSinkPath = $path;

return $path;
}
if (is_string($sink) && $sink !== '') {
Expand All @@ -102,6 +115,17 @@ private function resolveSinkPath(): ?string
return is_string($guzzleSink) && $guzzleSink !== '' ? $guzzleSink : null;
}

private function discardEphemeralSink(): void
{
if ($this->ephemeralSinkPath === null) {
return;
}

$path = $this->ephemeralSinkPath;
$this->ephemeralSinkPath = null;
@unlink($path);
}

private function assertSuccessStatus(int $statusCode): void
{
$allowed = $this->cp->options['successStatuses'] ?? null;
Expand Down
33 changes: 33 additions & 0 deletions packages/pulp/test/FileTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,39 @@ public function testStreamReadsFromPathWithoutTouchingContent(): void
}
}

public function testFromPathCanUnlinkWhenReleased(): void
{
$tmpFile = tempnam(sys_get_temp_dir(), 'pulp-file-test-');
$this->assertIsString($tmpFile);
file_put_contents($tmpFile, 'owned');

$f = File::fromPath($tmpFile, 'file.txt', true);
$this->assertSame('owned', $f->content);
$this->assertFileExists($tmpFile);

unset($f);

$this->assertFileDoesNotExist($tmpFile);
}

public function testOwnedPathSurvivesUntilTheLastCloneIsReleased(): void
{
$tmpFile = tempnam(sys_get_temp_dir(), 'pulp-file-test-');
$this->assertIsString($tmpFile);
file_put_contents($tmpFile, 'owned');

$original = File::fromPath($tmpFile, 'file.txt', true);
$clone = clone $original;

unset($original);
$this->assertFileExists($tmpFile);
$this->assertSame('owned', $clone->content);

unset($clone);

$this->assertFileDoesNotExist($tmpFile);
}

public function testStreamFromGeneratedContent(): void
{
$f = new File('file.txt');
Expand Down
128 changes: 128 additions & 0 deletions packages/pulp/test/SrcHttpHandlerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,124 @@ public function testSinkWritesPathBackedFile(): void
}
}

public function testEphemeralSinkStaysUntilTheFileIsReleased(): void
{
$result = Pulp::start()
->pipe(Pulp::srcHttp(
'GET',
'https://example.test/large.json',
[],
'large.json',
[
'client' => $this->client(new Response(200, [], '{"sites":true}')),
'sink' => true,
]
))
->run();

$path = $result[0]->srcFileName;
$this->assertIsString($path);
$this->assertMatchesRegularExpression('#/pulp-http-[^/]+$#', $path);
$this->assertFileExists($path);
$this->assertSame('{"sites":true}', $result[0]->content);
$this->assertFileExists($path);

unset($result);
gc_collect_cycles();

$this->assertFileDoesNotExist($path);
}

public function testEphemeralSinkSurvivesClonesUntilTheLastFileIsReleased(): void
{
$kept = [];
$result = Pulp::start()
->pipe(Pulp::srcHttp(
'GET',
'https://example.test/large.json',
[],
'large.json',
[
'client' => $this->client(new Response(200, [], '{"sites":true}')),
'sink' => true,
]
))
->pipe(Pulp::results(static function (array $files) use (&$kept): void {
$kept = $files;
}))
->run();

$path = $result[0]->srcFileName;
$this->assertIsString($path);
$this->assertFileExists($path);

unset($result);
gc_collect_cycles();

$this->assertFileExists($path);
$this->assertSame('{"sites":true}', $kept[0]->content);
$this->assertFileExists($path);

unset($kept);
gc_collect_cycles();

$this->assertFileDoesNotExist($path);
}

public function testEphemeralSinkIsRemovedWhenTheRequestFails(): void
{
$before = $this->ephemeralSinks();

try {
Pulp::start()
->pipe(Pulp::srcHttp(
'GET',
'https://example.test/status',
['http_errors' => false],
'status.json',
[
'client' => $this->client(new Response(500, [], 'nope')),
'sink' => true,
'successStatuses' => [200],
]
))
->run();
$this->fail('Expected the failed response to throw');
} catch (RuntimeException $err) {
$this->assertStringContainsString('returned 500', $err->getMessage());
}

$this->assertSame($before, $this->ephemeralSinks());
}

public function testCallerSinkPathIsKeptWhenTheRequestFails(): void
{
$sink = tempnam(sys_get_temp_dir(), 'pulp-http-sink-');
$this->assertIsString($sink);

try {
Pulp::start()
->pipe(Pulp::srcHttp(
'GET',
'https://example.test/status',
['http_errors' => false],
'status.json',
[
'client' => $this->client(new Response(500, [], 'nope')),
'sink' => $sink,
'successStatuses' => [200],
]
))
->run();
$this->fail('Expected the failed response to throw');
} catch (RuntimeException $err) {
$this->assertStringContainsString('returned 500', $err->getMessage());
$this->assertFileExists($sink);
} finally {
@unlink($sink);
}
}

public function testSuccessStatusesRejectUnexpectedCodes(): void
{
$this->expectException(RuntimeException::class);
Expand Down Expand Up @@ -101,6 +219,16 @@ public function testNotModifiedStillEmitsFileWithStatus(): void
$this->assertSame('', $result[0]->content);
}

/**
* @return list<string>
*/
private function ephemeralSinks(): array
{
$paths = glob(sys_get_temp_dir() . '/pulp-http-*');

return is_array($paths) ? $paths : [];
}

private function client(Response $response): Client
{
return new Client([
Expand Down
Loading