From 317788eb373af1dcc8691fbd2af533a46954928d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 25 Jun 2026 21:38:52 -0300 Subject: [PATCH] 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. --- .../Social/AbstractLinkedInPublisher.php | 3 + .../Social/Concerns/HasSocialHttpClient.php | 12 ++++ app/Services/Social/XPublisher.php | 5 +- app/Services/Social/YouTubePublisher.php | 3 + .../Services/Social/LinkedInPublisherTest.php | 69 +++++++++++++++++++ 5 files changed, 88 insertions(+), 4 deletions(-) diff --git a/app/Services/Social/AbstractLinkedInPublisher.php b/app/Services/Social/AbstractLinkedInPublisher.php index 8fc052cc..16c8f7d2 100644 --- a/app/Services/Social/AbstractLinkedInPublisher.php +++ b/app/Services/Social/AbstractLinkedInPublisher.php @@ -370,6 +370,9 @@ private function doUploadVideo(string $tempFile, $mediaItem): ?string if ($etag) { $uploadedPartIds[] = $etag; } + + unset($chunkData, $chunkResponse); + $this->freeChunkMemory(); } } finally { fclose($handle); diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index ed9cd0f8..a465b97a 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -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(); + } } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index bde45d74..f7be5127 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -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); diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index 5b4467c5..b4f1496b 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -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); diff --git a/tests/Feature/Services/Social/LinkedInPublisherTest.php b/tests/Feature/Services/Social/LinkedInPublisherTest.php index f0fe2a3f..94b837cc 100644 --- a/tests/Feature/Services/Social/LinkedInPublisherTest.php +++ b/tests/Feature/Services/Social/LinkedInPublisherTest.php @@ -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,