diff --git a/packages/plugins/apps/src/vite/execution-epoch.test.ts b/packages/plugins/apps/src/vite/execution-epoch.test.ts new file mode 100644 index 000000000..5bb180fc0 --- /dev/null +++ b/packages/plugins/apps/src/vite/execution-epoch.test.ts @@ -0,0 +1,55 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +import { createEpochGuard } from '@dd/apps-plugin/vite/execution-epoch'; + +describe('execution-epoch — createEpochGuard', () => { + test('Should invalidate an older scope once a newer one starts', () => { + const guard = createEpochGuard(); + const older = guard.start(); + expect(older.isCurrent()).toBe(true); + + const newer = guard.start(); + expect(older.isCurrent()).toBe(false); + expect(newer.isCurrent()).toBe(true); + }); + + test('Should make concludeIfCurrent a no-op returning false for an already-superseded scope', () => { + const guard = createEpochGuard(); + const older = guard.start(); + const newer = guard.start(); + + expect(older.concludeIfCurrent()).toBe(false); + // The newer scope must be unaffected by the older one's no-op conclude. + expect(newer.isCurrent()).toBe(true); + }); + + test('Should conclude a still-current scope, marking it no longer current', () => { + const guard = createEpochGuard(); + const scope = guard.start(); + + expect(scope.concludeIfCurrent()).toBe(true); + expect(scope.isCurrent()).toBe(false); + }); + + test('Should make a second concludeIfCurrent call on the same scope a no-op', () => { + const guard = createEpochGuard(); + const scope = guard.start(); + + expect(scope.concludeIfCurrent()).toBe(true); + expect(scope.concludeIfCurrent()).toBe(false); + }); + + test('Should keep independently-created guards from sharing any state', () => { + const guardA = createEpochGuard(); + const guardB = createEpochGuard(); + + const scopeA = guardA.start(); + const scopeB = guardB.start(); + + guardA.start(); + expect(scopeA.isCurrent()).toBe(false); + expect(scopeB.isCurrent()).toBe(true); + }); +}); diff --git a/packages/plugins/apps/src/vite/execution-epoch.ts b/packages/plugins/apps/src/vite/execution-epoch.ts new file mode 100644 index 000000000..cb058117b --- /dev/null +++ b/packages/plugins/apps/src/vite/execution-epoch.ts @@ -0,0 +1,38 @@ +// Unless explicitly stated otherwise all files in this repository are licensed under the MIT License. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2019-Present Datadog, Inc. + +/** Generation-counter guard so an abandoned scope's late cleanup can't touch a shared resource a newer scope now owns (used by `local-execution.ts`). */ +export interface EpochScope { + /** True until a newer scope starts, or this scope is concluded or invalidated. */ + isCurrent(): boolean; + /** Marks no scope active and returns true if still current, otherwise a no-op returning false — call in a `finally` to gate cleanup on still owning the resource. */ + concludeIfCurrent(): boolean; +} + +export interface EpochGuard { + /** Starts a new scope, superseding whichever one was previously active. */ + start(): EpochScope; +} + +export function createEpochGuard(): EpochGuard { + let currentGeneration = 0; + let activeGeneration: number | null = null; + + return { + start() { + const myGeneration = ++currentGeneration; + activeGeneration = myGeneration; + return { + isCurrent: () => activeGeneration === myGeneration, + concludeIfCurrent: () => { + if (activeGeneration === myGeneration) { + activeGeneration = null; + return true; + } + return false; + }, + }; + }, + }; +} diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts index ce411dd31..1be4470d8 100644 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ b/packages/plugins/apps/src/vite/local-execution.test.ts @@ -31,7 +31,7 @@ interface TestGlobalDollar { Source: { initiator: { id: string; orgId: string }; runAsUser: { id: string; orgId: string } }; } -/** Reads the `$` this module installs on `globalThis`, from the customer-code perspective these tests simulate — untyped since it's a runtime-only property (see `setGlobalDollar`). Centralized here instead of repeating the cast at each call site. */ +/** Reads the `$` local-execution.ts installs onto `globalThis` via `Object.defineProperty` — genuinely untyped, so the cast is centralized here instead of repeated at each call site. */ function testDollar(): TestGlobalDollar { return (globalThis as unknown as { $: TestGlobalDollar }).$; } @@ -56,6 +56,8 @@ function loadModuleReturning(exports: Record): LoadModule { }; } +const ORDER_MARKER = '__ddLocalExecutionTestOrder'; + describe('local-execution — executeScriptLocally', () => { test('Should run a simple function in-process and return its result', async () => { const result = await executeScriptLocally( @@ -103,11 +105,13 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(`"example" is not a function exported from ${func.absolutePath}`); }); - test('Should load and evaluate the customer module before installing globalThis.$, matching production module-evaluation order', async () => { + test('Should read $ as undefined when a customer module reaches for it during its own top-level evaluation, matching production module-evaluation order', async () => { let dollarDuringModuleLoad: unknown = 'not captured'; const loadModule: LoadModule = async (specifier) => { if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - // Captures globalThis.$ at module-evaluation time — production's static import runs before its wrapper installs $, so code reaching for $ during top-level evaluation must see the same absence locally. + // Production's static import also runs before its wrapper installs $, so $ isn't a + // global property yet — reading it must resolve to undefined the same way locally, + // not throw (typeof $ never throws on an unresolvable reference in production). dollarDuringModuleLoad = (globalThis as Record).$; return { example: () => 'done' }; } @@ -131,6 +135,128 @@ describe('local-execution — executeScriptLocally', () => { expect(dollarDuringModuleLoad).toBeUndefined(); }); + test("Should return a pre-existing globalThis.$ during a customer module's top-level evaluation when something (e.g. zx/globals) seeded it before this module loaded", async () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, '$'); + const preExisting = { fromZxGlobals: true }; + (globalThis as Record).$ = preExisting; + let isolatedExecuteScriptLocally!: typeof executeScriptLocally; + try { + jest.isolateModules(() => { + // A fresh module instance re-runs its Reflect.has check with preExisting already set; the outer instance was imported too early to exercise this path. + isolatedExecuteScriptLocally = require('./local-execution').executeScriptLocally; + }); + + let dollarDuringModuleLoad: unknown = 'not captured'; + const loadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + dollarDuringModuleLoad = (globalThis as Record).$; + return { example: () => 'done' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }; + + const result = await isolatedExecuteScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + expect(result).toEqual({ data: 'done' }); + expect(dollarDuringModuleLoad).toBe(preExisting); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, '$', originalDescriptor); + } else { + delete (globalThis as Record).$; + } + } + }); + + test('Should reinstall the $ accessor if a customer execution deleted globalThis.$, so a later execution can still use it', async () => { + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + delete (globalThis as Record).$; + return 'first'; + }, + }), + mockLogger, + ); + + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => testDollar().backendFunctionArgs }), + mockLogger, + ); + expect(second).toEqual({ data: [] }); + }); + + test("Should not leak one execution's top-level zx/globals-style $ write into a later execution's own top-level load", async () => { + const firstLoadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + // Simulates a top-level side effect (e.g. `import 'zx/globals'`) writing $ before this execution's box exists. + (globalThis as Record).$ = { + fromFirstExecutionTopLevel: true, + }; + return { example: () => 'first' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }; + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + firstLoadModule, + mockLogger, + ); + + let dollarDuringSecondLoad: unknown = 'not captured'; + const secondLoadModule: LoadModule = async (specifier) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + dollarDuringSecondLoad = (globalThis as Record).$; + return { example: () => 'second' }; + } + throw new Error(`Cannot find module '${specifier}'`); + }; + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + secondLoadModule, + mockLogger, + ); + + expect(dollarDuringSecondLoad).toBeUndefined(); + }); + + test('Should reject when loadModule itself rejects, same as a native-module load failure would', async () => { + // Simulates a native addon failing to load at import time — not a customer function throwing. + const loadModule: LoadModule = async () => { + throw new Error('cannot find native module'); + }; + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ), + ).rejects.toThrow('cannot find native module'); + }); + test('Should resolve a $.Actions.foo.bar(...) call through the injected executeAction, including connectionId', async () => { const executeAction = jest.fn().mockResolvedValue({ ok: true }); const result = await executeScriptLocally( @@ -155,20 +281,21 @@ describe('local-execution — executeScriptLocally', () => { ); }); - test('Should not hang when a customer function returns an un-invoked $.Actions reference instead of calling it', async () => { - // $.Actions.slack.chat is itself a callable Proxy; returning it without the trailing .postMessage(...) call must not make `await fn(...args)` treat it as a thenable and hang. - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => testDollar().Actions.slack.chat, - }), - mockLogger, - 20, - ); - expect(result.data).toBeDefined(); + test('Should reject with a clear error, not hang, when a customer function returns an un-invoked $.Actions reference instead of calling it', async () => { + // Returning $.Actions.slack.chat un-invoked must not be mistaken for a thenable (hang) or leak an unhandled rejection — just the ordinary "can't be serialized" error. + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => testDollar().Actions.slack.chat, + }), + mockLogger, + 20, + ), + ).rejects.toThrow(/JSON\.stringify silently drops/); }); test('Should resolve a single-segment $.Actions.foo(...) call to a single-segment fqn', async () => { @@ -366,6 +493,29 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/timed out after 50ms/); }); + // Proves the hang-detection timer only fires for a genuinely stuck execution, not for a legitimate in-flight $.Actions call that's still comfortably within its budget. + test('Should resolve normally when a legitimate in-flight $.Actions call finishes well within the timeout, without the hang-detection timer misfiring', async () => { + const executeAction: ExecuteAction = jest.fn( + () => new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 50)), + ); + const result = await executeScriptLocally( + funcWithConnection, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: () => + testDollar().Actions.slack.chat.postMessage({ + inputs: { text: 'hi' }, + connectionId: 'conn-1', + }), + }), + mockLogger, + 500, + ); + expect(result).toEqual({ data: { ok: true } }); + }); + // The caller already moved on after the timeout rejection above; this covers the abandoned execution's own eventual failure, which has no caller left to report it to. test('Should log a late failure from an abandoned execution instead of swallowing it silently', async () => { let rejectHung: ((error: Error) => void) | undefined; @@ -487,7 +637,24 @@ describe('local-execution — executeScriptLocally', () => { }); }); - test('Should restore a pre-existing globalThis.$ (e.g. from zx/globals) once the execution completes, not leave the execution context in place permanently', async () => { + test('Should allow a customer module to assign to globalThis.$ (e.g. importing zx/globals) without throwing', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + (globalThis as Record).$ = { notOurs: true }; + return 'done'; + }, + }), + mockLogger, + ); + expect(result).toEqual({ data: 'done' }); + }); + + test('Should restore a pre-existing globalThis.$ (e.g. from zx/globals) once the execution completes, even if the customer function reassigned it', async () => { const preExisting = { notOurs: true }; (globalThis as Record).$ = preExisting; try { @@ -497,44 +664,39 @@ describe('local-execution — executeScriptLocally', () => { [], stubExecuteAction, loadModuleReturning({ - example: () => testDollar().backendFunctionArgs, + example: () => { + (globalThis as Record).$ = { reassigned: true }; + return 'done'; + }, }), mockLogger, ); - expect(result).toEqual({ data: [] }); - // Compares via a plain boolean, not .toBe() directly — $.Actions's get trap returns a Proxy for every property, which crashes Jest's diff formatting if this assertion ever fails. + expect(result).toEqual({ data: 'done' }); expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); } finally { - delete (globalThis as Record).$; + (globalThis as Record).$ = undefined; } }); - test("Should restore a pre-existing globalThis.$ even when the customer function throws, not leave the execution's context behind", async () => { - const preExisting = { notOurs: true }; + test('Should seed the outside-execution slot from a globalThis.$ that already existed before this module was first loaded', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, '$'); + const preExisting = { fromZxGlobals: true }; (globalThis as Record).$ = preExisting; try { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - throw new Error('customer function failed'); - }, - }), - mockLogger, - ), - ).rejects.toThrow('customer function failed'); - expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); + jest.isolateModules(() => { + // A fresh module instance re-runs its top-level Object.defineProperty and must read the current $ (still `preExisting`) rather than start from an empty slot. + require('./local-execution'); + }); + expect((globalThis as Record).$).toBe(preExisting); } finally { - delete (globalThis as Record).$; + if (originalDescriptor) { + Object.defineProperty(globalThis, '$', originalDescriptor); + } } }); - test('Should remove globalThis.$ once the execution completes when nothing was previously defined there', async () => { - delete (globalThis as Record).$; + test('Should read globalThis.$ as undefined once the execution completes when nothing was defined before it started', async () => { + (globalThis as Record).$ = undefined; await executeScriptLocally( func, TEST_PROJECT_ROOT, @@ -543,7 +705,35 @@ describe('local-execution — executeScriptLocally', () => { loadModuleReturning({ example: () => 'done' }), mockLogger, ); - expect(Object.prototype.hasOwnProperty.call(globalThis, '$')).toBe(false); + expect((globalThis as Record).$).toBeUndefined(); + }); + + test("Should not leak one execution's globalThis.$ override into a later, separately-queued execution", async () => { + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + (globalThis as Record).$ = { fromFirstExecution: true }; + return 'first'; + }, + }), + mockLogger, + ); + + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => Object.keys(testDollar()).sort(), + }), + mockLogger, + ); + expect(second).toEqual({ data: ['Actions', 'Source', 'backendFunctionArgs'] }); }); describe('action-catalog / apps-backend registration', () => { @@ -560,6 +750,96 @@ describe('local-execution — executeScriptLocally', () => { expect(result).toEqual({ data: 'fine' }); }); + test('Should pick up action-catalog on the very next execution after it becomes installed mid-session, not stay permanently skipped', async () => { + const isInstalledSpy = jest + .spyOn(shared, 'isActionCatalogInstalled') + .mockReturnValue(false); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'fine' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + // Not installed yet — registration is skipped, same as the "neither package installed" case. + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + expect(registeredImpl).toBeUndefined(); + + // Simulates a mid-session install — the very next execution must register it, not stay skipped from the earlier uncached check. + isInstalledSpy.mockReturnValue(true); + await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + expect(registeredImpl).toBeDefined(); + }); + + // The happy-path counterpart to the "shared loadModule with a never-settling load" test below: proves the plain success case is deduped too, not just the failure/eviction paths. + test('Should load the action-catalog module only once across two successful executions that share the same loadModule', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + let actionCatalogLoadCount = 0; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'ok' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + actionCatalogLoadCount += 1; + return { setExecuteActionImplementation: () => {} }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const first = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + + expect(first).toEqual({ data: 'ok' }); + expect(second).toEqual({ data: 'ok' }); + expect(actionCatalogLoadCount).toBe(1); + }); + test('Should propagate a real load failure from an installed action-catalog package, not treat it as absent', async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); const loadModule: LoadModule = async (specifier: string) => { @@ -587,22 +867,17 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow('Unexpected token in action-catalog/action-execution'); }); - test('Should route an action-catalog typed-wrapper call through the same injected executeAction', async () => { + // The sibling registration failing doesn't affect this adapter — it's stable and execution-agnostic, so it rejects on its own once no execution is active. + test('Should still reject a typed-wrapper call through a successfully-registered action-catalog implementation after the sibling apps-backend registration genuinely fails and the execution concludes', async () => { jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const executeAction = jest.fn().mockResolvedValue({ ok: true }); + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); let registeredImpl: | ((actionId: string, request: unknown) => Promise) | undefined; const loadModule: LoadModule = async (specifier: string) => { if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { - example: async () => - registeredImpl?.('com.datadoghq.slack.chat.postMessage', { - inputs: { text: 'hi' }, - connectionId: 'conn-1', - }), - }; + return { example: () => 'unreachable' }; } if (specifier === '@datadog/action-catalog/action-execution') { return { @@ -613,23 +888,77 @@ describe('local-execution — executeScriptLocally', () => { }, }; } + if (specifier === '@datadog/apps-backend/runtime/jsFunctionWithActions') { + // A real transform/evaluation failure, not module-not-found — must not be swallowed as "package isn't installed". + throw new Error( + 'Unexpected token in apps-backend/runtime/jsFunctionWithActions', + ); + } const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); error.code = 'MODULE_NOT_FOUND'; throw error; }; - const result = await executeScriptLocally( - funcWithConnection, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.slack.chat.postMessage', - { text: 'hi' }, + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ), + ).rejects.toThrow('Unexpected token in apps-backend/runtime/jsFunctionWithActions'); + + expect(registeredImpl).toBeDefined(); + await expect( + registeredImpl?.('com.datadoghq.slack.chat.postMessage', { inputs: {} }), + ).rejects.toThrow(/no active local execution/i); + }); + + test('Should route an action-catalog typed-wrapper call through the same injected executeAction', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => + registeredImpl?.('com.datadoghq.slack.chat.postMessage', { + inputs: { text: 'hi' }, + connectionId: 'conn-1', + }), + }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); + error.code = 'MODULE_NOT_FOUND'; + throw error; + }; + + const result = await executeScriptLocally( + funcWithConnection, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModule, + mockLogger, + ); + expect(result).toEqual({ data: { ok: true } }); + expect(executeAction).toHaveBeenCalledWith( + 'com.datadoghq.slack.chat.postMessage', + { text: 'hi' }, 'conn-1', ); }); @@ -720,65 +1049,1070 @@ describe('local-execution — executeScriptLocally', () => { ).rejects.toThrow(/must have an inputs field/); expect(executeAction).not.toHaveBeenCalled(); }); + + // Mirrors the action-catalog abandonment test — apps-backend's setBackend has the same shared-module-level-setter hazard. + test("Should reject an abandoned execution's apps-backend accessor call once concluded", async () => { + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); + let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + let registeredBackend: { get: () => unknown } | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + registeredBackend?.get(); + abandonedCallOutcome = 'resolved'; + } catch (err) { + abandonedCallOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return { data: 'abandoned' }; + }, + }; + } + if (specifier === '@datadog/apps-backend/runtime/jsFunctionWithActions') { + return { + // Mirrors the real package's synchronous $.Source validation, so a poisoned proxy passed through here fails the same way. + buildRuntimeFromJsFunctionWithActions: ($: unknown) => { + const source = ($ as Record).Source as + | { initiator?: unknown } + | undefined; + if (!source || typeof source.initiator !== 'object') { + throw new Error( + 'Invalid $.Source supplied to buildRuntimeFromJsFunctionWithActions', + ); + } + return { get: () => source }; + }, + }; + } + if (specifier === '@datadog/apps-backend/runtime') { + return { + setBackend: (runtime: { get: () => unknown }) => { + registeredBackend = runtime; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(abandonedCallOutcome).toEqual({ + rejected: expect.stringContaining('already concluded'), + }); + }); + + // A flat method reading its own internal state via `this` (a real, common accessor + // pattern) must still work when called through the backend-runtime proxy — not just + // arrow-function methods that close over data instead, which every other test here uses. + test('Should preserve `this` when a flat apps-backend runtime method reads its own internal state', async () => { + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); + let registeredBackend: { getUserId(): string } | undefined; + let capturedUserId: unknown; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: () => { + capturedUserId = registeredBackend?.getUserId(); + return 'done'; + }, + }; + } + if (specifier === '@datadog/apps-backend/runtime/jsFunctionWithActions') { + return { + buildRuntimeFromJsFunctionWithActions: () => ({ + userId: 'real-user-id', + // A real accessor pattern: reads its own instance state via `this`, + // not a closure — throws if called unbound. + getUserId() { + if ( + !this || + typeof (this as { userId?: unknown }).userId !== 'string' + ) { + throw new Error('getUserId called with no `this`'); + } + return (this as { userId: string }).userId; + }, + }), + }; + } + if (specifier === '@datadog/apps-backend/runtime') { + return { + setBackend: (runtime: { getUserId(): string }) => { + registeredBackend = runtime; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + ); + + expect(result).toEqual({ data: 'done' }); + expect(capturedUserId).toBe('real-user-id'); + }); }); - describe('serialization of concurrent executions', () => { - function delayedResult(label: T, delayMs: number): () => Promise { - return () => new Promise((resolve) => setTimeout(() => resolve(label), delayMs)); - } + describe('non-serializable results', () => { + test('Should reject with a clear, attributed error when the result has a circular reference', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + const o: Record = {}; + o.self = o; + return o; + }, + }), + mockLogger, + ), + ).rejects.toThrow(/example.*can't be serialized to JSON/); + }); - test("Should allow two independent calls to run without cross-contaminating each other's result", async () => { - const [resultA, resultB] = await Promise.all([ + test('Should reject with a clear, attributed error when the result contains a BigInt', async () => { + await expect( executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: delayedResult('A', 20) }), + loadModuleReturning({ example: () => BigInt(10) }), mockLogger, ), + ).rejects.toThrow(/example.*can't be serialized to JSON/); + }); + + test('Should reject with a clear, attributed error when the result is a bare function (silently dropped by JSON.stringify)', async () => { + await expect( executeScriptLocally( func, TEST_PROJECT_ROOT, [], stubExecuteAction, - loadModuleReturning({ example: delayedResult('B', 0) }), + loadModuleReturning({ example: () => function notSerializable() {} }), mockLogger, ), - ]); - expect([resultA, resultB]).toEqual([{ data: 'A' }, { data: 'B' }]); + ).rejects.toThrow(/example.*JSON.stringify silently drops/); }); - // Reads $.backendFunctionArgs after a delay, which is what would surface cross-contamination between concurrent calls' globalThis.$. - function readOwnArgsAfterDelay(delayMs: number): () => Promise { - return () => - new Promise((resolve) => - setTimeout(() => resolve(testDollar().backendFunctionArgs), delayMs), - ); - } + test('Should reject with a clear, attributed error when the result is a Map (silently flattened to "{}" by JSON.stringify)', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => new Map([['a', 1]]) }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently flattens/); + }); - // Known race: two concurrent calls both write globalThis.$ synchronously, so the second write wins for both — skip until calls are serialized through an execution queue. - test.skip("Should let each concurrent call see its OWN backendFunctionArgs via globalThis.$, not the other call's", async () => { - const [resultA, resultB] = await Promise.all([ + test('Should reject with a clear, attributed error when the result is a Set (silently flattened to "{}" by JSON.stringify)', async () => { + await expect( executeScriptLocally( func, TEST_PROJECT_ROOT, - ['A-arg'], + [], stubExecuteAction, - loadModuleReturning({ example: readOwnArgsAfterDelay(20) }), + loadModuleReturning({ example: () => new Set([1, 2, 3]) }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently flattens/); + }); + + test('Should reject with a clear, attributed error when the result is NaN (silently converted to "null" by JSON.stringify)', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => NaN }), mockLogger, ), + ).rejects.toThrow(/example.*silently converts to "null"/); + }); + + test('Should reject with a clear, attributed error when the result is Infinity (silently converted to "null" by JSON.stringify)', async () => { + await expect( executeScriptLocally( func, TEST_PROJECT_ROOT, - ['B-arg'], + [], stubExecuteAction, - loadModuleReturning({ example: readOwnArgsAfterDelay(0) }), + loadModuleReturning({ example: () => Infinity }), mockLogger, ), - ]); - expect(resultA).toEqual({ data: ['A-arg'] }); - expect(resultB).toEqual({ data: ['B-arg'] }); + ).rejects.toThrow(/example.*silently converts to "null"/); + }); + + test('Should reject a Map nested inside a plain object, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ data: new Map([['a', 1]]) }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently flattens/); + }); + + test('Should reject a Set nested inside an array, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => [1, new Set([1, 2, 3])] }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently flattens/); + }); + + test('Should reject a NaN nested inside a plain object, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ score: NaN }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*silently converts to "null"/); + }); + + test('Should reject a function nested inside a plain object, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ status: 'ok', callback: () => {} }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should reject a Symbol-keyed property, which JSON.stringify silently omits with no replacer call at all', async () => { + const secretSymbol = Symbol('secret'); + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => ({ status: 'ok', [secretSymbol]: 'leaked' }), + }), + mockLogger, + ), + ).rejects.toThrow(/example.*Symbol-keyed property/); + }); + + test('Should reject a Symbol-keyed property nested inside an array, not just at the top level', async () => { + const secretSymbol = Symbol('secret'); + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => [{ [secretSymbol]: 'leaked' }] }), + mockLogger, + ), + ).rejects.toThrow(/example.*Symbol-keyed property/); + }); + + test('Should reject an explicit undefined nested inside a plain object, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ status: 'ok', extra: undefined }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should reject an explicit undefined at a property literally named the empty string, not mistake it for the JSON root', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ '': undefined, other: 'ok' }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should reject a function at a property literally named the empty string, not mistake it for the JSON root', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => ({ '': () => {}, other: 'ok' }) }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should reject a Symbol nested inside an array, not just at the top level', async () => { + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => [1, Symbol('unsupported')] }), + mockLogger, + ), + ).rejects.toThrow(/example.*JSON.stringify silently drops/); + }); + + test('Should allow an explicit undefined result through unchanged', async () => { + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => undefined }), + mockLogger, + ); + expect(result).toEqual({ data: undefined }); + }); + + // dev-server.ts serializes the result again for the HTTP response — returning the original (not the parsed round-trip) would invoke a custom toJSON() twice. + test('Should return the JSON-round-tripped value, not the original, so a custom toJSON() is only invoked once', async () => { + let toJsonCallCount = 0; + const result = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => ({ + toJSON() { + toJsonCallCount += 1; + return { callNumber: toJsonCallCount }; + }, + }), + }), + mockLogger, + ); + expect(result).toEqual({ data: { callNumber: 1 } }); + expect(toJsonCallCount).toBe(1); + }); + }); + + describe('serialization of concurrent executions', () => { + beforeEach(() => { + delete (globalThis as Record)[ORDER_MARKER]; + }); + + function recordingOrder(label: string, delayMs: number): () => Promise { + return async () => { + const marker = + ((globalThis as Record)[ORDER_MARKER] as string[]) ?? []; + (globalThis as Record)[ORDER_MARKER] = marker; + marker.push(`start-${label}`); + await new Promise((r) => setTimeout(r, delayMs)); + marker.push(`end-${label}`); + return label; + }; + } + + test('Should never interleave two concurrent executions — the second never starts until the first fully finishes', async () => { + const [resultA, resultB] = await Promise.all([ + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: recordingOrder('A', 20) }), + mockLogger, + ), + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: recordingOrder('B', 0) }), + mockLogger, + ), + ]); + + expect([resultA, resultB]).toEqual([{ data: 'A' }, { data: 'B' }]); + const order = (globalThis as Record)[ORDER_MARKER] as string[]; + // Whichever call runs first, its start/end pair must be adjacent — a real race would interleave as [start-A, start-B, end-B, end-A]. + expect(order).toEqual([ + expect.stringMatching(/^start-/), + expect.stringMatching(/^end-/), + expect.stringMatching(/^start-/), + expect.stringMatching(/^end-/), + ]); + expect(order[0].slice('start-'.length)).toEqual(order[1].slice('end-'.length)); + expect(order[2].slice('start-'.length)).toEqual(order[3].slice('end-'.length)); + }); + + function readOwnArgsAfterDelay(delayMs: number): () => Promise { + return () => + new Promise((resolve) => + setTimeout(() => resolve(testDollar().backendFunctionArgs), delayMs), + ); + } + + // globalThis.$ is scoped per call via AsyncLocalStorage, independent of the enqueue queue (which exists for the action-catalog/apps-backend module-singleton race). + test("Should let each concurrent call see its OWN backendFunctionArgs via globalThis.$, not the other call's", async () => { + const [resultA, resultB] = await Promise.all([ + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + ['A-arg'], + stubExecuteAction, + loadModuleReturning({ example: readOwnArgsAfterDelay(20) }), + mockLogger, + ), + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + ['B-arg'], + stubExecuteAction, + loadModuleReturning({ example: readOwnArgsAfterDelay(0) }), + mockLogger, + ), + ]); + expect(resultA).toEqual({ data: ['A-arg'] }); + expect(resultB).toEqual({ data: ['B-arg'] }); + }); + + test('Should still run the next queued execution after an earlier one rejects', async () => { + const first = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: () => { + throw new Error('first fails'); + }, + }), + mockLogger, + ); + const second = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 2 }), + mockLogger, + ); + + await expect(first).rejects.toThrow('first fails'); + await expect(second).resolves.toEqual({ data: 2 }); + }); + + // Covers the raw-$.Actions path: a captured Actions reference must reject once abandoned, even after globalThis.$ is overwritten by a newer execution. + test('Should reject a captured $.Actions reference once its own execution is abandoned, even after a newer execution has taken over', async () => { + let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ + example: async () => { + // Captured BEFORE the timeout fires — this execution's own Actions proxy, not whatever globalThis.$ points to later. + const { Actions } = testDollar(); + // Outlives the 20ms timeout below, so the caller already sees a rejection by the time this line runs. + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + await Actions.foo.bar({ inputs: {} }); + abandonedCallOutcome = 'resolved'; + } catch (err) { + abandonedCallOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return { data: 'abandoned' }; + }, + }), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // The queue is free as soon as the timeout wins — the second execution starts and completes normally, becoming "current". + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModuleReturning({ example: () => 'second' }), + mockLogger, + ); + expect(second).toEqual({ data: 'second' }); + + // Give the abandoned execution's background timer room to fire its action call before asserting on the outcome. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(abandonedCallOutcome).toEqual({ + rejected: expect.stringContaining('already concluded'), + }); + }); + + test("Should resolve a zombie execution's FRESH read of globalThis.$ to its OWN identity, never a newer execution's — even while that newer execution is still in flight", async () => { + const funcA: BackendFunction = { ...func, allowedConnectionIds: ['conn-A'] }; + const funcB: BackendFunction = { ...func, allowedConnectionIds: ['conn-B'] }; + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + + let zombieOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + + const abandoned = executeScriptLocally( + funcA, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: async () => { + // Fires ~60ms in, inside funcB's in-flight window — a fresh $ read here needs AsyncLocalStorage or it would resolve to funcB's $. + await new Promise((resolve) => setTimeout(resolve, 60)); + const $ = testDollar(); + try { + // funcB's own connectionId, not funcA's — only valid if this call incorrectly runs under funcB's still-live identity. + await $.Actions.foo.bar({ inputs: {}, connectionId: 'conn-B' }); + zombieOutcome = 'resolved'; + } catch (err) { + zombieOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return 'zombie-done'; + }, + }), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // Stays "current" for 80ms, overlapping the zombie's 60ms wakeup; never calls $.Actions itself, so any observed call must be the zombie's. + const second = executeScriptLocally( + funcB, + TEST_PROJECT_ROOT, + [], + executeAction, + loadModuleReturning({ + example: async () => { + await new Promise((resolve) => setTimeout(resolve, 80)); + return 'second'; + }, + }), + mockLogger, + ); + await expect(second).resolves.toEqual({ data: 'second' }); + + // The zombie's fresh read resolved to its own $ (funcA's allowedConnectionIds), so funcB's connectionId is rejected before reaching executeAction. + expect(zombieOutcome).toEqual({ + rejected: expect.stringContaining("not in this function's allowed connections"), + }); + expect(executeAction).not.toHaveBeenCalled(); + }); + + // The dispatcher resolves the calling execution's dispatch from AsyncLocalStorage at call time — a per-closure guard alone would be bypassed once a newer execution re-registers. + test("Should reject an abandoned execution's action-catalog typed-wrapper call, not silently run it under a newer registration", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + let abandonedCallOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { + example: async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + try { + await registeredImpl?.('com.datadoghq.foo.bar', { inputs: {} }); + abandonedCallOutcome = 'resolved'; + } catch (err) { + abandonedCallOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return { data: 'abandoned' }; + }, + }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // No second execution registers here — the call is rejected because the dispatcher resolves this execution's own dispatch, already concluded by the 20ms timeout. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(abandonedCallOutcome).toEqual({ + rejected: expect.stringContaining('already concluded'), + }); + }); + + // registeredImpl points at funcB's registration once it registers, but a call from within funcA's own continuation must still resolve funcA's concluded dispatch via AsyncLocalStorage and be rejected, not routed through funcB's identity. + test("Should reject a zombie execution's action-catalog typed-wrapper call even after a newer execution has legitimately re-registered its own implementation", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const funcA: BackendFunction = { ...func, allowedConnectionIds: ['conn-A'] }; + const funcB: BackendFunction = { ...func, allowedConnectionIds: ['conn-B'] }; + const executeAction = jest.fn().mockResolvedValue({ ok: true }); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + let zombieOutcome: 'pending' | 'resolved' | { rejected: string } = 'pending'; + + const makeLoadModule = (exampleImpl: () => Promise): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: exampleImpl }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + }; + + // Times out at 20ms, then calls the typed wrapper ~60ms in — inside funcB's in-flight window — using conn-B, a connection funcA is never allowed to use. + const abandoned = executeScriptLocally( + funcA, + TEST_PROJECT_ROOT, + [], + executeAction, + makeLoadModule(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)); + try { + await registeredImpl?.('com.datadoghq.foo.bar', { + inputs: {}, + connectionId: 'conn-B', + }); + zombieOutcome = 'resolved'; + } catch (err) { + zombieOutcome = { + rejected: err instanceof Error ? err.message : String(err), + }; + } + return 'zombie-done'; + }), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // Starts as soon as the queue frees, registers immediately, but doesn't conclude until 80ms — overlapping funcA's 60ms zombie wakeup. + const second = executeScriptLocally( + funcB, + TEST_PROJECT_ROOT, + [], + executeAction, + makeLoadModule(() => new Promise((resolve) => setTimeout(() => resolve('B'), 80))), + mockLogger, + ); + await expect(second).resolves.toEqual({ data: 'B' }); + + // funcB's own registration checks conn-B against funcB's allowedConnectionIds, which passes — the zombie call must not be allowed to reach that registration at all. + expect(zombieOutcome).toEqual({ + rejected: expect.stringContaining('already concluded'), + }); + expect(executeAction).not.toHaveBeenCalled(); + }); + + // The apps-backend loadModule hangs forever, so a post-Promise.all destructuring would never run — publishing each handle via its own .then() is what lets the completed action-catalog registration still take effect. + test('Should still register the action-catalog adapter even when the sibling apps-backend registration never settles, and reject a call once no execution is active', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(true); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'unused' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + if ( + specifier === '@datadog/apps-backend/runtime/jsFunctionWithActions' || + specifier === '@datadog/apps-backend/runtime' + ) { + return new Promise(() => {}); + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + expect(registeredImpl).toBeDefined(); + await expect(registeredImpl?.('com.datadoghq.foo.bar', { inputs: {} })).rejects.toThrow( + /no active local execution/i, + ); + }); + + // Deliberately reuses one loadModule across both calls (not the usual per-call closure) — a real dev server does the same, so a load that never settles must not permanently poison later executions sharing it. + test('Should let a later execution register and run after an earlier one shared the same loadModule with a registration load that never settles', async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + + let actionCatalogLoadCount = 0; + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'ok' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + actionCatalogLoadCount += 1; + if (actionCatalogLoadCount === 1) { + // Simulates a genuinely broken/circular module graph, not just a slow one. + return new Promise(() => {}); + } + return { setExecuteActionImplementation: () => {} }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const first = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ); + await expect(first).rejects.toThrow(/timed out after 20ms/); + + // Gives the first attempt's own registration timeout (also ~20ms, started microseconds after + // the execution's own timeout above) room to fire and evict its cache entry, the same way a + // real dev server's next request would naturally arrive well after that — not racing the two. + await new Promise((resolve) => setTimeout(resolve, 30)); + + // Without evicting the first attempt's still-pending registration, this would hang until it also times out — never actually invoking its own function. + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 50, + ); + expect(second).toEqual({ data: 'ok' }); + }); + + // An abandoned execution's fn() can settle normally later — its finally block's conclude step must not disturb whatever a newer execution's own registration already put in place. + test("Should not let a late-settling abandoned execution's own conclusion clobber a newer execution's already-registered action-catalog implementation", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const makeLoadModule = (exampleImpl: () => Promise): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: exampleImpl }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + }; + + // Times out at 20ms, but its own fn() resolves normally ~100ms later, well after being abandoned. + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule( + () => new Promise((resolve) => setTimeout(() => resolve('A-late'), 100)), + ), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // The queue is free as soon as the timeout wins — the second execution registers and finishes well before the abandoned one's 100ms sleep is up. + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule(() => Promise.resolve('B')), + mockLogger, + ); + expect(second).toEqual({ data: 'B' }); + + // Captures whatever B's own conclusion left registered — B's own registration staying in place after it concludes is fine; nothing else must overwrite it. + const registeredAfterB = registeredImpl; + + // Give the abandoned execution's late-settling fn() and its finally block room to run. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(registeredImpl).toBe(registeredAfterB); + }); + + // A's slow-to-resolve registration re-installs the same stable dispatcher B already put in place — harmless, since either closure resolves a call against whichever execution is on the AsyncLocalStorage call stack, not against whichever registered it. + test("Should still dispatch correctly after a stale execution's slow-to-resolve registration re-installs the adapter following a newer execution's own registration", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + let registeredImpl: + | ((actionId: string, request: unknown) => Promise) + | undefined; + + const makeLoadModule = (actionCatalogDelayMs: number): LoadModule => { + return async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + return { example: () => 'result' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + if (actionCatalogDelayMs > 0) { + await new Promise((resolve) => + setTimeout(resolve, actionCatalogDelayMs), + ); + } + return { + setExecuteActionImplementation: ( + impl: (actionId: string, request: unknown) => Promise, + ) => { + registeredImpl = impl; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + }; + + // Times out at 20ms, well before its own 100ms-delayed action-catalog module load resolves. + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule(100), + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // The queue is free as soon as the timeout wins — the second execution registers with no artificial delay, well before A's slow load resolves. + const second = await executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + makeLoadModule(0), + mockLogger, + ); + expect(second).toEqual({ data: 'result' }); + + // Give A's slow action-catalog load room to finally resolve and re-install the adapter. + await new Promise((resolve) => setTimeout(resolve, 150)); + + // No execution is active at this point — either closure instance correctly rejects the same way. + await expect(registeredImpl?.('com.datadoghq.foo.bar', { inputs: {} })).rejects.toThrow( + /no active local execution/i, + ); + }); + + // Neither the module load nor the registration individually exceeds its own timeout budget, but their SUM crosses the outer timeout — proves the specific "abandoned ... before it could start" rejection fires (and is logged) for this cumulative-delay case, not just an individual step timing out. + test("Should log 'abandoned ... before it could start' when cumulative module-load + registration delay crosses the timeout, without either step individually exceeding it", async () => { + jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); + const loadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + // Well under the 20ms timeout on its own. + await new Promise((resolve) => setTimeout(resolve, 12)); + return { example: () => 'should never run' }; + } + if (specifier === '@datadog/action-catalog/action-execution') { + // Also well under 20ms on its own, but by now ~24ms have elapsed since scope.start(). + await new Promise((resolve) => setTimeout(resolve, 12)); + return { setExecuteActionImplementation: () => {} }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + await expect( + executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + loadModule, + mockLogger, + 20, + ), + ).rejects.toThrow(/timed out after 20ms/); + + // Give registration room to resolve and run()'s own rejection to be logged. + await new Promise((resolve) => setTimeout(resolve, 40)); + + expect(mockLogFn).toHaveBeenCalledWith( + expect.stringContaining('was abandoned after timing out before it could start'), + 'debug', + ); + }); + + // An abandoned execution's loadModule/registration steps might still resolve after timeout — proves the customer function is never invoked once already known-stale. + test('Should never invoke the customer function once already known to be abandoned before it starts', async () => { + let callCount = 0; + const slowLoadModule: LoadModule = async (specifier: string) => { + if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { + // Slower than the 20ms timeout below — by the time this resolves, the execution is already known-abandoned. + await new Promise((resolve) => setTimeout(resolve, 100)); + return { + example: () => { + callCount += 1; + return 'should never run'; + }, + }; + } + const notFoundError: NodeJS.ErrnoException = new Error( + `Cannot find module '${specifier}'`, + ); + notFoundError.code = 'MODULE_NOT_FOUND'; + throw notFoundError; + }; + + const abandoned = executeScriptLocally( + func, + TEST_PROJECT_ROOT, + [], + stubExecuteAction, + slowLoadModule, + mockLogger, + 20, + ); + await expect(abandoned).rejects.toThrow(/timed out after 20ms/); + + // Give the slow loadModule call room to actually resolve. + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(callCount).toBe(0); }); }); }); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 4e315b08e..7a8dde201 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -7,33 +7,107 @@ /** Executes a backend function's file directly in-process inside the Vite dev server, mirroring executeScriptViaDatadog's `BackendOutputs` contract in dev-server.ts as a drop-in alternate implementation. */ import type { Logger } from '@dd/core/types'; +import { AsyncLocalStorage } from 'node:async_hooks'; import { isActionCatalogInstalled, isDatadogAppsBackendInstalled } from '../backend/shared'; import type { BackendFunction, BackendOutputs } from '../backend/types'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; -interface ActionCallArgs { - inputs: Record; - connectionId?: string; +import { createEpochGuard } from './execution-epoch'; + +type BackendGlobals = { + backendFunctionArgs: unknown[]; + Actions: unknown; + Source: ReturnType; +}; + +/** Boxed so a customer module assigning to `globalThis.$` (e.g. `zx/globals`) mutates only its own execution's box, never a concurrent or zombie execution's. */ +type BackendGlobalsBox = { value: unknown }; + +/** Scopes `globalThis.$` per execution via AsyncLocalStorage so a zombie execution's late "fresh" read resolves to its own `$`, never a newer execution's identity. */ +const backendGlobalsContext = new AsyncLocalStorage(); + +/** Whether `$` was installed (e.g. by `zx/globals`) before this module's own accessor below — distinguishes that legitimate passthrough from a customer module reaching for `$` with no prior value, which should fail like production does. */ +const hadPreexistingDollar = Reflect.has(globalThis, '$'); + +/** Marks the window where a customer module's own top-level code is loading, narrower than "no execution box on the call stack" (also true between executions, where the undefined-returning fallback below is correct). Carries its own mutable box so a top-level `$` write (e.g. `zx/globals`) lands scoped to this module's own load, not the shared `globalDollarOutsideExecution` slot a later, unrelated load would also read from. */ +const customerModuleLoadContext = new AsyncLocalStorage<{ assigned: boolean; value: unknown }>(); + +/** Backs `globalThis.$` outside any execution box (e.g. this module's own import-time state); seeded from any `$` already installed before this module loaded so the accessor below doesn't discard a legitimate `zx/globals`-style passthrough. */ +let globalDollarOutsideExecution: unknown = Reflect.get(globalThis, '$'); + +function ensureDollarAccessorInstalled(): void { + if (Object.getOwnPropertyDescriptor(globalThis, '$')?.get === dollarGetter) { + return; + } + Object.defineProperty(globalThis, '$', { + configurable: true, + enumerable: true, + get: dollarGetter, + set: dollarSetter, + }); } -/** Narrows an unknown value enough to read named properties off it by key. */ -function isIndexableRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; +function dollarGetter(): unknown { + const box = backendGlobalsContext.getStore(); + if (box) { + return box.value; + } + const loadBox = customerModuleLoadContext.getStore(); + if (loadBox) { + if (loadBox.assigned) { + return loadBox.value; + } + if (hadPreexistingDollar) { + return globalDollarOutsideExecution; + } + // Matches production: $ isn't a global property at all until main() assigns it, so an + // unresolvable `$` reads as undefined rather than throwing (per typeof's spec-defined + // behavior on unresolvable references) — returning undefined here keeps that true even + // though $ is a real accessor property locally, not a genuinely absent one. + return undefined; + } + return globalDollarOutsideExecution; } -/** `globalThis.$` is a runtime-only property `typeof globalThis` doesn't know about; `Reflect.get` reads it without a type assertion, like `deleteGlobalDollar` does for deletion. */ -function getGlobalDollar(): unknown { - return Reflect.get(globalThis, '$'); +function dollarSetter(value: unknown): void { + const box = backendGlobalsContext.getStore(); + if (box) { + box.value = value; + return; + } + const loadBox = customerModuleLoadContext.getStore(); + if (loadBox) { + // Scoped to this module load, not the shared globalDollarOutsideExecution slot — otherwise a top-level write (e.g. zx/globals) would leak into every later, unrelated load. + loadBox.assigned = true; + loadBox.value = value; + return; + } + globalDollarOutsideExecution = value; } -/** `Object.assign`'s signature doesn't require its source object's keys to already exist on the target, so this installs `$` without asserting `globalThis`'s type. */ -function setGlobalDollar(value: unknown): void { - Object.assign(globalThis, { $: value }); +ensureDollarAccessorInstalled(); + +/** What the stable, once-ever-registered adapters below need to dispatch a call to whichever execution is on the AsyncLocalStorage call stack — kept out of `BackendGlobals` since that object is also `globalThis.$`, visible to customer code. */ +type ExecutionDispatch = { + executeAction: ExecuteAction; + allowedConnectionIds: string[]; + isAbandoned: () => boolean; + functionName: string; + $: BackendGlobals; +}; + +/** Distinct from `backendGlobalsContext` so dispatch-only fields (the real `executeAction`, `allowedConnectionIds`) never leak onto `globalThis.$`. */ +const executionDispatchContext = new AsyncLocalStorage(); + +interface ActionCallArgs { + inputs: Record; + connectionId?: string; } -function deleteGlobalDollar(): void { - Reflect.deleteProperty(globalThis, '$'); +/** Narrows an unknown value enough to read named properties off it by key. */ +function isIndexableRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; } const DEFAULT_TIMEOUT_MS = 10_000; @@ -83,6 +157,29 @@ function validateActionCall( return { inputs, connectionId }; } +/** Serializes local executions — a customer function deleting `globalThis.$` mid-flight would otherwise break `$` access for any other execution concurrently in progress (see `ensureDollarAccessorInstalled`). */ +let queueTail: Promise = Promise.resolve(); + +function enqueue(run: () => Promise): Promise { + const result = queueTail.then(run); + queueTail = result.then( + () => undefined, + () => undefined, + ); + return result; +} + +// Shared wording for the "no longer current" rejection at every call site that checks execution abandonment (a direct $.Actions call, the action-catalog dispatcher, and the apps-backend accessor) — a concluded scope stays concluded forever, not just "not the latest", so refusing to act under its identity applies uniformly regardless of entry point. +function abandonedExecutionError(functionName: string, refusedAction: string): Error { + return new Error( + `Execution of "${functionName}" already concluded; refusing to ${refusedAction} ` + + `as this stale execution to avoid using a newer execution's identity.`, + ); +} + +/** One shared guard across all executions — `enqueue` only serializes each execution's *start*; a timed-out `fn()` keeps running afterward (see "abandoned, not canceled" below). `isCurrent()`'s cross-scope generation comparison is what rejects that zombie's later `$.Actions` dispatch, once a newer scope has taken over. Each scope's own `concludeIfCurrent()` is a separate, narrower guard: it only clears the shared generation if THIS scope is still the one active, so a scope's delayed cleanup can never clobber a newer scope that has already superseded it. */ +const executionEpoch = createEpochGuard(); + /** Resolves a nested property path (e.g. $.Actions.slack.chat.postMessage) to a callable that invokes `executeAction` directly — no IPC needed since there's no separate process to cross. */ function makeActionsProxy( executeAction: ExecuteAction, @@ -91,8 +188,8 @@ function makeActionsProxy( ): unknown { return new Proxy(function () {}, { get(_target, prop) { - // An un-invoked reference (e.g. $.Actions.foo.bar with no call) must not look like a thenable, or Promise's resolution protocol calls .then() on it and hangs until timeout. - if (prop === 'then') { + // An un-invoked $.Actions.foo.bar reference must not be mistaken for a thenable (Promise probes .then()) or serializable (assertJsonSerializable probes .toJSON()) — either probe hitting apply() below would hang or leak a rejection instead of a clear error. + if (prop === 'then' || prop === 'toJSON') { return undefined; } const nestedPathParts = pathParts.concat(String(prop)); @@ -114,45 +211,129 @@ function makeActionsProxy( }); } -/** No-ops if @datadog/action-catalog isn't installed; checks `isActionCatalogInstalled` up front rather than catching a load failure, since `loadModule` doesn't guarantee an error code for a missing bare specifier. */ -async function registerActionCatalogIfInstalled( +/** Bounds a registration's `loadModule` call so a load that never settles (a broken/circular module graph) rejects instead of leaving its cache entry pending forever — eviction-on-rejection below only fires once a promise settles. Can't cancel the underlying promise, so a load that eventually settles still runs its side effects late; see the registration functions for why that's harmless. */ +function withTimeout(promise: Promise, timeoutMs: number, what: string): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + reject(new Error(`Loading ${what} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + promise.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (err: unknown) => { + clearTimeout(timer); + reject(err); + }, + ); + }); +} + +/** Shared once-ever-registration wrapper for both adapters below: no-ops if uninstalled (re-checked uncached on every call, so a mid-session install is picked up on the very next execution), reuses the WeakMap-cached registration keyed by `loadModule` identity (true once-ever registration for a real dev server's reused `ssrLoadModule`, isolated per closure for each test), and evicts a rejection so the next execution retries instead of staying permanently poisoned. */ +function registerOnceIfInstalled( + isInstalled: (projectRoot: string) => boolean, + registrations: WeakMap>, + registerOnce: (loadModule: LoadModule, timeoutMs: number) => Promise, loadModule: LoadModule, projectRoot: string, - executeAction: ExecuteAction, - allowedConnectionIds: string[], + timeoutMs: number, ): Promise { - if (!isActionCatalogInstalled(projectRoot)) { - return; + if (!isInstalled(projectRoot)) { + return Promise.resolve(); } - const mod = await loadModule('@datadog/action-catalog/action-execution'); + const existing = registrations.get(loadModule); + if (existing) { + return existing; + } + const registration = registerOnce(loadModule, timeoutMs).catch((err) => { + registrations.delete(loadModule); + throw err; + }); + registrations.set(loadModule, registration); + return registration; +} + +/** Keyed by `loadModule` identity — see `registerOnceIfInstalled`'s doc comment. */ +const actionCatalogRegistrations = new WeakMap>(); + +/** Registers ONE stable dispatcher for the process lifetime that reads `executionDispatchContext.getStore()` at call time, so a zombie's typed-wrapper call can never dispatch under a newer execution's identity just because that execution's registration is the one currently live. */ +function registerActionCatalogIfInstalled( + loadModule: LoadModule, + projectRoot: string, + timeoutMs: number, +): Promise { + return registerOnceIfInstalled( + isActionCatalogInstalled, + actionCatalogRegistrations, + registerActionCatalogOnce, + loadModule, + projectRoot, + timeoutMs, + ); +} + +async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: number): Promise { + const loadPromise = loadModule('@datadog/action-catalog/action-execution'); + const mod = await withTimeout( + loadPromise, + timeoutMs, + '@datadog/action-catalog/action-execution', + ); const setExecuteActionImplementation = mod.setExecuteActionImplementation; if (typeof setExecuteActionImplementation !== 'function') { return; } setExecuteActionImplementation(async (actionId: string, request: unknown) => { + const dispatch = executionDispatchContext.getStore(); + if (!dispatch) { + throw new Error(`No active local execution to run "${actionId}" under.`); + } + if (dispatch.isAbandoned()) { + throw abandonedExecutionError(dispatch.functionName, `run "${actionId}"`); + } const call: Partial = isIndexableRecord(request) ? request : {}; const { inputs, connectionId } = validateActionCall( call, - allowedConnectionIds, + dispatch.allowedConnectionIds, `"${actionId}"`, ); - return executeAction(actionId, inputs, connectionId); + return dispatch.executeAction(actionId, inputs, connectionId); }); } -/** No-ops if @datadog/apps-backend isn't installed; see `registerActionCatalogIfInstalled` for why this checks installedness up front rather than catching a load failure. */ -async function registerBackendRuntimeIfInstalled( +/** Mirrors `actionCatalogRegistrations` — see `registerOnceIfInstalled`'s doc comment. */ +const backendRuntimeRegistrations = new WeakMap>(); + +/** Registers ONE stable runtime Proxy for the process lifetime that resolves whichever execution's `$` is live on the AsyncLocalStorage call stack, rather than binding to one execution's `$` at registration time. */ +function registerBackendRuntimeIfInstalled( loadModule: LoadModule, projectRoot: string, - $: unknown, + timeoutMs: number, ): Promise { - if (!isDatadogAppsBackendInstalled(projectRoot)) { - return; - } - const [jsFunctionWithActionsModule, runtimeModule] = await Promise.all([ + return registerOnceIfInstalled( + isDatadogAppsBackendInstalled, + backendRuntimeRegistrations, + registerBackendRuntimeOnce, + loadModule, + projectRoot, + timeoutMs, + ); +} + +async function registerBackendRuntimeOnce( + loadModule: LoadModule, + timeoutMs: number, +): Promise { + const loadPromise = Promise.all([ loadModule('@datadog/apps-backend/runtime/jsFunctionWithActions'), loadModule('@datadog/apps-backend/runtime'), ]); + const [jsFunctionWithActionsModule, runtimeModule] = await withTimeout( + loadPromise, + timeoutMs, + '@datadog/apps-backend/runtime', + ); const buildRuntimeFromJsFunctionWithActions = jsFunctionWithActionsModule.buildRuntimeFromJsFunctionWithActions; const setBackend = runtimeModule.setBackend; @@ -162,11 +343,114 @@ async function registerBackendRuntimeIfInstalled( ) { return; } - const backendRuntime = buildRuntimeFromJsFunctionWithActions($); - setBackend(backendRuntime); + // Cached by dispatch identity, not rebuilt per accessor call — dispatch.$ is fixed for the whole execution. + const runtimeByDispatch = new WeakMap(); + // Forwards whatever shape the real runtime's property has (nested namespace or flat method) rather than assuming every property is callable. + const backendRuntimeProxy = new Proxy( + {}, + { + get(_target, prop) { + const dispatch = executionDispatchContext.getStore(); + if (!dispatch) { + throw new Error( + `No active local execution to resolve an apps-backend accessor under.`, + ); + } + if (dispatch.isAbandoned()) { + throw abandonedExecutionError( + dispatch.functionName, + 'resolve a further apps-backend accessor', + ); + } + let runtime = runtimeByDispatch.get(dispatch); + if (runtime === undefined) { + runtime = buildRuntimeFromJsFunctionWithActions(dispatch.$); + runtimeByDispatch.set(dispatch, runtime); + } + if (!isIndexableRecord(runtime)) { + return undefined; + } + const value = runtime[String(prop)]; + // A flat method must be bound to the real runtime object, not this Proxy's empty target; a nested namespace is returned as-is since its own methods already bind correctly. + return typeof value === 'function' ? value.bind(runtime) : value; + }, + }, + ); + setBackend(backendRuntimeProxy); +} + +/** Rejects a non-JSON-serializable result (circular reference, `BigInt`, a dropped function/`Symbol`, a `Map`/`Set` flattened to `{}`) here with a clear error, instead of failing downstream when serialized for the HTTP response. */ +// Lets the replacer's already-specific message pass through the outer catch below unwrapped, instead of being replaced by its generic fallback. +class UnsupportedJsonValueError extends Error {} + +/** `JSON.stringify`'s replacer never runs for a symbol-KEYED property (only symbol-valued ones under a string key) — it silently omits them with no callback at all, so they need their own recursive check. */ +function findSymbolKeyedObject(value: unknown, visited: Set): boolean { + if (typeof value !== 'object' || value === null || visited.has(value)) { + return false; + } + if (Object.getOwnPropertySymbols(value).length > 0) { + return true; + } + visited.add(value); + return Object.values(value).some((child) => findSymbolKeyedObject(child, visited)); +} + +function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { + if (findSymbolKeyedObject(result, new Set())) { + throw new Error( + `Local execution of "${func.name}" returned a value with a Symbol-keyed property, which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, + ); + } + let serialized: string | undefined; + try { + // A replacer visits every key/value pair including the root, so a disallowed value nested arbitrarily deep is caught the same way a top-level one is, instead of JSON.stringify silently flattening/converting/dropping it. The root is excluded from the function/Symbol/undefined check below (handled separately via `serialized === undefined`) and tracked with a one-shot flag, not `key === ''`, since a real property can itself be named `''`. + let isRootCall = true; + serialized = JSON.stringify(result, (key, value) => { + const wasRootCall = isRootCall; + isRootCall = false; + if (value instanceof Map || value instanceof Set) { + throw new UnsupportedJsonValueError( + `Local execution of "${func.name}" returned a ${value.constructor.name}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently flattens to "{}" instead of serializing its entries — return a plain array or object instead.`, + ); + } + if (typeof value === 'number' && !Number.isFinite(value)) { + throw new UnsupportedJsonValueError( + `Local execution of "${func.name}" returned ${value}${key ? ` (at "${key}")` : ''}, which JSON.stringify silently converts to "null" instead of throwing — return a finite number instead.`, + ); + } + if ( + !wasRootCall && + (typeof value === 'function' || typeof value === 'symbol' || value === undefined) + ) { + throw new UnsupportedJsonValueError( + `Local execution of "${func.name}" returned a ${typeof value} (at "${key}"), which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, + ); + } + return value; + }); + } catch (err) { + if (err instanceof UnsupportedJsonValueError) { + throw err; + } + throw new Error( + `Local execution of "${func.name}" returned a value that can't be serialized to JSON: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + if (serialized === undefined) { + if (result !== undefined) { + throw new Error( + `Local execution of "${func.name}" returned a ${typeof result} value, which JSON.stringify silently drops instead of serializing — return a plain JSON-compatible value instead.`, + ); + } + return undefined; + } + // Return the parsed-and-reserialized value, not the original — the caller serializes again for the HTTP response, and the original would invoke a custom toJSON() a second time. + return JSON.parse(serialized); } -/** `globalThis.$` and the registrations above provide the same customer-visible bindings production's generated wrapper module sets up via text injection. */ +/** `globalThis.$` and the action-catalog/apps-backend registrations above provide the same customer-visible bindings production's generated wrapper module sets up via text injection; serialized via `enqueue`. */ export async function executeScriptLocally( func: BackendFunction, projectRoot: string, @@ -175,56 +459,107 @@ export async function executeScriptLocally( loadModule: LoadModule, log: Logger, timeoutMs: number = DEFAULT_TIMEOUT_MS, +): Promise { + return enqueue(() => + runScriptLocally(func, projectRoot, args, executeAction, loadModule, log, timeoutMs), + ); +} + +async function runScriptLocally( + func: BackendFunction, + projectRoot: string, + args: unknown[], + executeAction: ExecuteAction, + loadModule: LoadModule, + log: Logger, + timeoutMs: number, ): Promise { // Never log the args themselves — they may carry secrets/PII, matching dev-server.ts's cloud path. log.debug(`Executing "${func.name}" in-process with args`); + // A timed-out execution is abandoned, not canceled — its fn() may keep running and must not act under a newer execution's identity. isCurrent() gates both this execution's own captured `$.Actions` closure and the shared adapters, which resolve the calling execution's dispatch from AsyncLocalStorage rather than whichever registration is currently live. + const scope = executionEpoch.start(); + + const guardedExecuteAction: ExecuteAction = (fqn, inputs, connectionId) => { + if (!scope.isCurrent()) { + // A concluded scope stays concluded forever, not just "not the latest" — the wording stays conclusion-neutral rather than claiming a timeout that may not have happened. + return Promise.reject(abandonedExecutionError(func.name, `run "${fqn}"`)); + } + return executeAction(fqn, inputs, connectionId); + }; + + const concludeExecution = () => { + scope.concludeIfCurrent(); + }; + const $ = { backendFunctionArgs: args, - Actions: makeActionsProxy(executeAction, func.allowedConnectionIds), + Actions: makeActionsProxy(guardedExecuteAction, func.allowedConnectionIds), Source: makeLocalDevSource(), }; - const run = async (): Promise => { - // Loads the customer module before installing $ and the SDK bridges, matching production's import order (backend/virtual-entry.ts) — code reaching for $ during top-level evaluation fails the same way locally as in Datadog, instead of succeeding early. - const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX); - const fn = mod[func.name]; - if (typeof fn !== 'function') { - throw new Error(`"${func.name}" is not a function exported from ${func.absolutePath}`); - } + const dispatch: ExecutionDispatch = { + executeAction: guardedExecuteAction, + allowedConnectionIds: func.allowedConnectionIds, + isAbandoned: () => !scope.isCurrent(), + functionName: func.name, + $, + }; - // Restores whatever globalThis.$ held before this call (or removes it) once the execution settles, so a pre-existing global (e.g. zx/globals) isn't clobbered and this execution's context isn't left reachable afterward. - const hadPreviousDollar = Object.prototype.hasOwnProperty.call(globalThis, '$'); - const previousDollar = getGlobalDollar(); - setGlobalDollar($); + const run = async (): Promise => { + // Wraps the whole body, not just the customer-function call below, so a failure while loading/resolving the module (e.g. loadModule rejecting, or the export not being a function) also concludes the scope — otherwise the epoch guard's cross-scope supersession never runs for this execution, leaving activeGeneration pinned to it until the next start() overwrites it. try { - const actionCatalogRegistration = registerActionCatalogIfInstalled( - loadModule, - projectRoot, - executeAction, - func.allowedConnectionIds, - ); - const backendRuntimeRegistration = registerBackendRuntimeIfInstalled( - loadModule, - projectRoot, - $, + // Loads and evaluates the customer's module BEFORE installing $ and the SDK bridges below, matching production's own ordering (backend/virtual-entry.ts statically imports the customer module before its wrapper installs $ and the SDK bridges) — code that reaches for $ or a typed action during its own top-level evaluation fails the same way locally as it would in Datadog, instead of silently succeeding against bindings production wouldn't have installed yet. + const mod = await customerModuleLoadContext.run( + { assigned: false, value: undefined }, + () => loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX), ); - await Promise.all([actionCatalogRegistration, backendRuntimeRegistration]); + const fn = mod[func.name]; + if (typeof fn !== 'function') { + throw new Error( + `"${func.name}" is not a function exported from ${func.absolutePath}`, + ); + } + + // Reinstalls the accessor if a prior execution's customer code deleted globalThis.$, so this execution's box stays reachable. Only closes the gap between executions — a deletion made mid-flight by a still-running concurrent execution can't be recovered, since there's no way to intercept access on a since-deleted global property; that narrower case is accepted as-is. + ensureDollarAccessorInstalled(); - const result = await fn(...args); - return { data: result }; + // Scopes globalThis.$ and the dispatch info to this call's own async continuation chain. + return await backendGlobalsContext.run({ value: $ }, () => + executionDispatchContext.run(dispatch, async () => { + // Both adapters are stable and idempotent to re-register, so no coordination is needed between them or across executions. + const actionCatalogRegistration = registerActionCatalogIfInstalled( + loadModule, + projectRoot, + timeoutMs, + ); + const backendRuntimeRegistration = registerBackendRuntimeIfInstalled( + loadModule, + projectRoot, + timeoutMs, + ); + await Promise.all([actionCatalogRegistration, backendRuntimeRegistration]); + + if (!scope.isCurrent()) { + // Already known-abandoned before the customer function was reached — no point invoking it now. + throw new Error( + `Execution of "${func.name}" was abandoned after timing out before it could start.`, + ); + } + const result = await fn(...args); + return { data: assertJsonSerializable(result, func) }; + }), + ); } finally { - if (hadPreviousDollar) { - setGlobalDollar(previousDollar); - } else { - deleteGlobalDollar(); - } + // However this execution ends, mark it concluded so any further dispatch through it — direct or via the shared adapters — is rejected. + concludeExecution(); } }; let timer: ReturnType | undefined; const timeout = new Promise((_resolve, reject) => { timer = setTimeout(() => { + concludeExecution(); reject(new Error(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`)); }, timeoutMs); });