Current behavior
TagStream.transform waits for 'drain' on itself after this.push() returns false:
https://github.com/cypress-io/cypress/blob/4df93b5/packages/stderr-filtering/lib/TagStream.ts#L74-L79
push() returning false is a readable side signal, but 'drain' belongs to the writable side, and a Transform cannot emit it until the current _transform finishes, which needs the callback() this code is holding. So the first time the readable buffer fills, the transform parks and never forwards anything again.
I drove the real class the way ProjectConfigIpc.ts:174 does, source.pipe(new TagStream()).pipe(sink), with 160 KB in 4 KB chunks and a sink that takes 5 ms per write. 69 KB arrives, then it stops for good, with readableLength sitting at 0, so there is nothing left to drain and it still never resumes. Node's own PassThrough in the same harness delivers all 160 KB.
Through the real path it needs a burst that arrives faster than the parent's stderr is read. I measured a child dumping 192 KB in one go on all three runners (run, node 24):
| stderr is |
Linux |
macOS |
Windows |
| a pipe |
parks |
parks |
no |
| a tty |
no |
no |
not tested |
A pipe is what you get in CI, under | tee, and with container log drivers. webpack writes plenty to stderr, which is what #32569 was about.
The branch does have a unit test, but it stubs once so the 'drain' listener fires synchronously, which cannot happen in reality, and that is why the suite stays green.
Desired behavior
The transform hands the chunk on and completes. A Transform needs no manual wait for its own readable side, since Node stops pulling from the writable side until the consumer reads. Without the wait it matches PassThrough, 162 KB delivered at a peak of 89 KB buffered against PassThrough's 88 KB.
The other direction is already right in this package: writeWithBackpressure waits for 'drain' on the stream it writes to, which is not the stream it pushes into.
Test code to reproduce
Drop this in packages/stderr-filtering/lib/__spec__/ and run yarn workspace @packages/stderr-filtering test. Real streams, no mocks.
import { describe, it } from 'vitest'
import { PassThrough, Readable, Transform, Writable } from 'stream'
import { TagStream } from '../TagStream'
const run = async (middle: Transform, label: string) => {
let produced = 0
let sunk = 0
const source = new Readable({
read () {
if (produced >= 40) return this.push(null)
produced++
this.push(Buffer.alloc(4096, 0x61))
},
})
const sink = new Writable({
highWaterMark: 1024,
write (chunk, _enc, cb) {
sunk += chunk.length
setTimeout(cb, 5)
},
})
source.pipe(middle).pipe(sink)
const outcome = await new Promise((resolve) => {
const timer = setTimeout(() => resolve('STUCK'), 6000)
sink.on('finish', () => {
clearTimeout(timer)
resolve('finished')
})
})
console.log(`${label}: ${outcome}, ${sunk} of 163840 bytes forwarded`)
}
describe('TagStream backpressure', () => {
it('compares against PassThrough', async () => {
await run(new PassThrough(), 'PassThrough')
await run(new TagStream(), 'TagStream')
}, 20000)
})
Cypress Version
develop @ 4df93b5. The file landed in #32188, so the 15.x line has it. I reproduced against the package source rather than an installed binary.
Other
Ubuntu, macOS and Windows runners, node 24. The package suite passes as it stands, 87 tests, so nothing catches this today.
I have a fix and an updated unit test on a branch, and the package suite stays green with it. Happy to open a draft PR if this direction looks right to you.
Current behavior
TagStream.transformwaits for'drain'on itself afterthis.push()returns false:https://github.com/cypress-io/cypress/blob/4df93b5/packages/stderr-filtering/lib/TagStream.ts#L74-L79
push()returning false is a readable side signal, but'drain'belongs to the writable side, and a Transform cannot emit it until the current_transformfinishes, which needs thecallback()this code is holding. So the first time the readable buffer fills, the transform parks and never forwards anything again.I drove the real class the way
ProjectConfigIpc.ts:174does,source.pipe(new TagStream()).pipe(sink), with 160 KB in 4 KB chunks and a sink that takes 5 ms per write. 69 KB arrives, then it stops for good, withreadableLengthsitting at 0, so there is nothing left to drain and it still never resumes. Node's ownPassThroughin the same harness delivers all 160 KB.Through the real path it needs a burst that arrives faster than the parent's stderr is read. I measured a child dumping 192 KB in one go on all three runners (run, node 24):
A pipe is what you get in CI, under
| tee, and with container log drivers. webpack writes plenty to stderr, which is what #32569 was about.The branch does have a unit test, but it stubs
onceso the'drain'listener fires synchronously, which cannot happen in reality, and that is why the suite stays green.Desired behavior
The transform hands the chunk on and completes. A Transform needs no manual wait for its own readable side, since Node stops pulling from the writable side until the consumer reads. Without the wait it matches
PassThrough, 162 KB delivered at a peak of 89 KB buffered against PassThrough's 88 KB.The other direction is already right in this package:
writeWithBackpressurewaits for'drain'on the stream it writes to, which is not the stream it pushes into.Test code to reproduce
Drop this in
packages/stderr-filtering/lib/__spec__/and runyarn workspace @packages/stderr-filtering test. Real streams, no mocks.Cypress Version
develop@ 4df93b5. The file landed in #32188, so the 15.x line has it. I reproduced against the package source rather than an installed binary.Other
Ubuntu, macOS and Windows runners, node 24. The package suite passes as it stands, 87 tests, so nothing catches this today.
I have a fix and an updated unit test on a branch, and the package suite stays green with it. Happy to open a draft PR if this direction looks right to you.