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:
parent
5b065ce121
commit
317788eb37
5 changed files with 88 additions and 4 deletions
|
|
@ -370,6 +370,9 @@ private function doUploadVideo(string $tempFile, $mediaItem): ?string
|
|||
if ($etag) {
|
||||
$uploadedPartIds[] = $etag;
|
||||
}
|
||||
|
||||
unset($chunkData, $chunkResponse);
|
||||
$this->freeChunkMemory();
|
||||
}
|
||||
} finally {
|
||||
fclose($handle);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue