From 5b065ce1213ac87e2c46c1c90bb8b2f16bd3745c Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 25 Jun 2026 21:26:15 -0300 Subject: [PATCH 1/3] 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. --- .../Social/Concerns/HasSocialHttpClient.php | 2 +- app/Services/Social/XPublisher.php | 6 +++ .../Services/Social/XPublisherTest.php | 49 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/app/Services/Social/Concerns/HasSocialHttpClient.php b/app/Services/Social/Concerns/HasSocialHttpClient.php index 092ca717..ed9cd0f8 100644 --- a/app/Services/Social/Concerns/HasSocialHttpClient.php +++ b/app/Services/Social/Concerns/HasSocialHttpClient.php @@ -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); } diff --git a/app/Services/Social/XPublisher.php b/app/Services/Social/XPublisher.php index ee925fde..bde45d74 100644 --- a/app/Services/Social/XPublisher.php +++ b/app/Services/Social/XPublisher.php @@ -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); diff --git a/tests/Feature/Services/Social/XPublisherTest.php b/tests/Feature/Services/Social/XPublisherTest.php index ac3b8d2f..434d161a 100644 --- a/tests/Feature/Services/Social/XPublisherTest.php +++ b/tests/Feature/Services/Social/XPublisherTest.php @@ -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); +}); From 317788eb373af1dcc8691fbd2af533a46954928d Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 25 Jun 2026 21:38:52 -0300 Subject: [PATCH 2/3] 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, From a285c0c1fe066d2407b33d51418ce91da86bb8e1 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 25 Jun 2026 21:46:35 -0300 Subject: [PATCH 3/3] revert(youtube): drop the unverified chunked-upload guard YouTube uploads through the Google API client's resumable handler, not our HTTP client, so the leak couldn't be reproduced for it and the nextChunk loop can't be exercised by a test without making the Google client injectable. Keeping this PR to changes that are both reproduced and covered by a test (X and LinkedIn); YouTube is tracked separately. --- app/Services/Social/YouTubePublisher.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/Services/Social/YouTubePublisher.php b/app/Services/Social/YouTubePublisher.php index b4f1496b..5b4467c5 100644 --- a/app/Services/Social/YouTubePublisher.php +++ b/app/Services/Social/YouTubePublisher.php @@ -167,9 +167,6 @@ 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);