Skip to content

TagStream deadlocks when push() returns false, stderr from the config process stops being forwarded #34439

Description

@ondraulehla

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions