fix(x): stop chunked video upload from exhausting memory

The APPEND loop sent each 1MB segment without releasing it: the chunk plus
its multipart copies and the Guzzle/retry object graph were kept alive by
reference cycles the GC doesn't reclaim mid-loop, so memory grew ~4MB per
segment. A 49MB video peaked at ~242MB locally and crossed the 512MB limit
in production. Freeing the segment and forcing cycle collection each
iteration keeps memory flat (~50MB regardless of video size).

Also harden socialHttp()'s retry predicate: it read $exception->response
directly, which fatals on exceptions without a response (e.g. a
ConnectionException during a long upload), masking the real error.
This commit is contained in:
Paulo Castellano 2026-06-25 21:26:15 -03:00
parent 973fc604a2
commit 5b065ce121
3 changed files with 56 additions and 1 deletions

View file

@ -32,7 +32,7 @@ protected function socialHttp(): PendingRequest
return Http::retry(
times: 3,
sleepMilliseconds: 5000,
when: fn ($exception, $request) => $exception->response?->status() === 429,
when: fn ($exception, $request) => ($exception->response ?? null)?->status() === 429,
throw: false,
)->timeout(120);
}

View file

@ -236,6 +236,12 @@ private function chunkedUpload(string $tempFile, int $totalBytes, string $mimeTy
}
$index++;
// The HTTP client/retry wrapper keeps each segment's body alive
// through reference cycles, so without forcing collection a large
// video accumulates its full size in memory and exhausts the limit.
unset($chunk, $appendResponse);
gc_collect_cycles();
}
} finally {
fclose($handle);

View file

@ -304,3 +304,52 @@
Http::assertSent(fn ($request) => str_contains($request->url(), '/finalize'));
Http::assertSent(fn ($request) => str_contains($request->url(), '/2/tweets'));
});
test('x publisher uploads a large video in sequential 1MB segments', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/big-video.mp4',
'url' => 'https://example.com/media/2026-01/big-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'big-video.mp4',
],
],
]);
$appendCount = 0;
Http::fake(function ($request) use (&$appendCount) {
$url = $request->url();
if (str_contains($url, '/2/media/upload/initialize')) {
return Http::response(['data' => ['id' => 'media_id_999']], 200);
}
if (str_contains($url, '/append')) {
$appendCount++;
return Http::response(null, 204);
}
if (str_contains($url, '/finalize')) {
return Http::response(['data' => ['id' => 'media_id_999']], 200);
}
if (str_contains($url, '/2/media/')) {
return Http::response(['processing_info' => ['state' => 'succeeded']], 200);
}
if (str_contains($url, '/2/tweets')) {
return Http::response(['data' => ['id' => '9876543210987654321', 'text' => 'Hello from X!']], 200);
}
// ~2.5MB download -> at least three 1MB segments.
return Http::response(str_repeat('x', (int) (2.5 * 1024 * 1024)), 200);
});
$this->publisher->publish($this->postPlatform);
expect($appendCount)->toBeGreaterThan(1);
});