fix(social): apply chunked-upload memory fix to LinkedIn and YouTube

The same leak audited in the X publisher exists wherever a video is streamed
through a per-chunk request loop. Reproduced with the reported 49MB file:
LinkedIn's plain Http::withToken() chunk loop grew to ~146MB (and it allows
videos up to 5GB). Centralize the fix as freeChunkMemory() on the shared
trait and call it (after unset) in the X, LinkedIn and YouTube loops so memory
stays flat.

Audited and left alone: TikTok and Instagram pull from a video_url (no local
upload); Facebook and Bluesky stream the whole file in a single request.

Adds a LinkedIn multi-chunk video test.
This commit is contained in:
Paulo Castellano 2026-06-25 21:38:52 -03:00
parent 5b065ce121
commit 317788eb37
5 changed files with 88 additions and 4 deletions

View file

@ -370,6 +370,9 @@ private function doUploadVideo(string $tempFile, $mediaItem): ?string
if ($etag) {
$uploadedPartIds[] = $etag;
}
unset($chunkData, $chunkResponse);
$this->freeChunkMemory();
}
} finally {
fclose($handle);

View file

@ -41,4 +41,16 @@ protected function redactResponseBody(string $body): string
{
return TokenRedactor::redact($body);
}
/**
* Reclaim memory between chunks of a streaming upload. Each request's body
* and the HTTP client's request/response objects are held alive by reference
* cycles the PHP runtime only frees when the cycle collector runs, so a
* long chunked upload accumulates the whole file in memory without this.
* Callers must `unset()` the chunk and response first.
*/
protected function freeChunkMemory(): void
{
gc_collect_cycles();
}
}

View file

@ -237,11 +237,8 @@ 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();
$this->freeChunkMemory();
}
} finally {
fclose($handle);

View file

@ -167,6 +167,9 @@ private function publishShort(PostPlatform $postPlatform, $media, SocialAccount
while (! $uploadStatus && ! feof($handle)) {
$chunk = fread($handle, self::CHUNK_SIZE);
$uploadStatus = $mediaUpload->nextChunk($chunk);
unset($chunk);
$this->freeChunkMemory();
}
fclose($handle);

View file

@ -431,6 +431,75 @@
Http::assertSent(fn ($request) => str_contains($request->url(), '/rest/posts'));
});
test('linkedin publisher uploads a video across multiple chunks', function () {
$this->post->update([
'media' => [
[
'id' => 'test-media-video',
'path' => 'media/2026-01/test-video.mp4',
'url' => 'https://example.com/media/2026-01/test-video.mp4',
'mime_type' => 'video/mp4',
'original_filename' => 'test-video.mp4',
],
],
]);
$chunkBase = 'https://www.linkedin.com/dms/upload/v2/chunk/video/';
$chunkPuts = 0;
Http::fake(function ($request) use ($chunkBase, &$chunkPuts) {
$url = $request->url();
if (str_contains($url, 'initializeUpload') && str_contains($url, '/rest/videos')) {
return Http::response([
'value' => [
'video' => 'urn:li:video:FakeVideoUrn',
'uploadToken' => 'upload-token-abc',
'uploadInstructions' => [
['uploadUrl' => $chunkBase.'0', 'firstByte' => 0, 'lastByte' => 1023],
['uploadUrl' => $chunkBase.'1', 'firstByte' => 1024, 'lastByte' => 2047],
['uploadUrl' => $chunkBase.'2', 'firstByte' => 2048, 'lastByte' => 3071],
],
],
], 200);
}
if (str_starts_with($url, $chunkBase)) {
$chunkPuts++;
return Http::response(null, 200, ['etag' => '"etag-'.$chunkPuts.'"']);
}
if (str_contains($url, 'finalizeUpload') && str_contains($url, '/rest/videos')) {
return Http::response(null, 200);
}
if (str_contains($url, '/rest/videos/')) {
return Http::response(['status' => 'AVAILABLE'], 200);
}
if (str_contains($url, '/rest/posts')) {
return Http::response(null, 201, ['x-restli-id' => 'urn:li:share:multichunk']);
}
return Http::response(str_repeat('x', 4096), 200);
});
$result = $this->publisher->publish($this->postPlatform);
expect($result['id'])->toBe('urn:li:share:multichunk');
expect($chunkPuts)->toBe(3);
// All three chunk etags are collected and sent to finalize.
Http::assertSent(function ($request) {
if (! str_contains($request->url(), 'finalizeUpload')) {
return false;
}
return count(data_get($request->data(), 'finalizeUploadRequest.uploadedPartIds', [])) === 3;
});
});
test('linkedin publisher can publish a document (pdf carousel) with a title', function () {
$this->postPlatform->update([
'content_type' => ContentType::LinkedInPost,