current commit
250826.1 · fix: catch synchronous errors in request callbacks (#4443)
'use strict'

const { tspl } = require('@matteo.collina/tspl')
const { test, after } = require('node:test')
const { Client } = require('..')
const { createServer } = require('node:http')

test('synchronous error in request callback should reach uncaughtException handler', async (t) => {
  const p = tspl(t, { plan: 3 })

  const server = createServer((req, res) => {
    res.end('hello')
  })
  after(() => server.close())

  await new Promise((resolve) => {
    server.listen(0, resolve)
  })

  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  const testError = new Error('sync error in callback')

  // Set up uncaughtException handler
  const originalHandler = process.listenerCount('uncaughtException') > 0
    ? process.listeners('uncaughtException')[0]
    : null

  process.removeAllListeners('uncaughtException')

  const uncaughtHandler = (err) => {
    p.strictEqual(err, testError, 'Error should reach uncaughtException handler')
    // Clean up
    process.removeListener('uncaughtException', uncaughtHandler)
    if (originalHandler) {
      process.on('uncaughtException', originalHandler)
    }
  }

  process.on('uncaughtException', uncaughtHandler)

  client.request({
    path: '/',
    method: 'GET'
  }, (err, data) => {
    p.ifError(err)
    p.strictEqual(data.statusCode, 200)

    // Destroy the stream to simulate the described scenario
    data.body.destroy()

    // This synchronous error should reach the uncaughtException handler
    throw testError
  })

  // Wait a bit to ensure the uncaughtException handler is triggered
  await new Promise(resolve => setTimeout(resolve, 100))

  // Clean up handler if not triggered
  process.removeListener('uncaughtException', uncaughtHandler)
  if (originalHandler) {
    process.on('uncaughtException', originalHandler)
  }

  await p.completed
})

test('synchronous error thrown immediately in request callback', async (t) => {
  const p = tspl(t, { plan: 3 })

  const server = createServer((req, res) => {
    res.end('hello')
  })
  after(() => server.close())

  await new Promise((resolve) => {
    server.listen(0, resolve)
  })

  const client = new Client(`http://localhost:${server.address().port}`)
  after(() => client.close())

  const testError = new Error('immediate sync error')

  // Set up uncaughtException handler
  const originalHandler = process.listenerCount('uncaughtException') > 0
    ? process.listeners('uncaughtException')[0]
    : null

  process.removeAllListeners('uncaughtException')

  const uncaughtHandler = (err) => {
    p.strictEqual(err, testError, 'Error should reach uncaughtException handler')
    // Clean up
    process.removeListener('uncaughtException', uncaughtHandler)
    if (originalHandler) {
      process.on('uncaughtException', originalHandler)
    }
  }

  process.on('uncaughtException', uncaughtHandler)

  client.request({
    path: '/',
    method: 'GET'
  }, (err, data) => {
    p.ifError(err)
    p.strictEqual(data.statusCode, 200)

    // Throw immediately without any stream operations
    throw testError
  })

  // Wait a bit to ensure the uncaughtException handler is triggered
  await new Promise(resolve => setTimeout(resolve, 100))

  // Clean up handler if not triggered
  process.removeListener('uncaughtException', uncaughtHandler)
  if (originalHandler) {
    process.on('uncaughtException', originalHandler)
  }

  await p.completed
})

test('synchronous error in request callback with error parameter', async (t) => {
  const p = tspl(t, { plan: 1 })

  const server = createServer((req, res) => {
    // Force an error by destroying the socket
    req.socket.destroy()
  })
  after(() => server.close())

  server.listen(0, () => {
    const client = new Client(`http://localhost:${server.address().port}`)
    after(() => client.close())

    client.request({
      path: '/',
      method: 'GET'
    }, (err, data) => {
      // We expect an error from the destroyed socket
      p.ok(err)

      // Don't throw here as it would interfere with test completion
      // The important tests are the ones where we get successful responses
    })
  })

  await p.completed
})