Skip to content

fix(reporter): fail perfetto writes when the output stream errors - #42715

Open
Sebastien Tardif (SebTardif) wants to merge 4 commits into
microsoft:mainfrom
SebTardif:fix-perfetto-drain-error
Open

Sebastien Tardif (SebTardif) wants to merge 4 commits into
microsoft:mainfrom
SebTardif:fix-perfetto-drain-error

Conversation

@SebTardif

@SebTardif Sebastien Tardif (SebTardif) commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Fail the run when Perfetto cannot write outputFile after backpressure, instead of exiting 0 with no report.

Problem

On main, ChunkWriter.write waits only for 'drain'. If write() returns false and the stream then errors (for example outputFile is a directory), the drain promise never settles. There are no live handles, so Node exits 0. The runner computes the process exit code only after reporters finish onEnd, so a failing test plus a bad outputFile reports success and writes nothing.

Change

Wait with events.once(stream, 'drain'), which rejects if 'error' fires first. For gzip, pipe() only unpipes on a destination error, so the file stream error is forwarded with gzip.destroy(error).

Validation

  • tests/playwright-test/reporter-perfetto.spec.ts: directory outputFile plus a 256KB incompressible annotation, for both .json and .json.gz. On main the inner run exits 0. With this change it is non-zero.

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 <sebtardif@ncf.ca>
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

Comment thread packages/playwright/src/reporters/perfetto.ts Outdated
Comment thread tests/playwright-test/reporter-perfetto.spec.ts Outdated
Listen for file-stream errors while waiting on gzip drain.

Signed-off-by: Sebastien Tardif <sebtardif@ncf.ca>
'a.test.ts': `
import { test } from '@playwright/test';
test('one', async () => {
test.info().annotations.push({ type: 'blob', description: ${JSON.stringify('x'.repeat(64 * 1024))} });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this still passes without the fix because the repeated x compresses too well to reproduce the hang

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Rousso (@dcrousso)

this still passes without the fix because the repeated x compresses too well to reproduce the hang

The test now writes incompressible data to a fifo and closes the reader so the dest stream errors. Without the dest listener the inner run exits 0. With it, exit is non-zero. f98bd0941

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Rousso (@dcrousso)

this still passes without the fix because the repeated x compresses too well to reproduce the hang

The test now writes a 256KB incompressible annotation to a directory named trace.json or trace.json.gz. On main the inner run exits 0. With the once(drain) wait it is non-zero. No fifo.

@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

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 <sebtardif@ncf.ca>
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@pavelfeldman

Copy link
Copy Markdown
Member

This can be a lot smaller. Three notes:

The fix. events.once(stream, 'drain') already does what the hand-rolled waiter does: it rejects if the emitter fires 'error' first and removes its listeners either way. The one thing it doesn't cover is the gzip case, because pipe() merely unpipes on a destination error, so the gzip stream never drains or errors again. Forwarding the file error with gzip.destroy(error) takes care of that. The _closed change isn't needed: an fs write stream auto-destroys on error and emits 'close' right after 'error', so close() already resolves and rethrows. The inner if (this._error) return reject(...) is redundant with the check at the top of write().

So the whole change is:

import { once } from 'events';
...
    // 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));
...
    if (!this._stream.write(chunk))
      await once(this._stream, 'drain'); // Rejects if 'error' is emitted first.

I ran the perfetto spec (including your new test) plus plain/gzip × directory/fifo scenarios with this; all green.

The symptom. This isn't actually a hang. Once the drain promise is stuck there are no live handles, so Node exits with code 0, and the runner computes the exit code only after reporters finish onEnd. On main, a run with a failing test and outputFile pointing at a directory exits 0 with no error and no report written. That's the real reason this matters, worth putting in the description.

The test. mkfifo doesn't exist on Windows; that's the one failure in the Windows Test Runner job. A directory target reproduces this deterministically once the payload is over the 16KB high-water mark: the first write hits backpressure synchronously, before the open error can arrive. With a 256KB crypto.randomBytes annotation (it has to be incompressible for the gzip variant), every run exits 0 on main and non-zero with the fix, for both .json and .json.gz. So: directory + big random annotation, no fifo, no reader.

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 <sebtardif@ncf.ca>
@SebTardif Sebastien Tardif (SebTardif) changed the title fix(reporter): do not hang perfetto writes after a stream error fix(reporter): fail perfetto writes when the output stream errors Sep 18, 2026
@SebTardif

Copy link
Copy Markdown
Contributor Author

Pavel Feldman (@pavelfeldman)

This can be a lot smaller.

Yes. write() now waits with once(stream, 'drain'), and a file-stream error destroys the gzip stream so that wait rejects. The _closed path only listens for close again.

The tests write a 256KB incompressible annotation to a directory named trace.json or trace.json.gz. On main the inner run exits 0. With this change it is non-zero.

The description now says the stuck drain is a silent exit 0, not a hang.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "tests 1"

2 failed
❌ [installation tests] › playwright-cdn.spec.ts:43 › playwright cdn failover should work (https://cdn.playwright.dev/dbazure/download/playwright) @package-installations-macos-latest
❌ [installation tests] › playwright-cdn.spec.ts:43 › playwright cdn failover should work (https://playwright.download.prss.microsoft.com/dbazure/download/playwright) @package-installations-macos-latest

7 flaky ⚠️ [chromium-library] › library/video.spec.ts:762 › screencast › should work with video+trace `@frozen-time-library-chromium-linux`
⚠️ [chromium-library] › library/video.spec.ts:762 › screencast › should work with video+trace `@chromium-ubuntu-22.04-node24`
⚠️ [chromium-library] › library/video.spec.ts:762 › screencast › should work with video+trace `@chromium-ubuntu-22.04-node22`
⚠️ [firefox-library] › library/browsercontext-cookies-third-party.spec.ts:257 › third party 'Partitioned;' cookies `@firefox-ubuntu-22.04-node20`
⚠️ [firefox-library] › library/browsercontext-cookies-third-party.spec.ts:470 › top level 'Partitioned;' cookie and same origin iframe `@firefox-ubuntu-22.04-node20`
⚠️ [firefox-page] › page/page-goto.spec.ts:90 › should work with Cross-Origin-Opener-Policy `@firefox-ubuntu-22.04-node20`
⚠️ [playwright-test] › ui-mode-test-watch.spec.ts:145 › should watch all `@windows-latest-node22`

52007 passed, 1247 skipped


Merge workflow run.

@github-actions

Copy link
Copy Markdown
Contributor

Test results for "MCP"

1 failed
❌ [firefox] › mcp/cli-session.spec.ts:54 › idle timeout shuts the session down @mcp-windows-latest-firefox

8628 passed, 1446 skipped


Merge workflow run.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants