Skip to content

Commit cc88a08

Browse files
committed
fix(lambda): clear completed streaming adapter monitors
1 parent 2ffd91f commit cc88a08

2 files changed

Lines changed: 94 additions & 1 deletion

File tree

‎packages/dd-trace/src/lambda/handler.js‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ function onCompletion (callback, clear) {
237237
* @param {number} contextIndex
238238
*/
239239
function isCallbackCompletion (handler, result, contextIndex) {
240-
if (handler[HANDLER_STREAMING] === 'response') return false
240+
// datadog-lambda-js's unmarked streaming adapter still receives context as its third argument.
241+
if (contextIndex === 2 || handler[HANDLER_STREAMING] === 'response') return false
241242
if ((handler.length >= 3 && contextIndex !== 2) || result === undefined) return true
242243
if (result === null || typeof result !== 'object') return false
243244
for (let target = result; target !== null && !isProxy(target); target = Object.getPrototypeOf(target)) {

‎packages/dd-trace/test/lambda/registration.spec.js‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict'
22

33
const assert = require('node:assert/strict')
4+
const { EventEmitter } = require('node:events')
45
const path = require('node:path')
56

67
const { afterEach, beforeEach, describe, it } = require('mocha')
@@ -401,6 +402,97 @@ describe('lambda', () => {
401402
}
402403
})
403404
}
405+
406+
for (const outcome of ['undefined', 'null', 'false', 'zero', 'object', 'artifact',
407+
'fulfill', 'reject', 'throw', 'timeout']) {
408+
it(`handles ${outcome} streaming returns through the unmarked ${file} adapter`, async () => {
409+
process.env.LAMBDA_TASK_ROOT = '/var/task'
410+
process.env.DD_LAMBDA_HANDLER = 'handler.myEntry'
411+
const { hookCalls } = loadLambdaWithHookSpy()
412+
const fixturePath = `${path.resolve('/var/task', '', 'handler')}.js`
413+
const clock = sinon.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
414+
const oldTracer = global._ddtrace
415+
const beforeExit = Symbol.for('aws.lambda.beforeExit')
416+
const oldBeforeExit = globalThis[beforeExit]
417+
const killAll = sinon.spy()
418+
const span = { addTags: sinon.spy(), finish: sinon.spy() }
419+
const warmSpan = { addTags: sinon.spy(), finish: sinon.spy() }
420+
let activeSpan = span
421+
global._ddtrace = {
422+
_tracer: {
423+
_config: { DD_APM_FLUSH_DEADLINE_MILLISECONDS: 25 },
424+
_processor: { killAll },
425+
scope: () => ({ active: () => activeSpan }),
426+
},
427+
}
428+
globalThis[beforeExit] = () => {}
429+
430+
try {
431+
const stream = {}
432+
const context = { getRemainingTimeInMillis: () => 100 }
433+
const error = new Error('streaming handler failure')
434+
const values = { undefined, null: null, false: false, zero: 0, object: {}, artifact: new EventEmitter() }
435+
let complete = _result => assert.fail('The streaming handler did not capture its completion')
436+
const streaming = Symbol.for('aws.lambda.runtime.handler.streaming')
437+
const raw = (event, responseStream, ctx) => {
438+
assert.strictEqual(responseStream, stream)
439+
assert.strictEqual(ctx, context)
440+
if (event.pending || outcome === 'timeout') return new Promise(resolve => { complete = resolve })
441+
if (outcome === 'throw') throw error
442+
if (outcome === 'fulfill') return Promise.resolve('done')
443+
if (outcome === 'reject') return Promise.reject(error)
444+
return values[outcome]
445+
}
446+
raw[streaming] = 'response'
447+
const app = hookCalls[0].onrequire({ myEntry: raw }, fixturePath, undefined, '0.0.0')
448+
const moduleExports = {
449+
promisifiedHandler: handler => {
450+
assert.strictEqual(handler[streaming], 'response')
451+
// datadog-lambda-js's streaming adapter forwards all three arguments but omits the marker.
452+
return (event, responseStream, context) => handler(event, responseStream, context)
453+
},
454+
}
455+
hookCalls[1].onrequire(moduleExports, `datadog-lambda-js/${file}`, undefined, '12.143.0')
456+
const handler = moduleExports.promisifiedHandler(app.myEntry)
457+
assert.strictEqual(handler[streaming], undefined)
458+
const invoke = () => handler({}, stream, context)
459+
if (outcome === 'throw') assert.throws(invoke, candidate => candidate === error)
460+
else if (outcome === 'reject') await assert.rejects(invoke(), candidate => candidate === error)
461+
else if (outcome === 'timeout') {
462+
const invocation = invoke()
463+
clock.tick(74)
464+
sinon.assert.notCalled(killAll)
465+
clock.tick(1)
466+
sinon.assert.calledOnce(killAll)
467+
assert.strictEqual(span.addTags.firstCall.args[0]['error.type'], 'Impending Timeout')
468+
complete('done')
469+
assert.strictEqual(await invocation, 'done')
470+
} else assert.strictEqual(await invoke(), outcome === 'fulfill' ? 'done' : values[outcome])
471+
assert.strictEqual(clock.countTimers(), 0, 'the first invocation must release its monitor')
472+
473+
// RIC replaces its before-exit listener between invocations, so cleanup cannot rely on the old listener.
474+
globalThis[beforeExit] = () => {}
475+
clock.tick(25)
476+
activeSpan = warmSpan
477+
const warmInvocation = handler({ pending: true }, stream, context)
478+
clock.tick(50)
479+
assert.strictEqual(killAll.callCount, outcome === 'timeout' ? 1 : 0)
480+
sinon.assert.notCalled(warmSpan.addTags)
481+
sinon.assert.notCalled(warmSpan.finish)
482+
assert.strictEqual(clock.countTimers(), 1, 'the warm invocation must retain its own monitor')
483+
complete('warm result')
484+
assert.strictEqual(await warmInvocation, 'warm result')
485+
clock.tick(100)
486+
assert.strictEqual(killAll.callCount, outcome === 'timeout' ? 1 : 0)
487+
assert.strictEqual(clock.countTimers(), 0)
488+
} finally {
489+
clock.restore()
490+
global._ddtrace = oldTracer
491+
if (oldBeforeExit === undefined) delete globalThis[beforeExit]
492+
else globalThis[beforeExit] = oldBeforeExit
493+
}
494+
})
495+
}
404496
}
405497

406498
it('logs hook errors without unwinding the loop', () => {

0 commit comments

Comments
 (0)