Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions lib/api/api-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -118,14 +118,28 @@ class RequestHandler extends AsyncResource {
this.callback = null
this.res = res
if (callback !== null) {
this.runInAsyncScope(callback, null, null, {
statusCode,
headers,
trailers: this.trailers,
opaque,
body: res,
context
})
try {
this.runInAsyncScope(callback, null, null, {
statusCode,
headers,
trailers: this.trailers,
opaque,
body: res,
context
})
} catch (err) {
// If the callback throws synchronously, we need to handle it
// Remove reference to res to allow res being garbage collected
this.res = null

// Destroy the response stream
util.destroy(res.on('error', noop), err)

// Use queueMicrotask to re-throw the error so it reaches uncaughtException
queueMicrotask(() => {
throw err
})
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion test/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -2189,7 +2189,7 @@ test('stats', async (t) => {
}, (err, data) => {
t.ifError(err)
t.strictEqual(client.stats.connected, true)
t.strictEqual(client.stats.pending, 1)
t.strictEqual(client.stats.pending, 0)
t.strictEqual(client.stats.running, 1)
})
})
Expand Down
153 changes: 153 additions & 0 deletions test/sync-error-in-callback.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'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
})
Loading