From 3949fd680cb4bd291a4bf98ddda0199ba866aea6 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Mon, 14 Sep 2026 12:46:15 -0700 Subject: [PATCH 1/4] fix(reporter): do not hang perfetto writes after a stream error A failed write after backpressure waited forever for drain. Reject that wait on error and settle the close promise so the reporter exits. Signed-off-by: Sebastien Tardif --- packages/playwright/src/reporters/perfetto.ts | 28 +++++++++++++++++-- .../playwright-test/reporter-perfetto.spec.ts | 15 ++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/playwright/src/reporters/perfetto.ts b/packages/playwright/src/reporters/perfetto.ts index 2f0f59e27ed6f..fb0b736351035 100644 --- a/packages/playwright/src/reporters/perfetto.ts +++ b/packages/playwright/src/reporters/perfetto.ts @@ -300,7 +300,13 @@ class ChunkWriter { this._stream = gzip ?? fileStream; // The file is only complete once the destination closes, which is later than // the gzip stream ending. - this._closed = new Promise(resolve => fileStream.on('close', resolve)); + this._closed = new Promise(resolve => { + fileStream.on('close', resolve); + fileStream.on('error', error => { + this._error ??= error; + resolve(); + }); + }); for (const stream of new Set([this._stream, fileStream])) stream.on('error', error => this._error ??= error); } @@ -308,8 +314,24 @@ class ChunkWriter { async write(chunk: string) { if (this._error) throw this._error; - if (!this._stream.write(chunk)) - await new Promise(resolve => this._stream.once('drain', () => resolve())); + if (!this._stream.write(chunk)) { + await new Promise((resolve, reject) => { + if (this._error) + return reject(this._error); + const onDrain = () => finish(resolve); + const onError = (error: Error) => { + this._error ??= error; + finish(() => reject(error)); + }; + const finish = (done: () => void) => { + this._stream.off('drain', onDrain); + this._stream.off('error', onError); + done(); + }; + this._stream.once('drain', onDrain); + this._stream.once('error', onError); + }); + } } async close() { diff --git a/tests/playwright-test/reporter-perfetto.spec.ts b/tests/playwright-test/reporter-perfetto.spec.ts index 15d230405e996..56ba2af02fe93 100644 --- a/tests/playwright-test/reporter-perfetto.spec.ts +++ b/tests/playwright-test/reporter-perfetto.spec.ts @@ -215,6 +215,21 @@ test('should report step params', async ({ runInlineTest }, testInfo) => { expect(findSlice(events, 'my step')!.args.params).toEqual({ foo: 'bar', count: 7 }); }); +test('should fail when perfetto output file cannot be written', async ({ runInlineTest }, testInfo) => { + const dir = testInfo.outputPath('not-a-file'); + await fs.promises.mkdir(dir); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { reporter: [['perfetto', { outputFile: ${JSON.stringify(dir)} }]] }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('one', async () => {}); + `, + }); + expect(result.exitCode).not.toBe(0); +}); + test('should respect outputFile option', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({ 'playwright.config.ts': ` From a12b55ef7e419e9890c3421a737cb59868f12141 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Tue, 15 Sep 2026 16:54:46 -0700 Subject: [PATCH 2/4] fix(reporter): surface gzip dest errors during drain wait Listen for file-stream errors while waiting on gzip drain. Signed-off-by: Sebastien Tardif --- packages/playwright/src/reporters/perfetto.ts | 4 ++++ tests/playwright-test/reporter-perfetto.spec.ts | 13 ++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/playwright/src/reporters/perfetto.ts b/packages/playwright/src/reporters/perfetto.ts index fb0b736351035..21a981aa53ffd 100644 --- a/packages/playwright/src/reporters/perfetto.ts +++ b/packages/playwright/src/reporters/perfetto.ts @@ -290,11 +290,13 @@ class PerfettoReporter implements ReporterV2 { // Writes into a ".gz" file through a gzip stream, into a plain file otherwise. class ChunkWriter { private _stream: Writable; + private _fileStream: Writable; private _closed: Promise; private _error: Error | undefined; constructor(file: string) { const fileStream = fs.createWriteStream(file); + this._fileStream = fileStream; const gzip = file.endsWith('.gz') ? zlib.createGzip() : undefined; gzip?.pipe(fileStream); this._stream = gzip ?? fileStream; @@ -326,10 +328,12 @@ class ChunkWriter { const finish = (done: () => void) => { this._stream.off('drain', onDrain); this._stream.off('error', onError); + this._fileStream.off('error', onError); done(); }; this._stream.once('drain', onDrain); this._stream.once('error', onError); + this._fileStream.once('error', onError); }); } } diff --git a/tests/playwright-test/reporter-perfetto.spec.ts b/tests/playwright-test/reporter-perfetto.spec.ts index 56ba2af02fe93..0191a8ddd3c91 100644 --- a/tests/playwright-test/reporter-perfetto.spec.ts +++ b/tests/playwright-test/reporter-perfetto.spec.ts @@ -215,16 +215,19 @@ test('should report step params', async ({ runInlineTest }, testInfo) => { expect(findSlice(events, 'my step')!.args.params).toEqual({ foo: 'bar', count: 7 }); }); -test('should fail when perfetto output file cannot be written', async ({ runInlineTest }, testInfo) => { - const dir = testInfo.outputPath('not-a-file'); - await fs.promises.mkdir(dir); +test('should fail when gzip perfetto output stream errors', async ({ runInlineTest }, testInfo) => { + test.skip(!fs.existsSync('/dev/full'), 'needs /dev/full'); + const gz = testInfo.outputPath('trace.json.gz'); + await fs.promises.symlink('/dev/full', gz); const result = await runInlineTest({ 'playwright.config.ts': ` - module.exports = { reporter: [['perfetto', { outputFile: ${JSON.stringify(dir)} }]] }; + module.exports = { reporter: [['perfetto', { outputFile: ${JSON.stringify(gz)} }]] }; `, 'a.test.ts': ` import { test } from '@playwright/test'; - test('one', async () => {}); + test('one', async () => { + test.info().annotations.push({ type: 'blob', description: ${JSON.stringify('x'.repeat(64 * 1024))} }); + }); `, }); expect(result.exitCode).not.toBe(0); From f98bd0941d2a6a6a3a0e744d74bcb08a830c0e0b Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 17 Sep 2026 12:38:43 -0700 Subject: [PATCH 3/4] test(reporter): fail gzip perfetto writes with a fifo Repeated x compresses too well, so /dev/full never errors. Write incompressible data to a fifo and close the reader so the dest stream errors. Signed-off-by: Sebastien Tardif --- tests/playwright-test/reporter-perfetto.spec.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/playwright-test/reporter-perfetto.spec.ts b/tests/playwright-test/reporter-perfetto.spec.ts index 0191a8ddd3c91..7291a8a328dfb 100644 --- a/tests/playwright-test/reporter-perfetto.spec.ts +++ b/tests/playwright-test/reporter-perfetto.spec.ts @@ -14,6 +14,8 @@ * limitations under the License. */ +import { execFileSync } from 'child_process'; +import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; import * as zlib from 'zlib'; @@ -216,9 +218,11 @@ test('should report step params', async ({ runInlineTest }, testInfo) => { }); test('should fail when gzip perfetto output stream errors', async ({ runInlineTest }, testInfo) => { - test.skip(!fs.existsSync('/dev/full'), 'needs /dev/full'); const gz = testInfo.outputPath('trace.json.gz'); - await fs.promises.symlink('/dev/full', gz); + execFileSync('mkfifo', [gz]); + const reader = fs.createReadStream(gz); + reader.on('error', () => {}); + reader.once('data', () => reader.destroy()); const result = await runInlineTest({ 'playwright.config.ts': ` module.exports = { reporter: [['perfetto', { outputFile: ${JSON.stringify(gz)} }]] }; @@ -226,10 +230,11 @@ test('should fail when gzip perfetto output stream errors', async ({ runInlineTe 'a.test.ts': ` import { test } from '@playwright/test'; test('one', async () => { - test.info().annotations.push({ type: 'blob', description: ${JSON.stringify('x'.repeat(64 * 1024))} }); + test.info().annotations.push({ type: 'blob', description: ${JSON.stringify(crypto.randomBytes(256 * 1024).toString('base64'))} }); }); `, }); + reader.destroy(); expect(result.exitCode).not.toBe(0); }); From 0cdc4a27eebf5cc3fcd7e0205864ed9bf43e0e20 Mon Sep 17 00:00:00 2001 From: Sebastien Tardif Date: Thu, 17 Sep 2026 17:08:28 -0700 Subject: [PATCH 4/4] fix(reporter): reject perfetto drain wait on stream error Wait with events.once(stream, 'drain') so an error unblocks the write. Forward file-stream errors through gzip.destroy so gzip waits reject. Test both .json and .json.gz against a directory plus a 256KB payload. Signed-off-by: Sebastien Tardif --- packages/playwright/src/reporters/perfetto.ts | 37 ++++--------------- .../playwright-test/reporter-perfetto.spec.ts | 37 +++++++++---------- 2 files changed, 25 insertions(+), 49 deletions(-) diff --git a/packages/playwright/src/reporters/perfetto.ts b/packages/playwright/src/reporters/perfetto.ts index 21a981aa53ffd..c8b2fd9766587 100644 --- a/packages/playwright/src/reporters/perfetto.ts +++ b/packages/playwright/src/reporters/perfetto.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { once } from 'events'; import fs from 'fs'; import path from 'path'; import zlib from 'zlib'; @@ -290,25 +291,21 @@ class PerfettoReporter implements ReporterV2 { // Writes into a ".gz" file through a gzip stream, into a plain file otherwise. class ChunkWriter { private _stream: Writable; - private _fileStream: Writable; private _closed: Promise; private _error: Error | undefined; constructor(file: string) { const fileStream = fs.createWriteStream(file); - this._fileStream = fileStream; const gzip = file.endsWith('.gz') ? zlib.createGzip() : undefined; gzip?.pipe(fileStream); this._stream = gzip ?? fileStream; + // pipe() only unpipes on a destination error, so the gzip stream would never + // emit 'drain' or 'error' again. Destroy it to wake up the pending write. + if (gzip) + fileStream.on('error', error => gzip.destroy(error)); // The file is only complete once the destination closes, which is later than // the gzip stream ending. - this._closed = new Promise(resolve => { - fileStream.on('close', resolve); - fileStream.on('error', error => { - this._error ??= error; - resolve(); - }); - }); + this._closed = new Promise(resolve => fileStream.on('close', resolve)); for (const stream of new Set([this._stream, fileStream])) stream.on('error', error => this._error ??= error); } @@ -316,26 +313,8 @@ class ChunkWriter { async write(chunk: string) { if (this._error) throw this._error; - if (!this._stream.write(chunk)) { - await new Promise((resolve, reject) => { - if (this._error) - return reject(this._error); - const onDrain = () => finish(resolve); - const onError = (error: Error) => { - this._error ??= error; - finish(() => reject(error)); - }; - const finish = (done: () => void) => { - this._stream.off('drain', onDrain); - this._stream.off('error', onError); - this._fileStream.off('error', onError); - done(); - }; - this._stream.once('drain', onDrain); - this._stream.once('error', onError); - this._fileStream.once('error', onError); - }); - } + if (!this._stream.write(chunk)) + await once(this._stream, 'drain'); // Rejects if 'error' is emitted first. } async close() { diff --git a/tests/playwright-test/reporter-perfetto.spec.ts b/tests/playwright-test/reporter-perfetto.spec.ts index 7291a8a328dfb..eaedea02bdaa8 100644 --- a/tests/playwright-test/reporter-perfetto.spec.ts +++ b/tests/playwright-test/reporter-perfetto.spec.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { execFileSync } from 'child_process'; import * as crypto from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -217,26 +216,24 @@ test('should report step params', async ({ runInlineTest }, testInfo) => { expect(findSlice(events, 'my step')!.args.params).toEqual({ foo: 'bar', count: 7 }); }); -test('should fail when gzip perfetto output stream errors', async ({ runInlineTest }, testInfo) => { - const gz = testInfo.outputPath('trace.json.gz'); - execFileSync('mkfifo', [gz]); - const reader = fs.createReadStream(gz); - reader.on('error', () => {}); - reader.once('data', () => reader.destroy()); - const result = await runInlineTest({ - 'playwright.config.ts': ` - module.exports = { reporter: [['perfetto', { outputFile: ${JSON.stringify(gz)} }]] }; - `, - 'a.test.ts': ` - import { test } from '@playwright/test'; - test('one', async () => { - test.info().annotations.push({ type: 'blob', description: ${JSON.stringify(crypto.randomBytes(256 * 1024).toString('base64'))} }); - }); - `, +for (const fileName of ['trace.json', 'trace.json.gz']) { + test(`should fail when ${fileName} output is a directory`, async ({ runInlineTest }, testInfo) => { + const outputFile = testInfo.outputPath(fileName); + await fs.promises.mkdir(outputFile); + const result = await runInlineTest({ + 'playwright.config.ts': ` + module.exports = { reporter: [['perfetto', { outputFile: ${JSON.stringify(outputFile)} }]] }; + `, + 'a.test.ts': ` + import { test } from '@playwright/test'; + test('one', async () => { + test.info().annotations.push({ type: 'blob', description: ${JSON.stringify(crypto.randomBytes(256 * 1024).toString('base64'))} }); + }); + `, + }); + expect(result.exitCode).not.toBe(0); }); - reader.destroy(); - expect(result.exitCode).not.toBe(0); -}); +} test('should respect outputFile option', async ({ runInlineTest }, testInfo) => { const result = await runInlineTest({