#15487·puppeteer

[Bug]: @puppeteer/browsers: Abruptly truncated downloads exit with code 0 instead of failing

Author: sbc100Created Sep 23, 2026Updated Sep 24, 2026
Labelsconfirmed@puppeteer/browsers

Steps to reproduce

We use @puppeteer/browsers in the emscripten CI to install browsers for testing: https://github.com/emscripten-core/emscripten/blob/b6f4364e625d07c497db8ecb9cb29b8f97f35cee/.circleci/config.yml#L46

Occasionally we have seen flakes that have strange output that looks like this:

    Downloading firefox nightly - 99.4 MB [                    ] 0% 0.0s                                                                
    browser installed to:                                                                                                               
    /bin/bash: line 4: : command not found                                                                                              
                                                                                                                                        
    Exited with code exit status 127   

Description

The AI analysis of what is happening looks correct to me:

When a browser download is prematurely closed or truncated by the server/network (e.g., connection reset or dropped socket midway through download), @puppeteer/browsers install exits with code 0 instead of failing with an error.

This happens particularly for browsers that do not have an expectedHash (such as Firefox Nightly), leading to silent failures in CI scripts that expect either a valid executable on stdout or a non-zero exit code.

Environment

  • Package: @puppeteer/browsers
  • Version: 3.2.3 (and latest)
  • Node: >=18
  • OS: Linux / macOS

Steps to Reproduce

Run the following minimal Node.js script that simulates a server terminating the download stream early after sending a Content-Length header:

javascript
import http from 'node:http';
import { spawn } from 'node:child_process';

const server = http.createServer((req, res) => {
  if (req.url.includes('firefox_versions.json')) {
    res.writeHead(200, { 'Content-Type': 'application/json' });
    res.end(JSON.stringify({ FIREFOX_NIGHTLY: '158.0a1' }));
    return;
  }
  // Simulate download: announce 100 MB, send 1 KB, then destroy socket abruptly
  res.writeHead(200, {
    'Content-Length': '104857600',
    'Content-Type': 'application/x-tar',
  });
  res.write(Buffer.alloc(1024));
  setTimeout(() => req.socket.destroy(), 50);
});

server.listen(0, '127.0.0.1', () => {
  const port = server.address().port;
  const cp = spawn('npx', [
    '@puppeteer/browsers',
    'install',
    'firefox@nightly',
    '--base-url',
    `http://127.0.0.1:${port}`,
    '--format={{path}}',
  ]);

  cp.stdout.on('data', d => console.log('STDOUT:', d.toString()));
  cp.stderr.on('data', d => console.error('STDERR:', d.toString()));
  cp.on('close', code => {
    console.log(`Process exited with code: ${code}`);
    server.close();
  });
});

Root Cause Analysis

There are two issues compounding this:

  1. packages/browsers/src/httpUtil.ts (downloadFile):

    typescript
    const file = createWriteStream(destinationPath);
    file.on('close', () => {
      if (verifier && expectedHash) {
        try {
          verifier.verify(url, destinationPath, expectedHash);
        } catch (err) {
          reject(err);
          return;
        }
      }
      return resolve();
    });

    When expectedHash is not supplied, file.on('close') unconditionally calls resolve(). When the socket is closed prematurely, response.pipe(file) ends the write stream and emits 'close'. It never verifies whether downloadedBytes === totalBytes against the Content-Length header (if available), nor is there an error handler attached to response (response.on('error')).

  2. packages/browsers/src/fileUtil.ts (extractTar):

    typescript
    const unpack = spawn(internalConstantsForTesting[decompressUtilityName], ['-d'], {
      stdio: ['pipe', 'pipe', 'inherit'],
    })
      .once('error', handleError(decompressUtilityName))
      .once('exit', code => {
        logger?.(DEBUG_PREFIXES.fileUtil)?.(`${decompressUtilityName} exited, code=${code}`);
      });

    If the truncated archive is passed to extractTar(), xz -d or bzip2 -d exits with a non-zero status (e.g., code 2: File format not recognized), but the .once('exit') handler only logs this to the debug logger and does not reject the promise. modern-tar reaches EOF having unpacked 0 files, emits 'finish', and fulfills the promise as successful.

Expected results

The download should detect that the stream ended prematurely (downloadedBytes < totalBytes), reject the promise, clean up the corrupted/partial download, and exit with a non-zero status code (e.g. exit code 1) with an error on stderr.

Actual results

  • npx @puppeteer/browsers install prints the progress bar up to the point of disconnection and then exits with code 0.
  • Nothing is printed to stdout (--format={{path}} produces no output).
  • Any downstream script or pipeline expecting an executable path or a non-zero failure code fails unexpectedly.