From 9f3e198a9c48a717d25997f73f132362897e8388 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Thu, 3 Sep 2026 15:11:28 -0700 Subject: [PATCH] fix(sdk): don't accept a transcode as done without its cid pollProcessingStatus returned the moment a storage node reported status: done, without checking that the transcode result it is polling for is actually on the response. Upload rows replicate across nodes and getProcessingStatus talks to whichever node is selected, so a mirror can answer done from a row it has not finished catching up on. populateTrackMetadataWithUploadResponse then reads results['320'] off that response and gets undefined, and the track entity is written with no trackCid. The upload reports success. The track page loads, the artwork renders, people favorite and repost it - and there is no cid on the row pointing at the audio, so it can never be played and never records a single play. That is how DEZZE's only track ended up with 18 favorites, 16 reposts and 0 plays, and why share-to-story failed on it: the flow hands the 404ing stream URL to ffmpeg. Require the '320' result before treating an audio poll as finished. A node that really is done will have it on the next pass three seconds later, and an upload genuinely stuck in that state now times out with an error naming the cause instead of silently publishing unplayable audio. Image templates are unaffected - they have no '320' to wait for. Co-Authored-By: Claude Opus 5 --- .../src/sdk/services/Storage/Storage.test.ts | 78 +++++++++++++++++++ .../sdk/src/sdk/services/Storage/Storage.ts | 29 ++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/sdk/src/sdk/services/Storage/Storage.test.ts b/packages/sdk/src/sdk/services/Storage/Storage.test.ts index 3788eddc8f8..45772c3e1ea 100644 --- a/packages/sdk/src/sdk/services/Storage/Storage.test.ts +++ b/packages/sdk/src/sdk/services/Storage/Storage.test.ts @@ -50,3 +50,81 @@ describe('generatePreview', () => { ).rejects.toThrow('status: 401') }) }) + +// A storage node can answer `done` from an upload row it has not finished +// replicating, with no transcode results on it yet. Accepting that response +// writes a track whose trackCid is undefined: the upload "succeeds" into a +// track that can never be played, with no error raised anywhere. +describe('pollProcessingStatus', () => { + beforeEach(() => { + mockFetch.mockReset() + }) + + const statusResponse = (body: unknown) => + ({ ok: true, json: async () => body }) as unknown as Response + + const poll = (storage: Storage, template: string) => + ( + storage as unknown as { + pollProcessingStatus: ( + id: string, + template: string, + total: number + ) => Promise<{ results: Record }> + } + ).pollProcessingStatus('upload-1', template, 1) + + const nodeSelector = { + getSelectedNode: async () => 'https://node.example.com', + triedSelectingAllNodes: () => false + } as unknown as StorageNodeSelectorService + + it('keeps polling when a node reports done with no transcode result', async () => { + mockFetch + .mockResolvedValueOnce( + statusResponse({ id: 'upload-1', status: 'done', results: {} }) + ) + .mockResolvedValueOnce( + statusResponse({ + id: 'upload-1', + status: 'done', + results: { '320': 'QmTranscoded' } + }) + ) + + const storage = new Storage({ storageNodeSelector: nodeSelector }) + const resp = await poll(storage, 'audio') + + expect(resp.results['320']).toBe('QmTranscoded') + expect(mockFetch).toHaveBeenCalledTimes(2) + }, 20000) + + it('returns immediately once the transcode result is present', async () => { + mockFetch.mockResolvedValue( + statusResponse({ + id: 'upload-1', + status: 'done', + results: { '320': 'QmTranscoded' } + }) + ) + + const storage = new Storage({ storageNodeSelector: nodeSelector }) + const resp = await poll(storage, 'audio') + + expect(resp.results['320']).toBe('QmTranscoded') + expect(mockFetch).toHaveBeenCalledTimes(1) + }) + + // Image resizes have no '320' result to wait for; the gate is audio-only. + it('does not require a 320 result for image templates', async () => { + mockFetch.mockResolvedValue( + statusResponse({ id: 'upload-1', status: 'done', results: {} }) + ) + + const storage = new Storage({ storageNodeSelector: nodeSelector }) + const resp = await poll(storage, 'img_square') + + expect(resp.results).toEqual({}) + expect(mockFetch).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/sdk/src/sdk/services/Storage/Storage.ts b/packages/sdk/src/sdk/services/Storage/Storage.ts index 40e6271c1ee..d8c6525d913 100644 --- a/packages/sdk/src/sdk/services/Storage/Storage.ts +++ b/packages/sdk/src/sdk/services/Storage/Storage.ts @@ -222,6 +222,9 @@ export class Storage implements StorageService { const start = Date.now() let lastProgressUpdate = Date.now() let lastTranscodeProgress = 0 + // Tracks whether we ever saw a `done` response with no usable transcode + // result, so the timeout can say which of the two failures this was. + let sawDoneWithoutResults = false const maxPollingMs = template === 'audio' @@ -254,7 +257,26 @@ export class Storage implements StorageService { }) } if (resp?.status === 'done') { - return resp + // `done` alone is not proof the transcode results are here. Upload + // rows replicate across storage nodes, and getProcessingStatus talks + // to whichever node is selected, so a mirror can answer `done` from a + // row it has not finished catching up on. Accepting that response + // hands populateTrackMetadataWithUploadResponse a `results` map with + // no '320' key, the track entity gets written with an undefined + // trackCid, and the upload succeeds into a track that can never be + // played - no error anywhere, just a dead track with a live page. + // + // Keep polling instead. A node that really is finished will have the + // cid on the next pass, and in the genuinely stuck case this times + // out loudly rather than silently publishing unplayable audio. + if (template === 'audio' && !resp.results?.['320']) { + sawDoneWithoutResults = true + this.logger.warn( + `Storage node reported done with no transcode results, still polling. id=${id}` + ) + } else { + return resp + } } if (resp?.status === 'error') { throw new Error( @@ -278,6 +300,11 @@ export class Storage implements StorageService { await wait(POLL_STATUS_INTERVAL) } + if (sawDoneWithoutResults) { + throw new Error( + `Upload reported done but no transcode result appeared within ${maxPollingMs}ms. id=${id}` + ) + } throw new Error(`Upload took over ${maxPollingMs}ms. id=${id}`) }