diff --git a/resources/blob.ts b/resources/blob.ts index 094bb47fe9..bc51749742 100644 --- a/resources/blob.ts +++ b/resources/blob.ts @@ -816,6 +816,43 @@ class FileBackedBlob extends (Blob as unknown as { new (): Blob }) implements Bl return isBeingWritten; } } + /** + * Open the blob for streaming, resolving only once the backing content is confirmed readable. + * stream() must return synchronously (the web Blob contract), so a missing or corrupt backing + * file can only surface through the stream itself — by then an HTTP caller has typically already + * committed a 200 status for a body that will never arrive. This awaits the underlying file open + * and the first read (where the header is validated), so pre-streaming failures reject here as + * BlobReadErrors carrying their statusCode — missing file (404), in-flight write or + * half-replicated blob (503), error stub/truncation (500) — while holding at most one chunk in + * memory. Mid-stream failures still surface through the returned stream and any 'error' + * listeners registered via on(). + */ + async openStream(): Promise { + const storageInfo = storageInfoForBlob.get(this); + // in-memory content cannot fail to open, use the buffer-backed stream directly + if (storageInfo?.contentBuffer) return this.stream(); + const reader = this.stream().getReader(); + // the first read drives the file open (with its retry/timeout classification) and the header + // checks in the first pull, so everything wrong before content flows rejects here + const firstChunk = await reader.read(); + return new ReadableStream({ + start(controller) { + if (firstChunk.done) controller.close(); + else controller.enqueue(firstChunk.value); + }, + pull(controller) { + // .then() chain rather than async/await: this runs per chunk, so skip the async state + // machine and extra promise allocations on the hot path + return reader.read().then(({ done, value }) => { + if (done) controller.close(); + else controller.enqueue(value); + }); + }, + cancel(reason) { + return reader.cancel(reason); + }, + }); + } slice(start: number, end: number, type?: string): Blob { const sourceStorageInfo = storageInfoForBlob.get(this); const slicedBlob = new FileBackedBlob(type && { type }); diff --git a/unitTests/resources/blob.test.js b/unitTests/resources/blob.test.js index 8a19a26b22..5c51e1fc67 100644 --- a/unitTests/resources/blob.test.js +++ b/unitTests/resources/blob.test.js @@ -1199,6 +1199,57 @@ describe('Blob test', () => { env.setProperty(CONFIG_PARAMS.STORAGE_BLOBREADTIMEOUT, undefined); } }); + it('openStream() resolves for a healthy disk-backed blob and streams the full content', async () => { + const payload = randomBytes(20000); + const store = BlobTest.primaryStore.rootStore; + const blob = await createBlob(Readable.from(payload), { size: payload.length }); + await decodeFromDatabase(() => saveBlob(blob).saving, store); + const streamed = await streamToBytes(await blob.openStream()); + assert(streamed.equals(payload)); + // a slice shares the same backing file and must stream just the slice content + const slicedStream = await blob.slice(300, 400).openStream(); + assert((await streamToBytes(slicedStream)).equals(payload.subarray(300, 400))); + }); + it('openStream() resolves for a small in-memory blob', async () => { + const payload = randomBytes(100); + const blob = await createBlob(payload); + const streamed = await streamToBytes(await blob.openStream()); + assert(streamed.equals(payload)); + }); + it('openStream() rejects with a 404 for a cleanly-missing blob file, before any read', async () => { + const { blob, filePath } = await makeDiskBackedBlob(); + unlinkSync(filePath); + await assert.rejects(blob.openStream(), (error) => { + assert.strictEqual(error.statusCode, 404); + assert.strictEqual(error.code, 'ENOENT'); + return true; + }); + }); + it('openStream() rejects with a 503 while a write is in flight', async () => { + const { blob, filePath, store } = await makeDiskBackedBlob(); + const lockKey = getFileId(blob) + ':blob'; + assert(store.tryLock(lockKey), 'should be able to take the blob write lock for the test'); + try { + unlinkSync(filePath); // file gone while a "writer" still holds the lock + env.setProperty(CONFIG_PARAMS.STORAGE_BLOBREADTIMEOUT, '150'); + await assert.rejects(blob.openStream(), (error) => { + assert.strictEqual(error.statusCode, 503); + return true; + }); + } finally { + store.unlock(lockKey); + env.setProperty(CONFIG_PARAMS.STORAGE_BLOBREADTIMEOUT, undefined); + } + }); + it('openStream() rejects with a 500 for a blob truncated to a self-consistent smaller size', async () => { + const { blob, filePath } = await makeDiskBackedBlob(); + truncateBlobConsistently(filePath, 256); + await assert.rejects(blob.openStream(), (error) => { + assert.strictEqual(error.statusCode, 500); + assert.match(error.message, /size mismatch/); + return true; + }); + }); afterEach(function () { setAuditRetention(60000); setDeletionDelay(50); // restore shorter, but need to have it happen for the last test