From 8796b070ca779fd211d839d005a1743faa73fb62 Mon Sep 17 00:00:00 2001 From: Tiffany Trinh Date: Wed, 21 Oct 2026 11:11:36 -0400 Subject: [PATCH] feat(apps): run each local backend function call in a fresh process /__dd/executeAction forks a Node process per call, running the project's Vite module runner, which loads modules from the dev server over IPC. Each call starts with fresh module state. Calls still run one at a time. Each process runs under the guards, which refuse writes outside its temp dir and /tmp, subprocesses and worker threads. Its heap is capped at 256 MB and its environment holds no developer variables or keys. $.Actions calls go back to the dev server, which keeps the credentials and checks each call's connection. Output streams to the terminal line by line. A call past its time budget is killed, even in a synchronous loop, and the dev server kills its calls when it closes, restarts or exits. The in-process executor and its fallbacks are gone. A Yarn Plug'n'Play dev server, whose packages load through hooks a call's process doesn't get, fails each call with a hint to use a node_modules install or dev:verify. Co-Authored-By: Claude Opus 5.5 --- packages/plugins/apps/README.md | 14 + packages/plugins/apps/package.json | 6 + packages/plugins/apps/src/backend/types.ts | 2 +- ...kend-function-packages.integration.test.ts | 20 + .../src/vite/dev-server-module-graph.test.ts | 332 -- .../apps/src/vite/dev-server-module-graph.ts | 80 +- .../src/vite/dev-server.integration.test.ts | 388 +- .../plugins/apps/src/vite/dev-server.test.ts | 415 +-- packages/plugins/apps/src/vite/dev-server.ts | 33 +- .../apps/src/vite/execution-epoch.test.ts | 55 - .../plugins/apps/src/vite/execution-epoch.ts | 38 - packages/plugins/apps/src/vite/index.test.ts | 119 +- packages/plugins/apps/src/vite/index.ts | 65 +- .../src/vite/local-execution-child-path.ts | 10 + .../apps/src/vite/local-execution-child.ts | 228 ++ .../vite/local-execution-process.fixtures.ts | 51 + ...ocal-execution-process.integration.test.ts | 1324 +++++++ .../src/vite/local-execution-process.test.ts | 156 + .../apps/src/vite/local-execution-process.ts | 584 +++ .../apps/src/vite/local-execution-protocol.ts | 58 + .../apps/src/vite/local-execution-shared.ts | 11 +- .../apps/src/vite/local-execution.fixtures.ts | 35 - .../local-execution.process-exit.fixture.ts | 46 - .../vite/local-execution.resilience.test.ts | 71 - .../apps/src/vite/local-execution.test.ts | 3251 ----------------- .../plugins/apps/src/vite/local-execution.ts | 615 +--- .../apps/src/vite/network-guard.test.ts | 71 - .../plugins/apps/src/vite/network-guard.ts | 45 +- .../childActions.backend.ts | 62 + .../childLoadTime.backend.ts | 21 + .../childReturnValues.backend.ts | 70 + .../childSandbox.backend.ts | 13 + .../childSandboxProbes.ts | 75 + .../childSlowLoad.backend.ts | 10 + .../childState.backend.ts | 67 + .../childTopLevelWrite.backend.ts | 9 + .../childTopLevelWriteHelper.ts | 12 + .../childVirtual.backend.ts | 11 + packages/tests/src/_jest/helpers/mocks.ts | 24 - .../src/_jest/processEnvLintRule.test.ts | 4 +- 40 files changed, 3199 insertions(+), 5302 deletions(-) delete mode 100644 packages/plugins/apps/src/vite/execution-epoch.test.ts delete mode 100644 packages/plugins/apps/src/vite/execution-epoch.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-child-path.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-child.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-process.fixtures.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-process.integration.test.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-process.test.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-process.ts create mode 100644 packages/plugins/apps/src/vite/local-execution-protocol.ts delete mode 100644 packages/plugins/apps/src/vite/local-execution.fixtures.ts delete mode 100644 packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts delete mode 100644 packages/plugins/apps/src/vite/local-execution.resilience.test.ts delete mode 100644 packages/plugins/apps/src/vite/local-execution.test.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childActions.backend.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childLoadTime.backend.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childReturnValues.backend.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childSandboxProbes.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childSlowLoad.backend.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childState.backend.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWrite.backend.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWriteHelper.ts create mode 100644 packages/tests/src/_jest/fixtures/apps_backend_project/childVirtual.backend.ts diff --git a/packages/plugins/apps/README.md b/packages/plugins/apps/README.md index a5a234b8c..1afbd0de6 100644 --- a/packages/plugins/apps/README.md +++ b/packages/plugins/apps/README.md @@ -16,6 +16,7 @@ It needs Vite 6.4 or later: on an older Vite, it stops every Vite command with a - [Development server authentication](#development-server-authentication) - [Development server requests](#development-server-requests) - [Cloud execution](#cloud-execution) +- [Local execution](#local-execution) - [Package output](#package-output) - [apps.enable](#appsenable) - [apps.include](#appsinclude) @@ -94,6 +95,19 @@ Cloud execution (`dev:verify`, `/__dd/executeActionViaCloud`) bundles a backend When Datadog rejects a bundle as longer than the `jsFunctionWithActions` `script` input accepts, the dev server answers with a 413 naming the function, its size and Datadog's answer, and warns once per function until the dev server restarts. +## Local execution + +`/__dd/executeAction` runs each backend function call the way production does: in a fresh Node process of its own. Calls run one at a time. + +- **Fresh state**: module-level variables and globals start over on every call. +- **Timeout**: a call that runs past its time budget is killed, even inside a synchronous loop. +- **Guards**: guards in the call's process refuse writes outside the call's own temp dir (`os.tmpdir()`, also its working directory) and `/tmp`, subprocesses and worker threads, from the module's top-level code on. The heap is capped at 256 MB. +- **Environment**: a call sees no variables from the dev server's environment, keys included, except Node's TLS and time zone settings (`NODE_EXTRA_CA_CERTS`, `SSL_CERT_FILE`, `SSL_CERT_DIR`, `TZ`), plus `SystemRoot` on Windows, and no Node options from `NODE_OPTIONS`. Vite still inlines `import.meta.env` and `define` values into the code it serves. `$.Actions` calls go back to the dev server, which holds the credentials and checks each call's connection against the function's allowed connections. +- **Logs**: a call's `stdout` and `stderr` reach the dev server's terminal line by line, as each line ends. +- **Debugging**: when the dev server runs under a debugger (`--inspect`), each call's process listens on a random local port and prints its `Debugger listening on ws://…` URL. Attach to that URL to debug the call; calls don't time out while the dev server is under a debugger. + +Local execution doesn't support Yarn Plug'n'Play, whose packages load through hooks a call's process doesn't get: each call fails with a hint to set `nodeLinker: node-modules` or use `dev:verify`. + ## Package output A production `vite build` writes `datadog-app-assets.zip` beside the Vite output. The ZIP contains `frontend/`, `backend/`, and `manifest.json`. The app's identity is resolved by `@datadog/apps-cli` at deploy time. diff --git a/packages/plugins/apps/package.json b/packages/plugins/apps/package.json index 8ac737202..43447ae89 100644 --- a/packages/plugins/apps/package.json +++ b/packages/plugins/apps/package.json @@ -25,6 +25,12 @@ "format": [ "esm" ] + }, + "local-execution-child": { + "entry": "./src/vite/local-execution-child.ts", + "format": [ + "esm" + ] } } }, diff --git a/packages/plugins/apps/src/backend/types.ts b/packages/plugins/apps/src/backend/types.ts index 2022e8faa..94ef1c6ac 100644 --- a/packages/plugins/apps/src/backend/types.ts +++ b/packages/plugins/apps/src/backend/types.ts @@ -13,5 +13,5 @@ export interface BackendFunction { allowedConnectionIds: string[]; } -/** Shape of a backend function's result, shared by the remote (dev-server.ts) and in-process (local-execution.ts) paths — mirrors the app-builder query response's `{ data: }` wrapper. */ +/** Shape of a backend function's result, shared by the remote (dev-server.ts) and local (local-execution-process.ts) paths — mirrors the app-builder query response's `{ data: }` wrapper. */ export type BackendOutputs = { data: unknown }; diff --git a/packages/plugins/apps/src/vite/backend-function-packages.integration.test.ts b/packages/plugins/apps/src/vite/backend-function-packages.integration.test.ts index a947c3805..ce152e051 100644 --- a/packages/plugins/apps/src/vite/backend-function-packages.integration.test.ts +++ b/packages/plugins/apps/src/vite/backend-function-packages.integration.test.ts @@ -23,12 +23,30 @@ import { build, createServer, type ViteDevServer } from 'vite'; import { ARCHIVE_FILENAME } from '../constants'; import type { AppsManifest } from '../types'; +import { + buildLocalExecutionChild, + removeLocalExecutionChild, +} from './local-execution-process.fixtures'; + // Jest compiles loadViteParseAst's dynamic import into a `require`, which gets Vite's CJS // entry and no `parseAst`; the published build keeps the real import. jest.mock('@dd/apps-plugin/vite/vite-parse-ast', () => ({ loadViteParseAst: async () => parseAst, })); +let mockChildEntryPath = ''; +jest.mock('@dd/apps-plugin/vite/local-execution-child-path', () => ({ + getLocalExecutionChildPath: () => mockChildEntryPath, +})); + +beforeAll(async () => { + mockChildEntryPath = await buildLocalExecutionChild(); +}); + +afterAll(() => { + removeLocalExecutionChild(); +}); + const FIXTURES_DIR = path.resolve(__dirname, '../../../../tests/src/_jest/fixtures'); const PROJECT_DIR = path.join(FIXTURES_DIR, 'apps_backend_library_project'); const ACTION_CATALOG_DIR = path.join(FIXTURES_DIR, 'action_catalog_project'); @@ -71,6 +89,8 @@ async function assembleApp( await fsp.mkdir(path.dirname(linkPath), { recursive: true }); await fsp.symlink(checkout, linkPath, 'dir'); }; + // A call's process loads modules through the project's own Vite module runner. + await link(path.dirname(require.resolve('vite/package.json')), 'vite'); const linkedPlainCheckout = path.join(tempDir, 'linked-plain-lib'); await copy(path.join(PROJECT_DIR, 'packages/linked-plain-lib'), linkedPlainCheckout); await link(linkedPlainCheckout, 'linked-plain-backend-lib'); diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts index 81c92feef..956b4b3cb 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.test.ts @@ -46,342 +46,10 @@ function makeFakeServer( // its `importedModules`; these fixtures pre-wire the full graph instead, so this is a // no-op stand-in for that priming call. transformRequest: async () => null, - environments: { - ssr: { moduleGraph: { idToModuleMap: new Map(), getModuleById: () => undefined } }, - }, } as unknown as ViteDevServer; } -interface FakeEnvironmentNode { - id: string; - transformResult: unknown; - lastInvalidationTimestamp: number; - lastHMRTimestamp: number; -} - -const fakeEnvironmentNode = ( - id: string, - state: 'edited' | 'hmr-updated' | 'current' | 'never-loaded', -): FakeEnvironmentNode => ({ - id, - transformResult: state === 'current' ? { code: '' } : null, - lastInvalidationTimestamp: state === 'edited' ? 1 : 0, - lastHMRTimestamp: state === 'hmr-updated' ? 1 : 0, -}); - -// An SSR environment graph where priming refills a node's transformResult. -function withSsrEnvironmentGraph( - server: ViteDevServer, - nodes: FakeEnvironmentNode[], - onPrime: (id: string) => void = () => {}, -) { - const entries = nodes.map((node): [string, FakeEnvironmentNode] => [node.id, node]); - const idToModuleMap = new Map(entries); - const updateModuleTransformResult = jest.fn((node: FakeEnvironmentNode, result: unknown) => { - node.transformResult = result; - }); - const moduleGraph = { - idToModuleMap, - getModuleById: (id: string) => idToModuleMap.get(id), - updateModuleTransformResult, - }; - Object.assign(server, { - environments: { ssr: { moduleGraph } }, - transformRequest: async (id: string) => { - onPrime(id); - const node = idToModuleMap.get(id); - if (node) { - node.transformResult = { code: '' }; - } - return null; - }, - }); - return { updateModuleTransformResult }; -} - describe('dev-server-module-graph — collectModuleGraphFromServer', () => { - describe('un-caching stale modules the walk primed', () => { - const DEPENDENCY_ID = path.join(FIXTURE_ROOT, 'getRuntimeUsers.backend.ts'); - const OUTSIDE_ROOT_ID = path.join(FIXTURE_ROOT, '../shared/util.ts'); - const SDK_ID = path.join(FIXTURE_ROOT, 'node_modules/@datadog/apps-backend/index.js'); - const resolveToDependency = async () => ({ id: DEPENDENCY_ID }); - const collect = (server: ViteDevServer) => { - const log = getMockLogger(); - return collectModuleGraphFromServer(server, ENTRY_ID, FIXTURE_ROOT, log, parseAst); - }; - const entryImporting = (...dependencyIds: string[]): FakeModuleNode => { - const dependencies = dependencyIds.map((id) => ({ - id, - file: id, - importedModules: new Set(), - })); - return { - id: SUFFIXED_ENTRY_ID, - file: ENTRY_ID, - importedModules: new Set(dependencies), - }; - }; - - test('Should un-cache an edited entry the walk primed', async () => { - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [entry]); - - await collect(server); - - expect(updateModuleTransformResult).toHaveBeenCalledWith(entry, null); - expect(entry.transformResult).toBeNull(); - }); - - test('Should un-cache an edited dependency and the entry Vite invalidated with it', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'edited'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'edited'); - withSsrEnvironmentGraph(server, [entry, dependency]); - - await collect(server); - - expect(dependency.transformResult).toBeNull(); - expect(entry.transformResult).toBeNull(); - }); - - test('Should un-cache a module Vite invalidated through HMR', async () => { - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'hmr-updated'); - withSsrEnvironmentGraph(server, [entry]); - - await collect(server); - - expect(entry.transformResult).toBeNull(); - }); - - test('Should un-cache an edited dependency outside the build root, e.g. a workspace package', async () => { - const entryNode = entryImporting(OUTSIDE_ROOT_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const outsideRoot = fakeEnvironmentNode(OUTSIDE_ROOT_ID, 'edited'); - withSsrEnvironmentGraph(server, [entry, outsideRoot]); - - await collect(server); - - expect(outsideRoot.transformResult).toBeNull(); - }); - - test('Should un-cache edited app modules when the app itself lives under node_modules', async () => { - const nestedRoot = path.join(FIXTURE_ROOT, 'node_modules/nested-app'); - const nestedEntryId = path.join(nestedRoot, 'helper.ts'); - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode( - nestedEntryId + LOCAL_EXECUTION_LOAD_SUFFIX, - 'edited', - ); - withSsrEnvironmentGraph(server, [entry]); - const log = getMockLogger(); - - await collectModuleGraphFromServer(server, nestedEntryId, nestedRoot, log, parseAst); - - expect(entry.transformResult).toBeNull(); - }); - - // A directory named like `..gen` is still inside the root, not a parent-directory path. - test('Should un-cache an edited module in a `..`-prefixed directory of an app under node_modules', async () => { - const nestedRoot = path.join(FIXTURE_ROOT, 'node_modules/nested-app'); - const dotPrefixedEntryId = path.join(nestedRoot, '..gen/helper.ts'); - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode( - dotPrefixedEntryId + LOCAL_EXECUTION_LOAD_SUFFIX, - 'edited', - ); - withSsrEnvironmentGraph(server, [entry]); - const log = getMockLogger(); - - await collectModuleGraphFromServer( - server, - dotPrefixedEntryId, - nestedRoot, - log, - parseAst, - ); - - expect(entry.transformResult).toBeNull(); - }); - - test('Should leave a hoisted sibling package cached when the app itself lives under node_modules', async () => { - const nestedRoot = path.join(FIXTURE_ROOT, 'node_modules/nested-app'); - const nestedEntryId = path.join(nestedRoot, 'helper.ts'); - const hoistedSdkId = path.join( - FIXTURE_ROOT, - 'node_modules/@datadog/apps-backend/index.js', - ); - const nestedSuffixedEntryId = nestedEntryId + LOCAL_EXECUTION_LOAD_SUFFIX; - // Reads the real fixture source, so the walk reaches the hoisted import. - const nestedEntryNode: FakeModuleNode = { - id: nestedSuffixedEntryId, - file: ENTRY_ID, - importedModules: new Set([ - { id: hoistedSdkId, file: hoistedSdkId, importedModules: new Set() }, - ]), - }; - const server = makeFakeServer(resolveToDependency, nestedEntryNode); - Object.assign(server.moduleGraph, { - getModuleById: (id: string) => - id === nestedSuffixedEntryId ? nestedEntryNode : undefined, - }); - const entry = fakeEnvironmentNode(nestedSuffixedEntryId, 'current'); - const hoistedSdk = fakeEnvironmentNode(hoistedSdkId, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - hoistedSdk, - ]); - const log = getMockLogger(); - - await collectModuleGraphFromServer(server, nestedEntryId, nestedRoot, log, parseAst); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - }); - - test('Should un-cache a dependency saved while the walk is running', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'current'); - const saveDependencyWhenEntryPrimes = (id: string) => { - if (id === SUFFIXED_ENTRY_ID) { - dependency.transformResult = null; - dependency.lastInvalidationTimestamp += 1; - } - }; - withSsrEnvironmentGraph(server, [entry, dependency], saveDependencyWhenEntryPrimes); - - await collect(server); - - expect(dependency.transformResult).toBeNull(); - }); - - test("Should un-cache an edited dependency that another plugin's transform refilled before the walk primed it", async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'edited'); - // Like a transform hook on the entry that pre-transforms its own imports. - const transformImportsWhenEntryPrimes = (id: string) => { - if (id === SUFFIXED_ENTRY_ID) { - dependency.transformResult = { code: '' }; - } - }; - withSsrEnvironmentGraph(server, [entry, dependency], transformImportsWhenEntryPrimes); - - await collect(server); - - expect(dependency.transformResult).toBeNull(); - }); - - // Arises when an untaken dynamic import's target is edited and the importer runs again. - test('Should un-cache an edited dependency without re-running an importer that is current', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - dependency, - ]); - - await collect(server); - - expect(dependency.transformResult).toBeNull(); - expect(entry.transformResult).not.toBeNull(); - expect(updateModuleTransformResult).not.toHaveBeenCalledWith(entry, null); - }); - - // Vite invalidated it, so the runner would re-run it without the walk, and it isn't an SDK. - test('Should un-cache an invalidated virtual module the walk primed', async () => { - const virtualModuleId = '\0virtual:dd-generated-config'; - const entryNode: FakeModuleNode = { - id: SUFFIXED_ENTRY_ID, - file: ENTRY_ID, - importedModules: new Set([{ id: virtualModuleId, importedModules: new Set() }]), - }; - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const virtualModule = fakeEnvironmentNode(virtualModuleId, 'edited'); - withSsrEnvironmentGraph(server, [entry, virtualModule]); - - await collect(server); - - expect(virtualModule.transformResult).toBeNull(); - }); - - test('Should leave a stale module the walk never primed alone', async () => { - const server = makeFakeServer(resolveToDependency); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const unrelated = fakeEnvironmentNode(OUTSIDE_ROOT_ID, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - unrelated, - ]); - - await collect(server); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - }); - - test('Should leave modules that were already current cached', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const dependency = fakeEnvironmentNode(DEPENDENCY_ID, 'current'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - dependency, - ]); - - await collect(server); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - }); - - test('Should leave a module Vite never invalidated cached, e.g. an untaken dynamic import', async () => { - const entryNode = entryImporting(DEPENDENCY_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const neverLoaded = fakeEnvironmentNode(DEPENDENCY_ID, 'never-loaded'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [ - entry, - neverLoaded, - ]); - - await collect(server); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - }); - - test('Should leave an invalidated package dependency cached so an SDK keeps its setup', async () => { - const entryNode = entryImporting(SDK_ID); - const server = makeFakeServer(resolveToDependency, entryNode); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'current'); - const sdk = fakeEnvironmentNode(SDK_ID, 'edited'); - const { updateModuleTransformResult } = withSsrEnvironmentGraph(server, [entry, sdk]); - - await collect(server); - - expect(updateModuleTransformResult).not.toHaveBeenCalled(); - expect(sdk.transformResult).not.toBeNull(); - }); - - test('Should still un-cache an edited module when the walk fails partway', async () => { - const server = makeFakeServer(async () => null); - const entry = fakeEnvironmentNode(SUFFIXED_ENTRY_ID, 'edited'); - withSsrEnvironmentGraph(server, [entry]); - - const collecting = collect(server); - - await expect(collecting).rejects.toThrow(/unresolvable import specifier/); - expect(entry.transformResult).toBeNull(); - }); - }); - test('Should fail closed, not fall back to the raw specifier, when resolveId fails to resolve a static import', async () => { const server = makeFakeServer(async () => null); diff --git a/packages/plugins/apps/src/vite/dev-server-module-graph.ts b/packages/plugins/apps/src/vite/dev-server-module-graph.ts index bf2b969bf..bc8b024b4 100644 --- a/packages/plugins/apps/src/vite/dev-server-module-graph.ts +++ b/packages/plugins/apps/src/vite/dev-server-module-graph.ts @@ -7,8 +7,7 @@ import { readFile } from '@dd/core/helpers/fs'; import type { Logger } from '@dd/core/types'; import { transform } from 'esbuild'; -import path from 'path'; -import type { EnvironmentModuleNode, ModuleNode, parseAst, ViteDevServer } from 'vite'; +import type { ModuleNode, parseAst, ViteDevServer } from 'vite'; import { createParsedModuleRecord, @@ -17,12 +16,7 @@ import { unsupportedModuleGraphDependency, } from '../backend/ast-parsing/module-graph'; import { runBackendStaticChecks } from '../backend/ast-parsing/run-backend-static-checks'; -import { - getBackendModuleOwner, - isBackendSourceModule, - isOutsideRoot, - isPackageManagerModule, -} from '../backend/backend-sources'; +import { isBackendSourceModule } from '../backend/backend-sources'; import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; import { normalizeViteModuleId } from './backend-module-graph-collector'; @@ -30,8 +24,8 @@ import { normalizeViteModuleId } from './backend-module-graph-collector'; /** * Rebuilds `createBackendModuleGraphCollector`'s `ParsedModuleRecord` map for the dev server * (no `moduleParsed` hook here), re-parsing each module from disk via `esbuild.transform`. - * Primes each node via `transformRequest` (resolve + transform, never executes) before running - * static checks, so a module can't dodge them by having `ssrLoadModule` already run its code. + * Primes each node via `transformRequest` (resolve + transform, never executes), so its imports + * are known and checked before a call's process runs any of its code. */ export async function collectModuleGraphFromServer( server: ViteDevServer, @@ -41,72 +35,10 @@ export async function collectModuleGraphFromServer( parse: typeof parseAst, ): Promise> { const records = new Map(); - const staleAppModules = trackStaleAppModules(server, buildRoot); - try { - await walkModuleGraph( - server, - bareEntryId, - buildRoot, - log, - parse, - records, - staleAppModules.record, - ); - } finally { - staleAppModules.uncache(); - } + await walkModuleGraph(server, bareEntryId, buildRoot, log, parse, records); return records; } -// Priming refills an edited module's transformResult, which Vite's SSR runner reads as "cached -// evaluation still current", so each stale module the walk primes is un-cached again after it. -function trackStaleAppModules(server: ViteDevServer, buildRoot: string) { - const ssrGraph = server.environments.ssr.moduleGraph; - const isStale = (node: EnvironmentModuleNode) => { - // Untransformed and never invalidated means never run, so there's no stale evaluation. - const wasInvalidated = node.lastInvalidationTimestamp || node.lastHMRTimestamp; - if (!node.id || node.transformResult || !wasInvalidated) { - return false; - } - // Package dependencies stay cached: re-running an SDK would drop its once-per-server setup. - // Relative to the root so an app under node_modules still counts; outside it, the full path - // decides, so a hoisted sibling package isn't mistaken for app code. - const moduleId = normalizeDevServerModuleId(node.id); - const relativePath = path.relative(buildRoot, moduleId); - // Vite ids always use forward slashes; path.normalize gives the platform separator. - const pathToClassify = isOutsideRoot(relativePath) - ? path.normalize(moduleId) - : relativePath; - // A package that provides backend functions is backend code like the app's, so its edited - // modules re-run too, even when it's installed under node_modules. - return ( - !isPackageManagerModule(pathToClassify) || - getBackendModuleOwner(moduleId, buildRoot).kind === 'backend-package' - ); - }; - // Scanned upfront too: another plugin's transform hook can pre-transform its imports and - // refill a stale module before the walk reaches it. - const staleBeforeWalk = new Set(); - ssrGraph.idToModuleMap.forEach((node) => { - if (isStale(node)) { - staleBeforeWalk.add(node); - } - }); - const primedStaleModules = new Set(); - return { - // Checked again right before priming, to catch a file saved mid-walk. - record: (id: string) => { - const node = ssrGraph.getModuleById(id); - if (node && (staleBeforeWalk.has(node) || isStale(node))) { - primedStaleModules.add(node); - } - }, - // Reverts only the priming: invalidateModule would also re-run importers that are current. - uncache: () => - primedStaleModules.forEach((node) => ssrGraph.updateModuleTransformResult(node, null)), - }; -} - async function walkModuleGraph( server: ViteDevServer, bareEntryId: string, @@ -114,12 +46,10 @@ async function walkModuleGraph( log: Logger, parse: typeof parseAst, records: Map, - beforePrime: (id: string) => void, ): Promise { const visited = new Set(); const pending: ModuleNode[] = []; const prime = async (id: string) => { - beforePrime(id); await server.transformRequest(id, { ssr: true }); }; diff --git a/packages/plugins/apps/src/vite/dev-server.integration.test.ts b/packages/plugins/apps/src/vite/dev-server.integration.test.ts index b2a8a8d88..ea8e57d47 100644 --- a/packages/plugins/apps/src/vite/dev-server.integration.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.integration.test.ts @@ -19,7 +19,6 @@ import { EXECUTE_ACTION_ROUTE, } from '@dd/apps-plugin/vite/dev-server'; import { DEV_CLIENT_SRC, getVitePlugin, SSR_WARMUP_SETTING } from '@dd/apps-plugin/vite/index'; -import { FS_WRITE_BLOCKED_MESSAGE } from '@dd/apps-plugin/vite/network-guard'; import * as fsHelpers from '@dd/core/helpers/fs'; import { outputFileSync, readFileSync, rmSync } from '@dd/core/helpers/fs'; import type { AuthOptionsWithDefaults } from '@dd/core/types'; @@ -30,10 +29,10 @@ import { getContextMock, getMockLogger, } from '@dd/tests/_jest/helpers/mocks'; +import { execFileSync } from 'child_process'; import fs from 'fs'; import http from 'http'; import nock from 'nock'; -import os from 'os'; import path from 'path'; import { parseAst } from 'rollup/parseAst'; import { @@ -50,7 +49,11 @@ import { DEBUG_BUNDLE_PATH } from '../backend/protocol'; import type { BackendFunction } from '../backend/types'; import { DEV_VERIFY_MODE } from '../constants'; -import { makeProbeDirOutsideTmp } from './network-guard.fixtures'; +import { + buildLocalExecutionChild, + removeLocalExecutionChild, +} from './local-execution-process.fixtures'; +import { createChildProcessExecutor, resolveViteModuleRunner } from './local-execution-process'; // Jest compiles loadViteParseAst's dynamic import into a `require`, which gets Vite's CJS // entry and no `parseAst`; the published build keeps the real import. @@ -58,6 +61,19 @@ jest.mock('@dd/apps-plugin/vite/vite-parse-ast', () => ({ loadViteParseAst: async () => parseAst, })); +let mockChildEntryPath = ''; +jest.mock('@dd/apps-plugin/vite/local-execution-child-path', () => ({ + getLocalExecutionChildPath: () => mockChildEntryPath, +})); + +beforeAll(async () => { + mockChildEntryPath = await buildLocalExecutionChild(); +}); + +afterAll(() => { + removeLocalExecutionChild(); +}); + const FIXTURE_ROOT = path.resolve( __dirname, '../../../../tests/src/_jest/fixtures/apps_backend_project', @@ -159,6 +175,18 @@ function mockRuntimeContextHydration() { }); } +const makeChildExecutor = (devServer: ViteDevServer) => { + const moduleRunnerPath = resolveViteModuleRunner(FIXTURE_ROOT); + return createChildProcessExecutor({ + invokeChannel: devServer.environments.ssr.hot, + projectRoot: FIXTURE_ROOT, + childEntryPath: mockChildEntryPath, + moduleRunnerPath, + log: getMockLogger(), + writeOutput: () => {}, + }); +}; + describe('Dev Server Middleware — real end-to-end local execution', () => { let server: ViteDevServer; @@ -209,7 +237,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - server.ssrLoadModule.bind(server), () => [getRuntimeUsersFunc], async () => [], auth, @@ -220,6 +247,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const req = createMockRequest('/__dd/executeAction', { @@ -255,7 +283,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - server.ssrLoadModule.bind(server), () => [nestedImportFunc], async () => [], auth, @@ -266,6 +293,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const req = createMockRequest('/__dd/executeAction', { @@ -295,7 +323,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - server.ssrLoadModule.bind(server), () => [viaHelperFunc], async () => [], auth, @@ -306,6 +333,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const req = createMockRequest('/__dd/executeAction', { @@ -347,11 +375,45 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { expect(body.result).toEqual({ data: { value: 'real-middleware-value' } }); }, 30000); + test('Should run each call through the real configureServer-installed middleware in its own fresh child process', async () => { + const countCallsFunc: BackendFunction = { + relativePath: 'childState', + name: 'countCalls', + absolutePath: path.join(FIXTURE_ROOT, 'childState.backend.ts'), + allowedConnectionIds: [], + }; + await server.ssrLoadModule(countCallsFunc.absolutePath); + + const results = []; + for (let i = 0; i < 2; i++) { + // beforeEach mocks the first call's hydration. + if (i > 0) { + mockRuntimeContextHydration(); + } + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(countCallsFunc), + args: [0], + }); + const res = createMockResponse(); + server.middlewares(req, res, jest.fn()); + // eslint-disable-next-line no-await-in-loop + await res.done; + expect(res.statusCode).toBe(200); + results.push(JSON.parse(res.getBody()).result.data); + } + + expect(results).toEqual([ + expect.objectContaining({ calls: 1, globalCalls: 1 }), + expect.objectContaining({ calls: 1, globalCalls: 1 }), + ]); + expect(results[0].pid).not.toBe(process.pid); + expect(results[0].pid).not.toBe(results[1].pid); + }, 30000); + // Uses noSdkFunc since every other function here already has a warm moduleGraph node from // an earlier test, which would mask this invariant: on a cold entry, Vite only registers // the node under its fully-resolved (suffixed) id, not the bare path. test('Should compute allowed connection IDs on the very first request for an entry, with no prior priming import', async () => { - const loadModule = server.ssrLoadModule.bind(server); // collectModuleGraphFromServer appends LOCAL_EXECUTION_LOAD_SUFFIX internally, so this // closure only handles the bare id — matching vite/index.ts's real wiring. const getAllowedConnectionIds = async (entryId: string) => { @@ -374,7 +436,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - loadModule, () => [noSdkFunc], getAllowedConnectionIds, auth, @@ -385,6 +446,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const req = createMockRequest('/__dd/executeAction', { @@ -406,7 +468,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // collectActionCatalogImports's `ImportDeclaration` search can't parse, so the collector // must read each module's original source from disk instead of the transformed output. test('Should recognize a connectionId-scoped action-catalog call and allow it, not silently reject it', async () => { - const loadModule = server.ssrLoadModule.bind(server); const getAllowedConnectionIds = async (entryId: string) => { const log = getMockLogger(); const moduleGraph = await collectModuleGraphFromServer( @@ -427,7 +488,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - loadModule, () => [actionCatalogCallFunc], getAllowedConnectionIds, auth, @@ -438,6 +498,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); // The connection-ID collector is under test here, not the preview-async round trip @@ -466,11 +527,9 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { }, 30000); test('Should return a real bundle with the connection IDs it uses from /__dd/debugBundle as JSON', async () => { - const loadModule = server.ssrLoadModule.bind(server); const log = getMockLogger(); const middleware = createDevServerMiddleware( build, - loadModule, () => [actionCatalogCallFunc], async () => [], { site: 'datadoghq.com' }, @@ -505,7 +564,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { // A dynamic import sits between two static ones, so resolution must come from the AST // itself rather than node.importedModules's undocumented ordering for mixed imports. test('Should recognize a connectionId-scoped action-catalog call even when a top-level dynamic import sits between two static imports', async () => { - const loadModule = server.ssrLoadModule.bind(server); const getAllowedConnectionIds = async (entryId: string) => { const log = getMockLogger(); const moduleGraph = await collectModuleGraphFromServer( @@ -526,7 +584,6 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( build, - loadModule, () => [mixedImportsFunc], getAllowedConnectionIds, auth, @@ -537,6 +594,7 @@ describe('Dev Server Middleware — real end-to-end local execution', () => { 'development', isHostAllowed, defaultServerConditions, + makeChildExecutor(server), ); const apiScope = nock('https://api.datadoghq.com') @@ -601,7 +659,6 @@ describe('Dev Server Middleware — dev:verify bundles', () => { const isHostAllowed = createViteHostCheck({ server: { allowedHosts: [] }, preview: {} }); const middleware = createDevServerMiddleware( build, - async () => ({}), () => [func], async () => [], { site: 'datadoghq.com' } satisfies AuthOptionsWithDefaults, @@ -765,7 +822,6 @@ describe('Dev Server Middleware — dev:verify bundles', () => { // Uses its own dev server rooted in a temp dir, since writing files under the shared fixtures tree // would race other test files that copy that whole tree in parallel. -const FAKE_SDK_NAME = 'dd-fake-stateful-sdk'; describe('Dev client module', () => { const startDevServer = async (base: string, extraPlugins: Plugin[] = []) => { @@ -859,6 +915,10 @@ describe('Dev Server Middleware — editing files between local executions', () const seed = `apps-edit-then-execute-${process.pid}`; const root = getTempWorkingDir(seed); editRoot = root; + // A call's process loads modules through the project's own Vite module runner. + const viteDir = path.dirname(require.resolve('vite/package.json')); + fs.mkdirSync(path.join(root, 'node_modules'), { recursive: true }); + fs.symlinkSync(viteDir, path.join(root, 'node_modules', 'vite'), 'dir'); const context = getContextMock({ buildRoot: root }); const appsPlugin: Plugin = { @@ -884,8 +944,6 @@ describe('Dev Server Middleware — editing files between local executions', () }, plugins: [appsPlugin], optimizeDeps: { noDiscovery: true }, - // Bundled into the SSR graph like the real SDKs, so its evaluation can be cached or re-run. - ssr: { noExternal: [FAKE_SDK_NAME] }, }); }); @@ -1047,62 +1105,6 @@ describe('Dev Server Middleware — editing files between local executions', () result: { data: versionsInEveryBranch('v2') }, }); }, 30000); - - test('Should keep an invalidated package dependency cached, so its state survives an edit', async () => { - const sdkDir = path.join(editServer.config.root, 'node_modules', FAKE_SDK_NAME); - const sdkManifest = JSON.stringify({ - name: FAKE_SDK_NAME, - type: 'module', - main: 'index.js', - }); - const sdkManifestPath = path.join(sdkDir, 'package.json'); - const sdkEntryPath = path.join(sdkDir, 'index.js'); - outputFileSync(sdkManifestPath, sdkManifest); - outputFileSync( - sdkEntryPath, - 'let calls = 0;\nexport function bump() { calls += 1; return calls; }\n', - ); - const file = path.join(editServer.config.root, 'sdkEntry.backend.ts'); - const func = backendFunctionAt(file, 'callSdk'); - const sourceFor = (version: string) => - `import { bump } from '${FAKE_SDK_NAME}';\nexport async function callSdk() { return ['${version}', bump()]; }\n`; - const initialSource = sourceFor('v1'); - outputFileSync(file, initialSource); - await editServer.ssrLoadModule(file); - const first = await execute(func); - expect(first.body).toEqual({ success: true, result: { data: ['v1', 1] } }); - - const editedSource = sourceFor('v2'); - await editFile(file, editedSource); - // Invalidates the SDK too, as a tsconfig.json change would. - editServer.environments.ssr.moduleGraph.invalidateAll(); - mockRuntimeContextHydration(); - const second = await execute(func); - - expect(second.body).toEqual({ success: true, result: { data: ['v2', 2] } }); - }, 30000); - - test('Should keep module state across unchanged executions after an edit', async () => { - const file = path.join(editServer.config.root, 'countingEntry.backend.ts'); - const func = backendFunctionAt(file, 'countCalls'); - const sourceFor = (start: number) => - `let calls = ${start};\nexport async function countCalls() { calls += 1; return calls; }\n`; - const initialSource = sourceFor(0); - outputFileSync(file, initialSource); - await editServer.ssrLoadModule(file); - const countCalls = async () => { - mockRuntimeContextHydration(); - const { body } = await execute(func); - return body.result.data; - }; - await countCalls(); - - const editedStart = 100; - const editedSource = sourceFor(editedStart); - await editFile(file, editedSource); - const afterEdit = [await countCalls(), await countCalls(), await countCalls()]; - expect(afterEdit).toEqual([editedStart + 1, editedStart + 2, editedStart + 3]); - }, 30000); }); describe('Dev Server Middleware — SSR import warmup warnings', () => { @@ -1178,143 +1180,6 @@ describe('Dev Server Middleware — SSR import warmup warnings', () => { ); }); -const LAZY_PACKAGE_NAME = 'dd-fake-lazy-package'; - -// Its own server, since the exemption lives in the real configureServer hook, and the recording -// plugin below must not see other tests' modules. -describe('Dev Server Middleware — dynamic imports inside a backend function', () => { - let root = ''; - let transformCacheDir = ''; - let lazyServer: ViteDevServer; - const transformWrites: string[] = []; - - beforeAll(async () => { - process.env.DD_API_KEY = 'test-api-key'; - process.env.DD_APP_KEY = 'test-app-key'; - const tmpBase = os.tmpdir(); - const rootPrefix = path.join(tmpBase, 'dd-apps-dynamic-import-'); - // Real path, since Vite resolves symlinks (e.g. macOS's /var) and buildRoot must match. - const createdRoot = fs.mkdtempSync(rootPrefix); - root = fs.realpathSync(createdRoot); - const packageDir = path.join(root, 'node_modules', LAZY_PACKAGE_NAME); - const sourceDir = path.join(root, 'src'); - fs.mkdirSync(packageDir, { recursive: true }); - fs.mkdirSync(sourceDir, { recursive: true }); - const packageManifest = JSON.stringify({ - name: LAZY_PACKAGE_NAME, - type: 'module', - main: 'index.js', - }); - const packageManifestPath = path.join(packageDir, 'package.json'); - const packageEntryPath = path.join(packageDir, 'index.js'); - const lazyModulePath = path.join(packageDir, 'lazy-dep.js'); - fs.writeFileSync(packageManifestPath, packageManifest); - fs.writeFileSync( - packageEntryPath, - "export async function loadLazy() { return (await import('./lazy-dep.js')).value; }\n", - ); - // Its top-level write runs when the module is evaluated, which must stay blocked. - fs.writeFileSync( - lazyModulePath, - [ - "import fs from 'fs';", - "let evaluationWrite = 'allowed';", - "try { fs.writeFileSync(process.env.DD_TEST_LAZY_WRITE_PATH, 'x'); } catch (err) { evaluationWrite = err.message; }", - 'export const value = evaluationWrite;', - ].join('\n'), - ); - - // Outside the OS temp dir, so the write only succeeds if the transform runs exempt. - transformCacheDir = makeProbeDirOutsideTmp('dd-apps-transform-cache-'); - // Stands in for a project plugin that writes a cache file while transforming a module. - const cacheWritingPlugin: Plugin = { - name: 'dd-test-cache-writing-plugin', - transform(_code, id) { - if (!id.includes('lazy-dep')) { - return null; - } - const cachePath = path.join(transformCacheDir, 'transform-cache.json'); - try { - fs.writeFileSync(cachePath, '{}'); - transformWrites.push('written'); - } catch (err) { - transformWrites.push(err instanceof Error ? err.message : String(err)); - } - return null; - }, - }; - const appsPlugin: Plugin = { - name: 'dd-apps-test', - ...getVitePlugin({ - bundler: { build, defaultServerConditions }, - context: getContextMock({ buildRoot: root }), - options: { include: [], longPolling: mockLongPolling }, - }), - }; - lazyServer = await createServer({ - configFile: false, - root, - logLevel: 'silent', - server: { middlewareMode: true, hmr: false, watch: { ignored: ['**/*'] } }, - plugins: [appsPlugin, cacheWritingPlugin], - optimizeDeps: { noDiscovery: true }, - ssr: { noExternal: [LAZY_PACKAGE_NAME] }, - }); - }); - - afterAll(async () => { - delete process.env.DD_TEST_LAZY_WRITE_PATH; - await lazyServer?.close(); - if (root) { - rmSync(root); - } - if (transformCacheDir) { - rmSync(transformCacheDir); - } - }); - - afterEach(() => { - nock.cleanAll(); - }); - - test('Should let a project plugin write while Vite transforms a module the function imports dynamically, but keep its evaluation blocked', async () => { - const outsideTmpDir = makeProbeDirOutsideTmp('dd-apps-dynamic-import-write-'); - try { - const evaluationWritePath = path.join(outsideTmpDir, 'evaluation-write.txt'); - process.env.DD_TEST_LAZY_WRITE_PATH = evaluationWritePath; - const file = path.join(root, 'src', 'lazyEntry.backend.ts'); - fs.writeFileSync( - file, - `import { loadLazy } from '${LAZY_PACKAGE_NAME}';\nexport async function callLazy() { return loadLazy(); }\n`, - ); - await lazyServer.ssrLoadModule(file); - const func: BackendFunction = { - relativePath: 'src/lazyEntry', - name: 'callLazy', - absolutePath: file, - allowedConnectionIds: [], - }; - mockRuntimeContextHydration(); - const functionName = encodeQueryName(func); - const req = createMockRequest('/__dd/executeAction', { functionName, args: [] }); - const res = createMockResponse(); - - lazyServer.middlewares(req, res, jest.fn()); - await res.done; - const rawBody = res.getBody(); - const body: unknown = JSON.parse(rawBody); - - const evaluationWritten = fs.existsSync(evaluationWritePath); - - expect(body).toEqual({ success: true, result: { data: FS_WRITE_BLOCKED_MESSAGE } }); - expect(evaluationWritten).toBe(false); - expect(transformWrites).toEqual(['written']); - } finally { - rmSync(outsideTmpDir); - } - }, 30000); -}); - describe('Dev Server Middleware — request origin and host checks over real HTTP', () => { const VITE_ADDITIONAL_ALLOWED_HOSTS_ENV = '__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS'; const previewSuffix = '.preview.example'; @@ -1503,3 +1368,104 @@ describe('Dev Server Middleware — request origin and host checks over real HTT 30000, ); }); + +describe('Dev Server Middleware — calls in flight when the dev server closes or restarts', () => { + const spinForeverFunc: BackendFunction = { + relativePath: 'childState', + name: 'spinForever', + absolutePath: path.join(FIXTURE_ROOT, 'childState.backend.ts'), + allowedConnectionIds: [], + }; + const countCallsFunc: BackendFunction = { ...spinForeverFunc, name: 'countCalls' }; + // Under the call timeout, so only the server's own shutdown can end the call this fast. + const SHUTDOWN_DEADLINE_MS = 5000; + + const createAppServer = () => + createServer({ + configFile: false, + root: FIXTURE_ROOT, + logLevel: 'silent', + server: { middlewareMode: true, hmr: false }, + plugins: [ + { + name: 'dd-apps-test', + ...getVitePlugin({ + bundler: { build, defaultServerConditions }, + context: getContextMock({ buildRoot: FIXTURE_ROOT }), + options: { include: [], tags: [], longPolling: mockLongPolling }, + }), + }, + ], + optimizeDeps: { noDiscovery: true }, + }); + + const callChildPids = () => { + try { + const pgrepArgs = ['-P', String(process.pid), '-f', 'local-execution-child']; + const output = execFileSync('pgrep', pgrepArgs, { encoding: 'utf8' }); + return output.split('\n').filter(Boolean); + } catch { + return []; + } + }; + + const call = (server: ViteDevServer, func: BackendFunction, args: unknown[]) => { + mockRuntimeContextHydration(); + const req = createMockRequest('/__dd/executeAction', { + functionName: encodeQueryName(func), + args, + }); + const res = createMockResponse(); + server.middlewares(req, res, jest.fn()); + return res; + }; + + const startSpinningCall = async (server: ViteDevServer) => { + await server.ssrLoadModule(spinForeverFunc.absolutePath); + const res = call(server, spinForeverFunc, []); + while (callChildPids().length === 0) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + return res; + }; + + const settlesWithin = (done: Promise, timeoutMs: number) => { + const deadline = new Promise((resolve) => setTimeout(resolve, timeoutMs, 'still running')); + const settled = done.then(() => 'settled'); + return Promise.race([settled, deadline]); + }; + + afterEach(() => { + nock.cleanAll(); + }); + + test('Should end a call still in flight when its dev server closes', async () => { + const server = await createAppServer(); + const res = await startSpinningCall(server); + + await server.close(); + + await expect(settlesWithin(res.done, SHUTDOWN_DEADLINE_MS)).resolves.toBe('settled'); + expect(res.statusCode).toBe(500); + expect(callChildPids()).toEqual([]); + }, 30000); + + test("Should end the old server's calls on restart and keep serving new ones", async () => { + const server = await createAppServer(); + try { + const res = await startSpinningCall(server); + + await server.restart(); + + await expect(settlesWithin(res.done, SHUTDOWN_DEADLINE_MS)).resolves.toBe('settled'); + expect(res.statusCode).toBe(500); + await server.ssrLoadModule(countCallsFunc.absolutePath); + const next = call(server, countCallsFunc, [0]); + await next.done; + expect(next.statusCode).toBe(200); + } finally { + await server.close(); + } + }, 30000); +}); diff --git a/packages/plugins/apps/src/vite/dev-server.test.ts b/packages/plugins/apps/src/vite/dev-server.test.ts index 00f844842..30343f0ae 100644 --- a/packages/plugins/apps/src/vite/dev-server.test.ts +++ b/packages/plugins/apps/src/vite/dev-server.test.ts @@ -2,8 +2,6 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -/* global globalThis */ - import { getAuthenticatedRequest } from '@dd/apps-plugin/auth'; import { buildBackendFunctions } from '@dd/apps-plugin/vite/build-backend-functions'; import { createViteHostCheck } from '@dd/apps-plugin/vite/dev-server-request-guard'; @@ -15,7 +13,12 @@ import { getRetryDelay, oversizedBundleMessage, } from '@dd/apps-plugin/vite/dev-server'; -import { withTimeout } from '@dd/apps-plugin/vite/local-execution'; +import type { + LocalExecutionRequest, + LocalExecutor, +} from '@dd/apps-plugin/vite/local-execution-process'; +import { createChildProcessExecutor } from '@dd/apps-plugin/vite/local-execution-process'; +import { DEFAULT_TIMEOUT_MS } from '@dd/apps-plugin/vite/local-execution'; import { rmSync } from '@dd/core/helpers/fs'; import type { AuthOptionsWithDefaults, RequestOpts } from '@dd/core/types'; import { @@ -23,32 +26,30 @@ import { createMockResponse, getMockLogger, mockLogFn, - moduleResolverFor, } from '@dd/tests/_jest/helpers/mocks'; import { emitModuleParsed } from '@dd/tests/_jest/helpers/moduleParsed'; import type { IncomingMessage } from 'http'; import nock from 'nock'; +import { fork } from 'node:child_process'; import type { InlineConfig } from 'vite'; import { defaultServerConditions } from 'vite'; import { encodeQueryName } from '../backend/encodeQueryName'; import { DEBUG_BUNDLE_PATH } from '../backend/protocol'; import type { BackendFunction } from '../backend/types'; -import { DEV_VERIFY_MODE, LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import { DEV_VERIFY_MODE } from '../constants'; import type { AppsOptionsWithDefaults } from '../types'; -/** Shape of the `$.Actions` dynamic proxy — a nested property path (e.g. `$.Actions.slack.chat.postMessage`) callable at any depth; types `globalThis.$` in tests without an `any` cast. */ -type ActionsProxy = { [key: string]: ActionsProxy } & ((...args: unknown[]) => Promise); - -/** Reads the `$.Actions` local-execution.ts installs onto `globalThis` — genuinely untyped, so the cast is centralized here instead of repeated at each call site. */ -function testDollarActions(): ActionsProxy { - return (globalThis as typeof globalThis & { $: { Actions: ActionsProxy } }).$.Actions; -} +// Spying on the shared core module fails once a sandbox guard makes `fork` non-configurable. +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + return { ...actual, fork: jest.fn(actual.fork) }; +}); const mockViteBuild = jest.fn(); -/** Stands in for the real `server.ssrLoadModule` — the local executeAction path doesn't bundle, so tests exercising it configure this directly instead of `mockBuildWithParsedBackend`. */ -const mockLoadModule = jest.fn(); +/** Stands in for the child process executor, which the local executeAction path hands each call to. */ +const mockExecuteLocally = jest.fn(); const DD_API_ORIGIN = 'https://api.datadoghq.com'; @@ -192,22 +193,36 @@ describe('getRetryDelay', () => { }); }); -/** - * Configures `mockLoadModule` to resolve `func`'s absolute path to a module - * exporting a single named function, matching what the real `ssrLoadModule` - * returns for a real backend-function file. - */ -function mockLoadModuleReturning(func: BackendFunction, fn: (...args: never[]) => unknown) { - const resolveModule = moduleResolverFor(func, { [func.name]: fn }); - mockLoadModule.mockImplementation(resolveModule); +/** Has `mockExecuteLocally` hydrate `$` through the request, as the real executor does, then return `fn`'s result. */ +function mockExecutorReturning(fn: (...args: unknown[]) => unknown) { + mockExecuteLocally.mockImplementation(async (request: LocalExecutionRequest) => { + await request.getRuntimeContext(); + return { data: await fn(...request.args) }; + }); } -function createDeferred() { - let resolve: () => void = () => {}; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; +/** The real executor, for checks that end before a call's process starts; a fork fails the test. */ +function createTestExecutor(): LocalExecutor { + return createChildProcessExecutor({ + invokeChannel: { handleInvoke: async () => ({}) }, + projectRoot: '/project', + childEntryPath: '/nonexistent/local-execution-child.mjs', + moduleRunnerPath: '/nonexistent/module-runner.js', + log: mockLog, }); - return { promise, resolve }; +} + +/** Stands in for a call whose backend function makes one `$.Actions.slack.chat.postMessage` call. */ +function postMessageExecutor(connectionId: string | undefined): LocalExecutor { + return async ({ executeAction }) => { + const inputs = { text: 'hi' }; + const data = await executeAction( + 'com.datadoghq.slack.chat.postMessage', + inputs, + connectionId, + ); + return { data }; + }; } const previewRuntimeContext = { @@ -282,22 +297,21 @@ type CreateDevServerMiddlewareArgs = Parameters { return createDevServerMiddleware( overrides.viteBuild ?? mockViteBuild, - overrides.loadModule ?? mockLoadModule, overrides.getBackendFunctions ?? (() => mockFunctions), overrides.getAllowedConnectionIds ?? (async () => []), overrides.auth ?? mockAuth, @@ -310,6 +324,7 @@ function createTestMiddleware( overrides.mode ?? 'development', overrides.isHostAllowed ?? localOnlyHostCheck, overrides.serverConditions ?? defaultServerConditions, + overrides.executeLocally ?? mockExecuteLocally, ); } @@ -317,7 +332,7 @@ describe('Dev Server Middleware', () => { beforeEach(() => { jest.clearAllMocks(); mockViteBuild.mockReset(); - mockLoadModule.mockReset(); + mockExecuteLocally.mockReset(); }); afterEach(() => { @@ -370,7 +385,7 @@ describe('Dev Server Middleware', () => { test('Should handle /__dd/executeAction POST by hydrating context and running the function locally without bundling', async () => { mockRuntimeContextHydration(); - mockLoadModuleReturning(mockFunctions[0], (arg) => arg); + mockExecutorReturning((arg) => arg); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), @@ -461,7 +476,7 @@ describe('Dev Server Middleware', () => { expect(body.success).toBe(true); expect(body.result).toEqual({ data: { result: 'via cloud' } }); expect(apiScope.isDone()).toBe(true); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(mockExecuteLocally).not.toHaveBeenCalled(); }); }); @@ -510,7 +525,7 @@ describe('Dev Server Middleware', () => { expect(body.success).toBe(false); expect(next).not.toHaveBeenCalled(); expect(mockViteBuild).not.toHaveBeenCalled(); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(mockExecuteLocally).not.toHaveBeenCalled(); const routeMessage = expect.stringContaining(route); expect(mockLogFn).toHaveBeenCalledWith(routeMessage, 'debug'); }, @@ -1476,11 +1491,12 @@ describe('Dev Server Middleware', () => { }); describe('executeAction handler (local)', () => { + const forkMock = jest.mocked(fork); beforeEach(() => { mockRuntimeContextHydration(); }); - const middleware = createTestMiddleware(); + const middleware = createTestMiddleware({ executeLocally: createTestExecutor() }); test('Should return 400 for missing functionRef', async () => { const req = createMockRequest('/__dd/executeAction', {}); @@ -1504,26 +1520,31 @@ describe('Dev Server Middleware', () => { expect(res.statusCode).toBe(404); }); - test('Should hydrate context, run the function directly in-process, and return its result without bundling', async () => { - mockLoadModuleReturning(mockFunctions[0], (arg: number) => arg * 2); - + test('Should hand the call, with its timeout and long polling, to the local executor', async () => { + const executeLocally = jest.fn().mockResolvedValue({ data: 'from child' }); + const childMiddleware = createTestMiddleware({ executeLocally }); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), args: [21], }); const res = createMockResponse(); - middleware(req, res, jest.fn()); + childMiddleware(req, res, jest.fn()); await res.done; expect(res.statusCode).toBe(200); - const body = JSON.parse(res.getBody()); - expect(body.success).toBe(true); - expect(body.result).toEqual({ data: 42 }); - expect(mockViteBuild).not.toHaveBeenCalled(); + expect(JSON.parse(res.getBody()).result).toEqual({ data: 'from child' }); + expect(executeLocally).toHaveBeenCalledWith( + expect.objectContaining({ + func: mockFunctions[0], + args: [21], + timeoutMs: DEFAULT_TIMEOUT_MS, + longPolling: mockLongPolling, + }), + ); }); - test('Should reject a malformed hydrated identity before loading customer code without exposing the response body', async () => { + test("Should reject a malformed hydrated identity before starting a call's process without exposing the response body", async () => { nock.cleanAll(); mockRuntimeContextHydration(1, { data: { @@ -1548,14 +1569,14 @@ describe('Dev Server Middleware', () => { const body = JSON.parse(res.getBody()); expect(body.error).toContain('Source.initiator'); expect(body.error).not.toContain('must-not-appear'); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(forkMock).not.toHaveBeenCalled(); expect(mockLogFn).not.toHaveBeenCalledWith( expect.stringContaining('must-not-appear'), expect.anything(), ); }); - test('Should reject hydration outputs with no data field before loading customer code', async () => { + test("Should reject hydration outputs with no data field before starting a call's process", async () => { nock.cleanAll(); mockRuntimeContextHydration(1, {}); @@ -1573,13 +1594,14 @@ describe('Dev Server Middleware', () => { expect(body.error).toContain( 'Runtime context hydration completed without a "data" field', ); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(forkMock).not.toHaveBeenCalled(); }); test('Should bound stalled runtime-context hydration when the request ignores abort so later local executions are not wedged', async () => { jest.useFakeTimers(); try { nock.cleanAll(); + const executeLocally = createTestExecutor(); let hydrationSignal: AbortSignal | undefined; const nonAbortableRuntimeContextRequest = ( options: Omit, @@ -1591,7 +1613,6 @@ describe('Dev Server Middleware', () => { }; const hangingHydrationMiddleware = createDevServerMiddleware( mockViteBuild, - mockLoadModule, () => mockFunctions, async () => [], mockAuth, @@ -1602,6 +1623,7 @@ describe('Dev Server Middleware', () => { 'development', localOnlyHostCheck, defaultServerConditions, + executeLocally, ); const req = createMockRequest('/__dd/executeAction', { @@ -1620,14 +1642,16 @@ describe('Dev Server Middleware', () => { const body = JSON.parse(res.getBody()); expect(body.error).toContain('Runtime context hydration timed out after 60000ms'); expect(hydrationSignal?.aborted).toBe(true); - expect(mockLoadModule).not.toHaveBeenCalled(); + expect(forkMock).not.toHaveBeenCalled(); const recoveringRuntimeContextRequest = makeImmediateRuntimeContextRequest(); + // Reaching the connection lookup shows the stalled call no longer holds the queue. const recoveringMiddleware = createDevServerMiddleware( mockViteBuild, - mockLoadModule, () => mockFunctions, - async () => [], + async () => { + throw new Error('reached the connection lookup'); + }, mockAuth, recoveringRuntimeContextRequest, mockLongPolling, @@ -1636,8 +1660,8 @@ describe('Dev Server Middleware', () => { 'development', localOnlyHostCheck, defaultServerConditions, + executeLocally, ); - mockLoadModuleReturning(mockFunctions[0], () => 'recovered'); const recoveringReq = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), args: [], @@ -1649,10 +1673,9 @@ describe('Dev Server Middleware', () => { await jest.advanceTimersByTimeAsync(0); await recoveringDone; - expect(recoveringRes.statusCode).toBe(200); - expect(JSON.parse(recoveringRes.getBody()).result).toEqual({ - data: 'recovered', - }); + expect(recoveringRes.statusCode).toBe(500); + const recoveringBody = JSON.parse(recoveringRes.getBody()); + expect(recoveringBody.error).toContain('reached the connection lookup'); } finally { jest.useRealTimers(); } @@ -1660,7 +1683,6 @@ describe('Dev Server Middleware', () => { test('Should reject a request with no auth configured upfront, even for a function that never calls $.Actions — matching production, which authenticates before any backend code runs', async () => { const noAuthMiddleware = createTestMiddleware({ doAuthenticatedRequest: undefined }); - mockLoadModuleReturning(mockFunctions[0], () => 'pure result, no $.Actions call'); const req = createMockRequest('/__dd/executeAction', { functionName: encodeQueryName(mockFunctions[0]), @@ -1684,15 +1706,8 @@ describe('Dev Server Middleware', () => { }; const middlewareWithConnection = createTestMiddleware({ getBackendFunctions: () => [funcWithConnection, mockFunctions[1]], - getAllowedConnectionIds: async (entryId: string) => - entryId === funcWithConnection.absolutePath ? ['conn-1'] : [], + executeLocally: postMessageExecutor('conn-1'), }); - mockLoadModuleReturning(funcWithConnection, () => - testDollarActions().slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: 'conn-1', - }), - ); type PreviewAsyncBody = { data: { @@ -1733,9 +1748,7 @@ describe('Dev Server Middleware', () => { expect(res.statusCode).toBe(200); const body = JSON.parse(res.getBody()); expect(body.success).toBe(true); - // The action's raw output ({ok, ts}) is what $.Actions.foo.bar() resolves to; the - // outer {data: ...} comes from executeScriptLocally's usual return-value wrapping, - // not anything action-specific. + // The action's raw output ({ok, ts}) is what $.Actions.foo.bar() resolves to. expect(body.result).toEqual({ data: { ok: true, ts: '123' } }); expect(apiScope.isDone()).toBe(true); expect(capturedBody?.data.attributes.query.properties.spec).toEqual({ @@ -1755,15 +1768,8 @@ describe('Dev Server Middleware', () => { }; const middlewareWithEmptyConnection = createTestMiddleware({ getBackendFunctions: () => [funcWithEmptyConnection, mockFunctions[1]], - getAllowedConnectionIds: async (entryId: string) => - entryId === funcWithEmptyConnection.absolutePath ? [''] : [], + executeLocally: postMessageExecutor(''), }); - mockLoadModuleReturning(funcWithEmptyConnection, () => - testDollarActions().slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: '', - }), - ); let capturedBody: | { @@ -1800,11 +1806,9 @@ describe('Dev Server Middleware', () => { }); test("Should log a successful $.Actions call's fqn, duration, and response size at debug level without logging the response body", async () => { - mockLoadModuleReturning(mockFunctions[0], () => - testDollarActions().slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - ); + const actionMiddleware = createTestMiddleware({ + executeLocally: postMessageExecutor(undefined), + }); const responseSentinel = 'monitor-creator@example.com'; const actionOutputs = { ok: true, creator: responseSentinel, name: 'café' }; @@ -1826,7 +1830,7 @@ describe('Dev Server Middleware', () => { }); const res = createMockResponse(); - middleware(req, res, jest.fn()); + actionMiddleware(req, res, jest.fn()); await res.done; expect(res.statusCode).toBe(200); @@ -1854,11 +1858,9 @@ describe('Dev Server Middleware', () => { }); test("Should surface a failed $.Actions call's error detail to the local console", async () => { - mockLoadModuleReturning(mockFunctions[0], () => - testDollarActions().slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - ); + const actionMiddleware = createTestMiddleware({ + executeLocally: postMessageExecutor(undefined), + }); nock(DD_API_ORIGIN) .post('/api/v2/app-builder/queries/preview-async') @@ -1874,7 +1876,7 @@ describe('Dev Server Middleware', () => { }); const res = createMockResponse(); - middleware(req, res, jest.fn()); + actionMiddleware(req, res, jest.fn()); await res.done; expect(res.statusCode).toBe(500); @@ -1888,215 +1890,14 @@ describe('Dev Server Middleware', () => { ); }); - // The priming loadModule call (see handleExecuteAction) is the only place the entry's - // top-level code runs (Vite caches the module for executeScriptLocally's reuse), so it - // must carry the same $-scoping guarantee executeScriptLocally's own load would provide. - test('Should read $ as undefined when a customer module reaches for it during its own top-level evaluation, before runScriptLocally installs its own execution-scoped $', async () => { - let dollarDuringTopLevelLoad: unknown = 'not captured'; - mockLoadModule.mockImplementation(async (specifier: string) => { - if (specifier === mockFunctions[0].absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - dollarDuringTopLevelLoad = (globalThis as Record).$; - return { [mockFunctions[0].name]: () => 'done' }; - } - throw new Error(`Cannot find module '${specifier}'`); - }); - - const req = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[0]), - args: [], - }); - const res = createMockResponse(); - - middleware(req, res, jest.fn()); - await res.done; - - expect(res.statusCode).toBe(200); - expect(dollarDuringTopLevelLoad).toBeUndefined(); - }); - - // The priming load runs before runScriptLocally's own hang-detection timeout is - // installed, so a hanging top-level await would otherwise wedge this request and every - // request queued behind it forever. - test('Should eventually time out and return a clear error when the priming load never settles', async () => { - jest.useFakeTimers(); - try { - const immediateRuntimeContextRequest = makeImmediateRuntimeContextRequest(); - const hangingLoadModule = jest.fn( - // Never settles. - () => new Promise>(() => {}), - ); - const immediateHydrationMiddleware = createDevServerMiddleware( - mockViteBuild, - hangingLoadModule, - () => mockFunctions, - async () => [], - mockAuth, - immediateRuntimeContextRequest, - mockLongPolling, - '/project', - mockLog, - 'development', - localOnlyHostCheck, - defaultServerConditions, - ); - - const req = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[0]), - args: [], - }); - const res = createMockResponse(); - - immediateHydrationMiddleware(req, res, jest.fn()); - const doneAssertion = res.done; - - // createMockRequest emits the body via a real process.nextTick, which fake timers - // don't advance — drain it first so the priming load's setTimeout is scheduled - // before runAllTimersAsync tries to advance past it. - await jest.advanceTimersByTimeAsync(0); - await jest.runAllTimersAsync(); - await doneAssertion; - - expect(res.statusCode).toBe(500); - const body = JSON.parse(res.getBody()); - expect(body.error).toMatch(/timed out after 10000ms/); - } finally { - jest.useRealTimers(); - } - }); - - // The race test's timeout leaves both deadlines room to fail with their own message. - const RACE_SIGNAL_DEADLINE_MS = 5000; - const RACE_RESPONSE_DEADLINE_MS = 4000; - // Priming evaluates real top-level customer code — if it ran outside executeColdActionLocally's - // enqueue() call, two concurrent requests for two different cold functions could evaluate - // their top-level code in genuine parallel instead of one fully finishing before the other starts. - test( - 'Should never let two concurrent requests for different cold functions race their priming loads', - async () => { - // The describe's beforeEach registers greet's hydration; this one serves compute's. - const apiScope = mockRuntimeContextHydration(); - const order: string[] = []; - const greetPrimingGate = createDeferred(); - const greetPrimingStarted = createDeferred(); - const computeQueued = createDeferred(); - // handleExecuteAction logs this and reaches enqueue() with no await in between. - const computeQueuedMessage = `Executing action locally: ${formatRef(mockFunctions[1])}`; - const queueAwareLog = getMockLogger({ - debug: (text: string) => { - if (text.startsWith(computeQueuedMessage)) { - computeQueued.resolve(); - } - }, - }); - // A cold execution submits its runtime-context query as soon as its queued turn starts, - // so a second submission means compute's turn began while greet still held the queue. - let submittedQueryCount = 0; - const countingRequest = (opts: Omit) => { - if (opts.method === 'POST') { - submittedQueryCount += 1; - } - return testAuthenticatedRequest(opts); - }; - const queueAwareMiddleware = createTestMiddleware({ - log: queueAwareLog, - doAuthenticatedRequest: countingRequest, - }); - - mockLoadModule.mockImplementation(async (specifier: string) => { - if (specifier === mockFunctions[0].absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - order.push('greet-priming-start'); - greetPrimingStarted.resolve(); - await greetPrimingGate.promise; - order.push('greet-priming-end'); - return { [mockFunctions[0].name]: () => 'greet-done' }; - } - if (specifier === mockFunctions[1].absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - order.push('compute-priming-start'); - order.push('compute-priming-end'); - return { [mockFunctions[1].name]: () => 'compute-done' }; - } - throw new Error(`Cannot find module '${specifier}'`); - }); - - const reqGreet = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[0]), - args: [], - }); - const resGreet = createMockResponse(); - const reqCompute = createMockRequest('/__dd/executeAction', { - functionName: encodeQueryName(mockFunctions[1]), - args: [], - }); - const resCompute = createMockResponse(); - - // Fired back-to-back, before either request's own body has even finished parsing. - const nextGreet = jest.fn(); - const nextCompute = jest.fn(); - queueAwareMiddleware(reqGreet, resGreet, nextGreet); - queueAwareMiddleware(reqCompute, resCompute, nextCompute); - - // The finally releases greet so queued work can drain before the next test. Its deadline - // doesn't throw, so a stuck response can't hide an earlier failure. - let responsesOutcome = ''; - try { - const bothSignals = Promise.all([ - greetPrimingStarted.promise, - computeQueued.promise, - ]); - await withTimeout( - bothSignals, - RACE_SIGNAL_DEADLINE_MS, - 'Waiting for greet to prime and compute to queue', - ); - // Compute starting its turn early or priming outside the queue has no event to wait - // for, so this delay only bounds how long either regression gets to show up; correct - // code passes for any delay well under the priming and test timeouts. - await new Promise((resolve) => setTimeout(resolve, 20)); - expect(submittedQueryCount).toBe(1); - expect(order).toEqual(['greet-priming-start']); - } finally { - greetPrimingGate.resolve(); - const bothResponses = Promise.all([resGreet.done, resCompute.done]); - responsesOutcome = await withTimeout( - bothResponses, - RACE_RESPONSE_DEADLINE_MS, - 'Waiting for responses', - ).then( - () => 'ended', - (error: unknown) => String(error), - ); - } - - expect(responsesOutcome).toBe('ended'); - expect(resGreet.statusCode).toBe(200); - expect(resCompute.statusCode).toBe(200); - expect(nextGreet).not.toHaveBeenCalled(); - expect(nextCompute).not.toHaveBeenCalled(); - const isComputeHydrated = apiScope.isDone(); - expect(isComputeHydrated).toBe(true); - expect(order).toEqual([ - 'greet-priming-start', - 'greet-priming-end', - 'compute-priming-start', - 'compute-priming-end', - ]); - }, - RACE_SIGNAL_DEADLINE_MS + RACE_RESPONSE_DEADLINE_MS + 5000, - ); - - // Regression test: getAllowedConnectionIds (which only resolves/transforms modules, never - // executes them) must run and reject before the priming load ever evaluates the entry's - // real top-level code — otherwise a banned import there could run before being rejected. - test('Should never prime (evaluate) the entry when getAllowedConnectionIds rejects', async () => { - const primingCalls: string[] = []; - mockLoadModule.mockImplementation(async (specifier: string) => { - primingCalls.push(specifier); - return { [mockFunctions[0].name]: () => 'done' }; - }); + // getAllowedConnectionIds only resolves and transforms modules, so it rejects a banned + // import before any of the entry's code runs. + test("Should never start a call's process when getAllowedConnectionIds rejects", async () => { const rejectingMiddleware = createTestMiddleware({ getAllowedConnectionIds: async () => { throw new Error('Importing Node built-in module "fs" is not supported'); }, + executeLocally: createTestExecutor(), }); const req = createMockRequest('/__dd/executeAction', { @@ -2109,21 +1910,21 @@ describe('Dev Server Middleware', () => { await res.done; expect(res.statusCode).toBe(500); - expect(primingCalls).toEqual([]); + expect(JSON.parse(res.getBody()).error).toContain('"fs" is not supported'); + expect(forkMock).not.toHaveBeenCalled(); }); - // Guards getAllowedConnectionIds — it reads and transforms every reachable module from - // disk (dev-server-module-graph.ts) with no bound of its own, unlike the sibling priming - // load next to it in executeColdActionLocally. + // getAllowedConnectionIds reads and transforms every reachable module from disk + // (dev-server-module-graph.ts) with no bound of its own. test('Should eventually time out and return a clear error when getAllowedConnectionIds never settles', async () => { jest.useFakeTimers(); try { - mockLoadModuleReturning(mockFunctions[0], () => 'done'); const immediateRuntimeContextRequest = makeImmediateRuntimeContextRequest(); const hangingMiddleware = createTestMiddleware({ // Never settles. getAllowedConnectionIds: () => new Promise(() => {}), doAuthenticatedRequest: immediateRuntimeContextRequest, + executeLocally: createTestExecutor(), }); const req = createMockRequest('/__dd/executeAction', { diff --git a/packages/plugins/apps/src/vite/dev-server.ts b/packages/plugins/apps/src/vite/dev-server.ts index d43bfc787..a3a1dab38 100644 --- a/packages/plugins/apps/src/vite/dev-server.ts +++ b/packages/plugins/apps/src/vite/dev-server.ts @@ -29,8 +29,9 @@ import { createBackendStaticChecksPlugin } from './backend-static-checks-plugin' import { getBaseBackendBuildConfig, withProductionSettings } from './build-config'; import type { HostCheck } from './dev-server-request-guard'; import { checkDevServerRequest } from './dev-server-request-guard'; -import type { ExecuteAction, GetRuntimeContext, LoadModule } from './local-execution'; -import { DEFAULT_TIMEOUT_MS, executeColdActionLocally } from './local-execution'; +import type { LocalExecutor } from './local-execution-process'; +import type { ExecuteAction, GetRuntimeContext } from './local-execution'; +import { DEFAULT_TIMEOUT_MS } from './local-execution'; import { getMaxRetryDelayMs } from './retry-delay'; interface BundleResult { @@ -656,9 +657,9 @@ async function handleDebugBundle( } /** - * Handles POST /__dd/executeAction — imports a backend function's real file and executes - * it in-process (see local-execution.ts), with no bundling. Auth is checked upfront by the - * caller in `createDevServerMiddleware`, matching production's auth-before-execution ordering. + * Handles POST /__dd/executeAction — runs a backend function's real file with no bundling, in its + * own child process (see local-execution-process.ts). Auth is checked upfront by the caller in + * `createDevServerMiddleware`, matching production's auth-before-execution ordering. */ async function handleExecuteAction( req: IncomingMessage, @@ -667,19 +668,19 @@ async function handleExecuteAction( auth: AuthConfig, doAuthenticatedRequest: DoAuthenticatedRequest, longPolling: LongPollingConfig, - loadModule: LoadModule, getAllowedConnectionIds: (entryId: string) => Promise, - projectRoot: string, log: Logger, + executeLocally: LocalExecutor | undefined, ): Promise { try { + if (!executeLocally) { + throw new Error('Local execution is not set up on this dev server.'); + } const { func, args } = await parseAndLookupFunction(req, functionsByName); const displayName = formatRef(func); log.debug(`Executing action locally: ${displayName} with args`); - // Priming and connection-ID collection must happen inside the same serialization - // boundary as execution — see executeColdActionLocally's doc comment for why. const executeAction = makeExecuteActionRemotely( auth, doAuthenticatedRequest, @@ -692,18 +693,15 @@ async function handleExecuteAction( longPolling, log, ); - const result = await executeColdActionLocally( + const result = await executeLocally({ func, - projectRoot, args, executeAction, getRuntimeContext, - loadModule, getAllowedConnectionIds, - log, - DEFAULT_TIMEOUT_MS, + timeoutMs: DEFAULT_TIMEOUT_MS, longPolling, - ); + }); sendSuccess(res, result); } catch (error: unknown) { @@ -794,7 +792,6 @@ function buildFunctionMap(backendFunctions: BackendFunction[]): Map BackendFunction[], getAllowedConnectionIds: (entryId: string) => Promise, auth: AuthConfig, @@ -805,6 +802,7 @@ export function createDevServerMiddleware( mode: string, isHostAllowed: HostCheck, serverConditions: readonly string[], + executeLocally?: LocalExecutor, ): (req: IncomingMessage, res: ServerResponse, next: () => void) => void { const bundle = (func: BackendFunction) => bundleBackendFunction(viteBuild, serverConditions, func, projectRoot, log); @@ -839,10 +837,9 @@ export function createDevServerMiddleware( auth, authedRequest, longPolling, - loadModule, getAllowedConnectionIds, - projectRoot, log, + executeLocally, ), ); const routeHandlers: Record = { diff --git a/packages/plugins/apps/src/vite/execution-epoch.test.ts b/packages/plugins/apps/src/vite/execution-epoch.test.ts deleted file mode 100644 index 5bb180fc0..000000000 --- a/packages/plugins/apps/src/vite/execution-epoch.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -// 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 deleted file mode 100644 index cb058117b..000000000 --- a/packages/plugins/apps/src/vite/execution-epoch.ts +++ /dev/null @@ -1,38 +0,0 @@ -// 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/index.test.ts b/packages/plugins/apps/src/vite/index.test.ts index 8a1be7583..8e8551635 100644 --- a/packages/plugins/apps/src/vite/index.test.ts +++ b/packages/plugins/apps/src/vite/index.test.ts @@ -54,6 +54,7 @@ import { import * as buildPackage from './build-package'; import * as devServer from './dev-server'; +import * as localExecutionProcess from './local-execution-process'; import * as networkGuard from './network-guard'; import { MIN_VITE_VERSION, unsupportedViteMessage } from './vite-version'; @@ -103,7 +104,11 @@ type DevServerMiddleware = (req: IncomingMessage, res: ServerResponse, next: () type FakeViteDevServer = { middlewares: { use: (fn: DevServerMiddleware) => void }; ssrLoadModule: (id: string) => Promise; + close?: () => Promise; config: { mode: string } & HostCheckConfig; + environments?: { + ssr: { hot: { handleInvoke: () => Promise }; config: { dev: object } }; + }; }; function fakeServerConfig(mode: string): FakeViteDevServer['config'] { @@ -121,7 +126,9 @@ function getConfigureServer( throw new Error('Expected plugin.configureServer to be the plain function-hook form'); } return function callConfigureServer(server: FakeViteDevServer): void { - Reflect.apply(configureServer, undefined, [server]); + const ssr = { hot: { handleInvoke: async () => ({}) }, config: { dev: {} } }; + const fullServer = { close: async () => {}, environments: { ssr }, ...server }; + Reflect.apply(configureServer, undefined, [fullServer]); }; } @@ -499,6 +506,8 @@ describe('Backend Functions - getVitePlugin', () => { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig('development'), + environments: { ssr: { hot: { handleInvoke: jest.fn() }, config: { dev: {} } } }, + close: jest.fn(), } as unknown as ViteDevServer; // The hooks are typed with Rollup's `this: PluginContext`, but the // plugin closures never read `this`, so a stand-in satisfies the call. @@ -528,6 +537,8 @@ describe('Backend Functions - getVitePlugin', () => { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig('development'), + environments: { ssr: { hot: { handleInvoke: jest.fn() }, config: { dev: {} } } }, + close: jest.fn(), } as unknown as ViteDevServer; plugin.configureServer(server); @@ -556,6 +567,8 @@ describe('Backend Functions - getVitePlugin', () => { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig('development'), + environments: { ssr: { hot: { handleInvoke: jest.fn() }, config: { dev: {} } } }, + close: jest.fn(), } as unknown as ViteDevServer; plugin.configureServer(server); @@ -1451,16 +1464,21 @@ describe('Backend Functions - getVitePlugin', () => { // Without auth both endpoints are off, and dev-verify sends every execution to the cloud. test.each([ - { scenario: 'no authentication', mode: 'development', withAuth: false, installs: 0 }, - { scenario: 'dev-verify mode', mode: DEV_VERIFY_MODE, withAuth: true, installs: 0 }, - { scenario: 'local execution', mode: 'development', withAuth: true, installs: 1 }, + { scenario: 'no authentication', mode: 'development', withAuth: false, runsLocally: false }, + { scenario: 'dev-verify mode', mode: DEV_VERIFY_MODE, withAuth: true, runsLocally: false }, + { scenario: 'local execution', mode: 'development', withAuth: true, runsLocally: true }, ])( - 'Should install the sandbox guards only when local execution can run ($scenario)', - ({ mode, withAuth, installs }) => { + 'Should run backend functions in child processes only when local execution can run ($scenario)', + ({ mode, withAuth, runsLocally }) => { const plugin = getVitePlugin(defaultOptions); - const installGuards = jest - .spyOn(networkGuard, 'installGuards') - .mockImplementation(() => undefined); + const installGuards = jest.spyOn(networkGuard, 'installGuards'); + const createMiddleware = jest.spyOn(devServer, 'createDevServerMiddleware'); + const createExecutor = jest.spyOn(localExecutionProcess, 'createChildProcessExecutor'); + const runnerPath = '/build/node_modules/vite/dist/node/module-runner.js'; + const resolveRunner = jest + .spyOn(localExecutionProcess, 'resolveViteModuleRunner') + .mockReturnValue(runnerPath); + const hot = { handleInvoke: jest.fn(async () => ({})) }; const configureServer = getConfigureServer(plugin); const restoreEnv = clearDatadogEnv(); if (withAuth) { @@ -1473,82 +1491,29 @@ describe('Backend Functions - getVitePlugin', () => { middlewares: { use: jest.fn() }, ssrLoadModule: jest.fn(), config: fakeServerConfig(mode), + environments: { ssr: { hot, config: { dev: {} } } }, }); - expect(installGuards).toHaveBeenCalledTimes(installs); - } finally { - restoreEnv(); - installGuards.mockRestore(); - } - }, - ); - - test.each([ - { predates: true, warns: true }, - { predates: false, warns: false }, - ])( - 'Should warn that fs-extra/graceful-fs loaded before the dev server is unguarded (predates: $predates)', - ({ predates, warns }) => { - const plugin = getVitePlugin(defaultOptions); - const installGuards = jest - .spyOn(networkGuard, 'installGuards') - .mockImplementation(() => undefined); - const gracefulFsPredatesGuards = jest - .spyOn(networkGuard, 'gracefulFsPredatesGuards') - .mockReturnValue(predates); - const configureServer = getConfigureServer(plugin); - const restoreEnv = clearDatadogEnv(); - process.env.DD_API_KEY = 'test-api-key'; - process.env.DD_APP_KEY = 'test-app-key'; - - try { - configureServer({ - middlewares: { use: jest.fn() }, - ssrLoadModule: jest.fn(), - config: fakeServerConfig('development'), - }); - const warned = mockLogFn.mock.calls.some( - ([text, level]) => - level === 'warn' && text === networkGuard.GRACEFUL_FS_UNGUARDED_WARNING, - ); - - expect(warned).toBe(warns); + const executeLocally = createMiddleware.mock.calls[0]?.at(-1); + expect(typeof executeLocally).toBe(runsLocally ? 'function' : 'undefined'); + const executorOptions = createExecutor.mock.calls.map(([options]) => ({ + invokeChannel: options.invokeChannel, + moduleRunnerPath: options.moduleRunnerPath, + })); + const expectedOptions = { invokeChannel: hot, moduleRunnerPath: runnerPath }; + expect(executorOptions).toEqual(runsLocally ? [expectedOptions] : []); + // The dev server's own fs and subprocess APIs stay unpatched: calls run elsewhere. + expect(installGuards).not.toHaveBeenCalled(); } finally { restoreEnv(); installGuards.mockRestore(); - gracefulFsPredatesGuards.mockRestore(); + createMiddleware.mockRestore(); + createExecutor.mockRestore(); + resolveRunner.mockRestore(); } }, ); - // Each local execution installs again and fails closed, so the server itself must still start. - test('Should keep the dev server starting when installing the sandbox guards throws', () => { - const plugin = getVitePlugin(defaultOptions); - const installGuards = jest.spyOn(networkGuard, 'installGuards').mockImplementation(() => { - throw new Error('Cannot redefine property: cpSync'); - }); - const use = jest.fn(); - const configureServer = getConfigureServer(plugin); - const restoreEnv = clearDatadogEnv(); - process.env.DD_API_KEY = 'test-api-key'; - process.env.DD_APP_KEY = 'test-app-key'; - - try { - expect(() => - configureServer({ - middlewares: { use }, - ssrLoadModule: jest.fn(), - config: fakeServerConfig('development'), - }), - ).not.toThrow(); - expect(installGuards).toHaveBeenCalled(); - expect(use).toHaveBeenCalledTimes(1); - } finally { - restoreEnv(); - installGuards.mockRestore(); - } - }); - test("Should hand the middleware Vite's default server conditions", () => { const middlewareSpy = jest.spyOn(devServer, 'createDevServerMiddleware'); const defaultServerConditions = ['module', 'node']; @@ -1562,7 +1527,7 @@ describe('Backend Functions - getVitePlugin', () => { configureServer({ middlewares: { use }, ssrLoadModule, config }); const [middlewareArgs] = middlewareSpy.mock.calls; - const passedConditions = middlewareArgs.at(-1); + const passedConditions = middlewareArgs.at(-2); expect(passedConditions).toBe(defaultServerConditions); }); diff --git a/packages/plugins/apps/src/vite/index.ts b/packages/plugins/apps/src/vite/index.ts index 3e4bcfbce..74c8a8e26 100644 --- a/packages/plugins/apps/src/vite/index.ts +++ b/packages/plugins/apps/src/vite/index.ts @@ -53,13 +53,10 @@ import { buildAppPackage } from './build-package'; import { collectModuleGraphFromServer } from './dev-server-module-graph'; import { createViteHostCheck } from './dev-server-request-guard'; import { createDevServerMiddleware } from './dev-server'; +import { getLocalExecutionChildPath } from './local-execution-child-path'; +import type { ChildProcessExecutor } from './local-execution-process'; +import { createChildProcessExecutor, resolveViteModuleRunner } from './local-execution-process'; import { localExecutionResolutionContext } from './local-execution'; -import { - exemptFetchFromBlockedScope, - GRACEFUL_FS_UNGUARDED_WARNING, - gracefulFsPredatesGuards, - installGuards, -} from './network-guard'; import { createParameterCollector } from './parameter-collector'; import { loadViteParseAst } from './vite-parse-ast'; import { assertSupportedVite } from './vite-version'; @@ -268,9 +265,6 @@ export const getVitePlugin = ({ serverPreTransformRequests = userConfig.server?.preTransformRequests; // A restarted dev server shares this process: read the manifests afresh. forgetBackendModuleOwners(); - // These SDKs ship ESM-only, but ssrLoadModule externalizes node_modules with a plain - // require(), which throws "Cannot use import statement outside a module"; - // ssr.noExternal forces Vite's SSR transform instead. // Only the dev server pre-bundles dependencies or loads modules through SSR; the // nested backend builds configure their own resolution (see build-config.ts). if (command !== 'serve') { @@ -403,9 +397,9 @@ export const getVitePlugin = ({ return null; } - // The other half of the scoping: the store is only populated while a local - // execution's own loadModule call is in flight (see configureServer), so an - // unrelated SSR resolution never inherits a marker from an earlier execution. + // The other half of the scoping: the store is only populated while a call's module + // runner request is in flight (see local-execution-process.ts), so an unrelated SSR + // resolution never inherits a marker from an earlier execution. const subgraphImporters = localExecutionResolutionContext.getStore(); const isPartOfSuffixedSubgraph = !!importer && @@ -494,7 +488,7 @@ export const getVitePlugin = ({ return null; } if (id.endsWith(LOCAL_EXECUTION_LOAD_SUFFIX) && transformOptions?.ssr) { - // Local execution needs the real function body, not the proxy stub below — real loads always go through ssrLoadModule, which runs in SSR, so this only fires for that legitimate path. + // Local execution needs the real function body, not the proxy stub below — a call's module runner always fetches through the SSR environment, so this only fires for that legitimate path. return null; } // Any other case (no query, a spoofed client-side import reusing the suffix, or an unrecognized query) falls through to the safe proxy-stub generation below. Strip the query first so it registers under the file's real (unsuffixed) relativePath/query-name, not a duplicate. @@ -680,36 +674,35 @@ export const getVitePlugin = ({ ); } // Without auth nothing executes, and dev-verify sends every execution to the cloud. - if (doAuthenticatedRequest && server.config.mode !== DEV_VERIFY_MODE) { - // As early as a dev server allows, so fewer fs wrappers (e.g. graceful-fs clones) - // predate it. A failure must not stop the server: each execution installs again, failing closed. - try { - installGuards(); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - log.warn( - `Could not install the backend function sandbox guards yet: ${reason}`, - ); - } - if (gracefulFsPredatesGuards()) { - log.warn(GRACEFUL_FS_UNGUARDED_WARNING); - } + const runsLocally = doAuthenticatedRequest && server.config.mode !== DEV_VERIFY_MODE; + const childEntryPath = getLocalExecutionChildPath(); + const moduleRunnerPath = resolveViteModuleRunner(context.buildRoot); + const executeLocally: ChildProcessExecutor | undefined = runsLocally + ? createChildProcessExecutor({ + invokeChannel: server.environments.ssr.hot, + projectRoot: context.buildRoot, + childEntryPath, + moduleRunnerPath, + log, + }) + : undefined; + if (executeLocally) { + // A restart starts the new server before closing this one, so each server ends only its own calls. + const closeServer = server.close.bind(server); + server.close = async () => { + executeLocally.close(); + await closeServer(); + }; // Started here so an unusable Vite is reported at startup; each execution reuses // this load, or retries it after a failure. loadViteParseAst().catch((error: unknown) => { const reason = error instanceof Error ? error.message : String(error); log.warn(`Local execution can't parse backend modules: ${reason}`); }); - const ssrEnvironment = server.environments?.ssr; - if (ssrEnvironment) { - const fetchModule = ssrEnvironment.fetchModule.bind(ssrEnvironment); - ssrEnvironment.fetchModule = exemptFetchFromBlockedScope(fetchModule); - } } - const loadModule = server.ssrLoadModule.bind(server); - // Safe to call before `loadModule` runs anything: collectModuleGraphFromServer primes - // each node itself via `transformRequest`, since `moduleParsed` (production's + // Safe to call before a call's process loads anything: collectModuleGraphFromServer + // primes each node itself via `transformRequest`, since `moduleParsed` (production's // mechanism) is Rollup-build-only and never fires on a real dev server. const getAllowedConnectionIds = async (entryId: string) => { const parseAst = await loadViteParseAst(); @@ -725,7 +718,6 @@ export const getVitePlugin = ({ const isHostAllowed = createViteHostCheck(server.config); const middleware = createDevServerMiddleware( bundler.build, - loadModule, getBackendFunctions, getAllowedConnectionIds, auth, @@ -736,6 +728,7 @@ export const getVitePlugin = ({ server.config.mode, isHostAllowed, bundler.defaultServerConditions, + executeLocally, ); server.middlewares.use((req, res, next) => { revalidateBackendFunctionFile(req); diff --git a/packages/plugins/apps/src/vite/local-execution-child-path.ts b/packages/plugins/apps/src/vite/local-execution-child-path.ts new file mode 100644 index 000000000..cbb9173b8 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-child-path.ts @@ -0,0 +1,10 @@ +// 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 path from 'path'; + +/** Built from `local-execution-child.ts` next to the published bundle. */ +export function getLocalExecutionChildPath(): string { + return path.join(__dirname, './local-execution-child.mjs'); +} diff --git a/packages/plugins/apps/src/vite/local-execution-child.ts b/packages/plugins/apps/src/vite/local-execution-child.ts new file mode 100644 index 000000000..e659748a0 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-child.ts @@ -0,0 +1,228 @@ +// 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. + +/* global globalThis */ + +// Runs one backend function call in its own process, forked by the dev server's local execution. +// Modules come from the dev server over IPC; `$.Actions` calls go back to it for scope checks and credentials. + +import type { Writable } from 'node:stream'; + +import type { ChildMessage, ChildStartConfig, ParentMessage } from './local-execution-protocol'; +import { isParentMessage } from './local-execution-protocol'; +import type { ActionCallArgs } from './local-execution-shared'; +import { + assertJsonRoundTrippable, + assertNoSelfDeclaredScope, + describeError, + isIndexableRecord, + makeActionsProxy, + returnValueSubject, + serializeActionInputs, + validateActionCallShape, +} from './local-execution-shared'; +import { installGuards, runBlocked } from './network-guard'; + +type ModuleRunnerInstance = { import(url: string): Promise> }; +type CreateImportMeta = (modulePath: string) => unknown; +type ModuleRunnerOptions = { + transport: { invoke(payload: unknown): Promise }; + hmr: false; + createImportMeta: CreateImportMeta; +}; +type ModuleRunnerModule = { + ModuleRunner: new (options: ModuleRunnerOptions, evaluator: unknown) => ModuleRunnerInstance; + ESModulesEvaluator: new () => unknown; + createNodeImportMeta: CreateImportMeta; +}; + +type Settle = { resolve(value: unknown): void; reject(error: Error): void }; + +const send = process.send?.bind(process); +let nextRequestId = 0; +const pendingInvokes = new Map(); +const pendingActions = new Map(); + +function sendMessage(message: ChildMessage): Promise { + return new Promise((resolve, reject) => { + if (!send) { + reject(new Error('Local execution child process has no IPC channel.')); + return; + } + send(message, undefined, undefined, (error: Error | null) => + error ? reject(error) : resolve(), + ); + }); +} + +function request( + pending: Map, + id: number, + message: ChildMessage, +): Promise { + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + sendMessage(message).catch((error: Error) => { + pending.delete(id); + reject(error); + }); + }); +} + +function invoke(payload: unknown): Promise { + const id = ++nextRequestId; + return request(pendingInvokes, id, { type: 'invoke', id, payload }); +} + +async function dispatchAction( + actionId: string, + call: Partial, + description: string, +): Promise { + const { inputs, connectionId } = validateActionCallShape(call, description); + const serializedInputs = serializeActionInputs(inputs, description); + assertNoSelfDeclaredScope(serializedInputs, description); + const id = ++nextRequestId; + return request(pendingActions, id, { + type: 'action', + id, + actionId, + description, + inputs: serializedInputs, + ...(connectionId === undefined ? {} : { connectionId }), + }); +} + +function onParentMessage(message: ParentMessage): void { + if (message.type === 'start') { + run(message.config).catch(() => {}); + } else if (message.type === 'invoke-result') { + pendingInvokes.get(message.id)?.resolve(message.response); + pendingInvokes.delete(message.id); + } else if (message.type === 'action-result') { + const settle = pendingActions.get(message.id); + pendingActions.delete(message.id); + if (message.ok) { + settle?.resolve(message.value); + } else { + const error = new Error(message.message); + settle?.reject(error); + } + } +} + +async function registerSdkAdapters( + runner: ModuleRunnerInstance, + config: ChildStartConfig, + $: Record, +): Promise { + if (config.actionCatalogInstalled) { + const actionExecution = await runner.import('@datadog/action-catalog/action-execution'); + const { setExecuteActionImplementation } = actionExecution; + if (typeof setExecuteActionImplementation === 'function') { + setExecuteActionImplementation((actionId: string, actionRequest: unknown) => { + const call = isIndexableRecord(actionRequest) ? actionRequest : {}; + return dispatchAction(actionId, call, `"${actionId}"`); + }); + } + } + if (config.appsBackendInstalled) { + const [jsFunctionWithActions, runtime] = await Promise.all([ + runner.import('@datadog/apps-backend/runtime/jsFunctionWithActions'), + runner.import('@datadog/apps-backend/runtime'), + ]); + const { buildRuntimeFromJsFunctionWithActions } = jsFunctionWithActions; + const { setBackend } = runtime; + if ( + typeof buildRuntimeFromJsFunctionWithActions === 'function' && + typeof setBackend === 'function' + ) { + const backend = buildRuntimeFromJsFunctionWithActions($); + setBackend(backend); + } + } +} + +const writeStdout = process.stdout.write; +const writeStderr = process.stderr.write; + +// A write callback fires once earlier writes are flushed, which a pipe on macOS does asynchronously. +// Uses the original write, and gives up after a while, in case the call replaced or broke it. +function flush(stream: Writable, write: Writable['write'], timeoutMs: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, timeoutMs); + Reflect.apply(write, stream, [ + '', + () => { + clearTimeout(timer); + resolve(); + }, + ]); + }); +} + +async function load(config: ChildStartConfig): Promise { + const { ModuleRunner, ESModulesEvaluator, createNodeImportMeta }: ModuleRunnerModule = + await import(config.moduleRunnerUrl); + // Declared, so top-level code reading `$` sees undefined as in production rather than a ReferenceError. + Reflect.set(globalThis, '$', undefined); + const evaluator = new ESModulesEvaluator(); + // Without it, Vite's runner gives modules an import.meta.resolve that always throws. + const runnerOptions: ModuleRunnerOptions = { + transport: { invoke }, + hmr: false, + createImportMeta: createNodeImportMeta, + }; + const runner = new ModuleRunner(runnerOptions, evaluator); + const mod = await runner.import(config.entryUrl); + const fn = mod[config.functionName]; + if (typeof fn !== 'function') { + throw new Error( + `"${config.functionName}" is not a function exported from ${config.entryPath}`, + ); + } + const $ = { + ...config.runtimeContext, + backendFunctionArgs: config.args, + Actions: makeActionsProxy(dispatchAction), + }; + Reflect.set(globalThis, '$', $); + await registerSdkAdapters(runner, config, $); + await sendMessage({ type: 'loaded' }); + const returned = await fn(...config.args); + const subject = returnValueSubject(config.functionName); + return assertJsonRoundTrippable(returned, subject); +} + +// The guards cover the module's top-level code as well as the call. +function execute(config: ChildStartConfig): Promise { + installGuards(); + return runBlocked(() => load(config)); +} + +async function run(config: ChildStartConfig): Promise { + let outcome: ChildMessage; + try { + const data = await execute(config); + outcome = { type: 'result', data }; + } catch (error) { + outcome = { + type: 'error', + message: describeError(error), + }; + } + const stdoutFlushed = flush(process.stdout, writeStdout, config.flushTimeoutMs); + const stderrFlushed = flush(process.stderr, writeStderr, config.flushTimeoutMs); + await Promise.all([stdoutFlushed, stderrFlushed]); + await sendMessage(outcome); +} + +// A dev server that exits or restarts leaves no call running on its own. +process.on('disconnect', () => process.exit(1)); + +process.on('message', (message: unknown) => { + if (isParentMessage(message)) { + onParentMessage(message); + } +}); diff --git a/packages/plugins/apps/src/vite/local-execution-process.fixtures.ts b/packages/plugins/apps/src/vite/local-execution-process.fixtures.ts new file mode 100644 index 000000000..d20516b5f --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-process.fixtures.ts @@ -0,0 +1,51 @@ +// 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 { build } from 'esbuild'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +let builtChildPath: Promise | undefined; +let outDir: string | undefined; + +// Jest runs the TypeScript sources, but the child process needs the plain ESM file the published build ships. +export function buildLocalExecutionChild(): Promise { + builtChildPath ??= (async () => { + const tmpPrefix = path.join(os.tmpdir(), 'dd-apps-child-'); + outDir = fs.mkdtempSync(tmpPrefix); + const outfile = path.join(outDir, 'local-execution-child.mjs'); + await build({ + entryPoints: [path.join(__dirname, 'local-execution-child.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + outfile, + logLevel: 'silent', + }); + return outfile; + })(); + return builtChildPath; +} + +export function removeLocalExecutionChild(): void { + if (outDir) { + fs.rmSync(outDir, { recursive: true, force: true }); + } + outDir = undefined; + builtChildPath = undefined; +} + +export const FIXTURE_ROOT = path.resolve( + __dirname, + '../../../../tests/src/_jest/fixtures/apps_backend_project', +); + +export const previewRuntimeContext = { + Source: { + initiator: { id: 'preview-initiator', orgId: 'preview-org' }, + runAsUser: { id: 'preview-run-as', orgId: 'preview-org' }, + }, +}; diff --git a/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts b/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts new file mode 100644 index 000000000..e953f0a07 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-process.integration.test.ts @@ -0,0 +1,1324 @@ +// 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 { getVitePlugin } from '@dd/apps-plugin/vite/index'; +import { getContextMock, getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import fs from 'fs'; +import http from 'http'; +import { type ChildProcess, fork } from 'node:child_process'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath, pathToFileURL } from 'url'; +import { + build, + createServer, + defaultServerConditions, + type HotPayload, + type Plugin, + type ViteDevServer, +} from 'vite'; + +import type { BackendFunction } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; + +import { + buildLocalExecutionChild, + FIXTURE_ROOT, + previewRuntimeContext, + removeLocalExecutionChild, +} from './local-execution-process.fixtures'; +import { + CHILD_MAX_OLD_SPACE_MB, + createChildProcessExecutor, + killLocalExecutionChildren, + type ChildProcessExecutorOptions, + type LocalExecutionRequest, + type OutputStream, + resolveViteModuleRunner, +} from './local-execution-process'; +import type { ChildStartConfig } from './local-execution-protocol'; +import { isIndexableRecord } from './local-execution-shared'; +import { + DEFAULT_LONG_POLLING_CONFIG, + deriveActionTimeouts, + type ExecuteAction, + withTimeout, +} from './local-execution'; +import { FS_WRITE_BLOCKED_MESSAGE } from './network-guard'; + +// Spying on the shared core module fails once a sandbox guard makes `fork` non-configurable. +jest.mock('node:child_process', () => { + const actual = jest.requireActual('node:child_process'); + return { ...actual, fork: jest.fn(actual.fork) }; +}); + +const CALL_TIMEOUT_MS = 60_000; + +const fixtureFunction = ( + file: string, + name: string, + allowedConnectionIds: string[] = [], +): BackendFunction => ({ + relativePath: file, + name, + absolutePath: path.join(FIXTURE_ROOT, `${file}.backend.ts`), + allowedConnectionIds, +}); + +type TimedCall = { calls: number; pid: number; startedAt: number; endedAt: number }; + +const isTimedCall = (value: unknown): value is TimedCall => + typeof value === 'object' && + value !== null && + 'calls' in value && + 'pid' in value && + 'startedAt' in value && + 'endedAt' in value && + typeof value.calls === 'number' && + typeof value.pid === 'number' && + typeof value.startedAt === 'number' && + typeof value.endedAt === 'number'; + +const isProcessAlive = (pid: number) => { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +}; + +// A killed child stays visible to `kill(pid, 0)` until it's reaped, which can lag on a busy machine. +const waitUntilGone = async (pid: number, deadline = Date.now() + 5000): Promise => { + if (!isProcessAlive(pid)) { + return true; + } + if (Date.now() > deadline) { + return false; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + return waitUntilGone(pid, deadline); +}; + +const childPidFrom = (debugLines: string[]) => { + const match = debugLines.join('\n').match(/\(pid (\d+),/); + if (!match) { + throw new Error(`No child pid logged in: ${debugLines.join('\n')}`); + } + return Number(match[1]); +}; + +const VIRTUAL_MODULE_ID = 'virtual:dd-child-test'; +// Some plugins give a virtual module an id that looks like a path, with nothing on disk there. +const SLASH_VIRTUAL_MODULE_ID = '/~dd-child-test-virtual.js'; +const virtualModulePlugin: Plugin = { + name: 'dd-child-test-virtual', + resolveId: (id) => { + if (id === VIRTUAL_MODULE_ID) { + return `\0${VIRTUAL_MODULE_ID}`; + } + return id === SLASH_VIRTUAL_MODULE_ID ? id : null; + }, + load: (id) => { + if (id === `\0${VIRTUAL_MODULE_ID}`) { + return 'export const virtualValue = 42;'; + } + return id === SLASH_VIRTUAL_MODULE_ID ? 'export const slashValue = 7;' : null; + }, +}; + +describe('Local execution in a child process', () => { + let server: ViteDevServer; + let childEntryPath: string; + let moduleRunnerPath: string; + + beforeAll(async () => { + childEntryPath = await buildLocalExecutionChild(); + const resolvedRunner = resolveViteModuleRunner(FIXTURE_ROOT); + if (!resolvedRunner) { + throw new Error('Expected the fixture to resolve Vite'); + } + moduleRunnerPath = resolvedRunner; + const appsPlugin: Plugin = { + name: 'dd-apps-test', + ...getVitePlugin({ + bundler: { build, defaultServerConditions }, + context: getContextMock({ buildRoot: FIXTURE_ROOT }), + options: { include: [], tags: [], longPolling: DEFAULT_LONG_POLLING_CONFIG }, + }), + }; + server = await createServer({ + configFile: false, + root: FIXTURE_ROOT, + logLevel: 'silent', + server: { middlewareMode: true, hmr: false }, + plugins: [appsPlugin, virtualModulePlugin], + optimizeDeps: { noDiscovery: true }, + }); + }, CALL_TIMEOUT_MS); + + afterAll(async () => { + await server?.close(); + removeLocalExecutionChild(); + }); + + const makeExecutor = (overrides: Partial = {}) => + createChildProcessExecutor({ + invokeChannel: server.environments.ssr.hot, + projectRoot: FIXTURE_ROOT, + childEntryPath, + moduleRunnerPath, + log: getMockLogger(), + writeOutput: () => {}, + ...overrides, + }); + + const makeRequest = ( + func: BackendFunction, + args: unknown[], + overrides: Partial = {}, + ): LocalExecutionRequest => ({ + func, + args, + executeAction: async () => ({ unused: true }), + getRuntimeContext: async () => previewRuntimeContext, + getAllowedConnectionIds: async () => func.allowedConnectionIds, + timeoutMs: 10_000, + longPolling: DEFAULT_LONG_POLLING_CONFIG, + ...overrides, + }); + + describe('Isolation', () => { + const countCalls = fixtureFunction('childState', 'countCalls'); + + test( + 'Should start each call with fresh module state and globals', + async () => { + const execute = makeExecutor(); + const results: unknown[] = []; + for (let i = 0; i < 3; i++) { + // eslint-disable-next-line no-await-in-loop + results.push((await execute(makeRequest(countCalls, [0]))).data); + } + expect(results).toEqual([ + expect.objectContaining({ calls: 1, globalCalls: 1 }), + expect.objectContaining({ calls: 1, globalCalls: 1 }), + expect.objectContaining({ calls: 1, globalCalls: 1 }), + ]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should run concurrent calls one at a time, each in its own fresh process', + async () => { + const execute = makeExecutor(); + const results = await Promise.all( + [1, 2, 3].map(() => execute(makeRequest(countCalls, [300]))), + ); + + const data = results.map((r) => r.data).filter(isTimedCall); + expect(data).toHaveLength(3); + expect(data.map((d) => d.calls)).toEqual([1, 1, 1]); + expect(new Set(data.map((d) => d.pid)).size).toBe(3); + const byStart = [...data].sort((a, b) => a.startedAt - b.startedAt); + const overlapping = byStart.filter( + (d, i) => i > 0 && d.startedAt < byStart[i - 1].endedAt, + ); + expect(overlapping).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should run each call in its own temp dir, so a .env in the project is not picked up from the working directory', + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childState', 'readWorkingDirectory'), []), + ); + expect(result.data).toEqual({ + cwd: expect.stringContaining('dd-apps-exec-'), + cwdIsTmpdir: true, + }); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should strip the developer environment, keys included, and keep Node's own runtime settings", + async () => { + const previous = { + api: process.env.DD_API_KEY, + app: process.env.DD_APP_KEY, + tz: process.env.TZ, + }; + process.env.DD_API_KEY = 'parent-api-key'; + process.env.DD_APP_KEY = 'parent-app-key'; + process.env.TZ = 'UTC'; + try { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childState', 'readEnvNames'), []), + ); + const allNames = Array.isArray(result.data) ? result.data : []; + const allowed = [ + 'TMPDIR', + 'TZ', + 'NODE_EXTRA_CA_CERTS', + 'SSL_CERT_FILE', + 'SSL_CERT_DIR', + 'SystemRoot', + 'TEMP', + 'TMP', + // macOS adds this one to every process it starts. + '__CF_USER_TEXT_ENCODING', + ]; + expect(allNames.filter((name) => !allowed.includes(name))).toEqual([]); + expect(allNames).toEqual(expect.arrayContaining(['TMPDIR', 'TZ'])); + } finally { + const restore = (name: string, value: string | undefined) => { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + }; + restore('DD_API_KEY', previous.api); + restore('DD_APP_KEY', previous.app); + restore('TZ', previous.tz); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should show top-level code an undefined $, as production does, and the call the real one', + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childLoadTime', 'readDollarAtLoad'), []), + ); + expect(result.data).toEqual({ dollarAtLoad: 'undefined', dollarAtCall: 'object' }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should run a nested backend import and an SDK-backed $.Source read for real', + async () => { + const execute = makeExecutor(); + const viaHelper = await execute( + makeRequest(fixtureFunction('viaHelper', 'usesHelper'), ['via-helper']), + ); + const runtimeUsers = await execute( + makeRequest(fixtureFunction('getRuntimeUsers', 'getRuntimeUsers'), ['label']), + ); + expect(viaHelper.data).toEqual({ value: 'via-helper' }); + expect(runtimeUsers.data).toEqual({ + label: 'label', + executionUser: previewRuntimeContext.Source.runAsUser, + initiatingUser: previewRuntimeContext.Source.initiator, + }); + }, + CALL_TIMEOUT_MS, + ); + }); + + describe('Limits', () => { + test.each([ + { + description: 'a call that never settles', + name: 'waitForever', + }, + { + description: 'a synchronous infinite loop', + name: 'spinForever', + }, + ])( + 'Should kill the child of $description once it times out', + async ({ name }) => { + const debugLines: string[] = []; + const execute = makeExecutor({ + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + const start = Date.now(); + await expect( + execute( + makeRequest(fixtureFunction('childState', name), [], { timeoutMs: 3000 }), + ), + ).rejects.toThrow(`Local execution of "${name}" timed out after 3000ms`); + expect(Date.now() - start).toBeLessThan(10_000); + const pid = childPidFrom(debugLines); + const gone = await waitUntilGone(pid); + expect(gone).toBe(true); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should give loading its own time budget before the call starts its own', + async () => { + const execute = makeExecutor(); + const slowLoad = fixtureFunction('childSlowLoad', 'runAfterSlowLoad'); + await expect( + execute(makeRequest(slowLoad, [0], { timeoutMs: 400 })), + ).rejects.toThrow('Loading "childSlowLoad/runAfterSlowLoad" timed out after 400ms'); + // 1200ms to load plus 4000ms to run passes a 5000ms budget only when each gets its own. + await expect( + execute(makeRequest(slowLoad, [4000], { timeoutMs: 5000 })), + ).resolves.toEqual({ data: 'done' }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should return a result even when the call replaced process.stdout.write, without waiting out the flush timeout', + async () => { + // Long enough that waiting it out would fail the test. + const execute = makeExecutor({ flushTimeoutMs: CALL_TIMEOUT_MS * 2 }); + await expect( + execute(makeRequest(fixtureFunction('childState', 'silenceStdout'), [])), + ).resolves.toEqual({ data: 'quiet' }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should not count a $.Actions call started during loading against the call budget', + async () => { + const execute = makeExecutor(); + const executeAction: ExecuteAction = async (_fqn, inputs) => { + await new Promise((resolve) => setTimeout(resolve, 4000)); + return inputs; + }; + await expect( + execute( + makeRequest(fixtureFunction('childLoadTime', 'awaitEarlyAction'), [], { + executeAction, + timeoutMs: 3000, + }), + ), + ).resolves.toEqual({ data: { early: true } }); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should kill every call's process and remove its temp dir when the dev server exits", + async () => { + const debugLines: string[] = []; + const execute = makeExecutor({ + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + const call = execute( + makeRequest(fixtureFunction('childState', 'spinForever'), [], { + timeoutMs: 30_000, + }), + ); + while (!debugLines.some((line) => line.includes('(pid '))) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + const tmpDir = debugLines.join('\n').match(/temp dir ([^)]+)\)/)?.[1]; + expect(tmpDir && fs.existsSync(tmpDir)).toBe(true); + + killLocalExecutionChildren(); + + expect(tmpDir && fs.existsSync(tmpDir)).toBe(false); + + await expect(call).rejects.toThrow( + 'Local execution of "spinForever" ended before returning a result (signal SIGKILL)', + ); + }, + CALL_TIMEOUT_MS, + ); + + const childErrorCases = [ + { + description: 'still kill a started process whose kill failed', + spawnOptions: {}, + expectedKills: ['SIGKILL', 'SIGKILL'], + }, + { + description: 'stop tracking a process that never started', + spawnOptions: { execPath: path.join(os.tmpdir(), 'dd-apps-missing-node') }, + expectedKills: ['SIGKILL'], + }, + ]; + test.each(childErrorCases)( + "Should $description when its process emits 'error'", + async ({ spawnOptions, expectedKills }) => { + const { fork: realFork } = + jest.requireActual('node:child_process'); + const kills: unknown[] = []; + let spawned: ChildProcess | undefined; + jest.mocked(fork).mockImplementationOnce((modulePath, forkArgs, forkOptions) => { + const child = realFork(modulePath, forkArgs, { + ...forkOptions, + ...spawnOptions, + }); + Object.defineProperty(child, 'kill', { + value: (signal: unknown) => { + kills.push(signal); + return false; + }, + }); + spawned = child; + return child; + }); + const execute = makeExecutor(); + const call = execute( + makeRequest(fixtureFunction('childState', 'waitForever'), [], { + timeoutMs: 30_000, + }), + ); + call.catch(() => {}); + while (!spawned) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + const child = spawned; + try { + if (child.pid !== undefined) { + const killFailure = new Error('kill EPERM'); + child.emit('error', killFailure); + } + await expect(call).rejects.toThrow(); + + killLocalExecutionChildren(); + + expect(kills).toEqual(expectedKills); + } finally { + if (child.pid !== undefined) { + process.kill(child.pid, 'SIGKILL'); + } + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should refuse calls still queued or starting once its executor closes', + async () => { + const debugLines: string[] = []; + const execute = makeExecutor({ + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + let releaseContext: () => void = () => {}; + const contextHeld = new Promise((resolve) => { + releaseContext = () => resolve(previewRuntimeContext); + }); + let markContextRequested: () => void = () => {}; + const contextRequested = new Promise((resolve) => { + markContextRequested = resolve; + }); + const countCalls = fixtureFunction('childState', 'countCalls'); + const starting = execute( + makeRequest(countCalls, [0], { + getRuntimeContext: () => { + markContextRequested(); + return contextHeld; + }, + }), + ); + const queued = execute( + makeRequest(countCalls, [0], { getRuntimeContext: () => contextHeld }), + ); + const settled = Promise.allSettled([starting, queued]); + + // The first call is past its start, waiting on its runtime context, when the server closes. + await contextRequested; + execute.close(); + releaseContext(); + + const refusal = + 'Local execution of "countCalls" didn\'t start: its dev server closed'; + const reasons = (await settled).map((result) => + result.status === 'rejected' ? String(result.reason) : 'fulfilled', + ); + expect(reasons).toEqual([`Error: ${refusal}`, `Error: ${refusal}`]); + expect(debugLines.filter((line) => line.includes('(pid '))).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should kill only its own calls' processes when an executor closes", + async () => { + const debugLines: string[] = []; + const execute = makeExecutor({ + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + const otherExecutor = makeExecutor(); + const call = execute( + makeRequest(fixtureFunction('childState', 'spinForever'), [], { + timeoutMs: 30_000, + }), + ); + const settled = call.then( + () => 'settled', + () => 'settled', + ); + while (!debugLines.some((line) => line.includes('(pid '))) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + otherExecutor.close(); + const stillPending = new Promise((resolve) => setTimeout(resolve, 500, 'pending')); + await expect(Promise.race([settled, stillPending])).resolves.toBe('pending'); + + execute.close(); + await expect(call).rejects.toThrow( + 'Local execution of "spinForever" ended before returning a result (signal SIGKILL)', + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should end a call's process once its IPC channel to the dev server closes", + async () => { + const countCalls = fixtureFunction('childState', 'countCalls'); + const config: ChildStartConfig = { + moduleRunnerUrl: pathToFileURL(moduleRunnerPath).href, + entryUrl: countCalls.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + entryPath: countCalls.absolutePath, + functionName: countCalls.name, + args: [CALL_TIMEOUT_MS], + runtimeContext: previewRuntimeContext, + actionCatalogInstalled: false, + appsBackendInstalled: false, + flushTimeoutMs: 1000, + }; + const child = fork(childEntryPath, [], { + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + try { + let loaded = false; + child.on( + 'message', + async (message: { type: string; id: number; payload: HotPayload }) => { + if (message.type === 'invoke') { + const response = await server.environments.ssr.hot.handleInvoke( + message.payload, + ); + child.send({ type: 'invoke-result', id: message.id, response }); + loaded = true; + } + }, + ); + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.send({ type: 'start', config }); + while (!loaded) { + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => setTimeout(resolve, 20)); + } + await new Promise((resolve) => setTimeout(resolve, 300)); + + child.disconnect(); + + await expect(withTimeout(exited, 5000, 'Child exit')).resolves.toBe(1); + } finally { + child.kill('SIGKILL'); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should report a call that exits before returning', + async () => { + const execute = makeExecutor(); + await expect( + execute(makeRequest(fixtureFunction('childState', 'exitEarly'), [])), + ).rejects.toThrow( + 'Local execution of "exitEarly" ended before returning a result (exit code 3)', + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should report a thrown value that can't be converted to a string, instead of timing out", + async () => { + const execute = makeExecutor(); + const func = fixtureFunction('childState', 'throwUnprintable'); + await expect(execute(makeRequest(func, [], { timeoutMs: 3000 }))).rejects.toThrow( + "A thrown object that can't be converted to a string", + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + `Should report a call that runs past ${CHILD_MAX_OLD_SPACE_MB} MB of heap`, + async () => { + const execute = makeExecutor(); + await expect( + execute( + makeRequest(fixtureFunction('childState', 'allocateUntilOutOfMemory'), [], { + timeoutMs: 30_000, + }), + ), + ).rejects.toThrow( + `Local execution of "allocateUntilOutOfMemory" ran out of memory (${CHILD_MAX_OLD_SPACE_MB} MB)`, + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should not report a call as out of memory because its own output mentions it', + async () => { + const execute = makeExecutor({ writeOutput: () => {} }); + await expect( + execute( + makeRequest(fixtureFunction('childState', 'logOutOfMemoryAndExit'), []), + ), + ).rejects.toThrow( + 'Local execution of "logOutOfMemoryAndExit" ended before returning a result (exit code 1)', + ); + }, + CALL_TIMEOUT_MS, + ); + }); + + describe('Guards', () => { + let fetchServer: http.Server; + let fetchUrl: string; + let projectFile: string; + let sharedTmpFile: string; + + beforeAll(async () => { + fetchServer = http.createServer((_req, res) => res.end('pong')); + await new Promise((resolve) => fetchServer.listen(0, '127.0.0.1', resolve)); + const address = fetchServer.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected the fetch server to listen on a TCP port'); + } + fetchUrl = `http://127.0.0.1:${address.port}/`; + }); + + beforeEach(() => { + const probeName = `sandbox-probe-${process.pid}-${Date.now()}.txt`; + projectFile = path.join(FIXTURE_ROOT, probeName); + sharedTmpFile = path.join('/tmp', probeName); + }); + + afterEach(() => { + fs.rmSync(projectFile, { force: true }); + fs.rmSync(sharedTmpFile, { force: true }); + fs.rmSync(path.join(FIXTURE_ROOT, 'top-level-probe.txt'), { force: true }); + }); + + afterAll(async () => { + await new Promise((resolve) => fetchServer.close(resolve)); + }); + + test( + 'Should refuse project writes, subprocesses and workers with the in-process guards, and allow temp writes and fetch', + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childSandbox', 'runSandboxProbes'), [ + projectFile, + fetchUrl, + sharedTmpFile, + ]), + ); + expect(result.data).toEqual({ + sharedTmpWrite: 'ok: written', + projectWrite: 'refused: EROFS', + tempWrite: 'ok: x', + spawn: 'refused: Spawning a subprocess is not allowed in backend functions.', + worker: 'refused: Spawning a worker thread is not allowed in backend functions.', + fetch: 'ok: 200 pong', + }); + expect(fs.existsSync(projectFile)).toBe(false); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should guard a module's top-level code too", + async () => { + const execute = makeExecutor(); + await expect( + execute( + makeRequest( + fixtureFunction('childTopLevelWrite', 'afterTopLevelWrite'), + [], + ), + ), + ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); + expect(fs.existsSync(path.join(FIXTURE_ROOT, 'top-level-probe.txt'))).toBe(false); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should print each call's debugger URL while debugging", + async () => { + const lines: string[] = []; + const execute = makeExecutor({ + isInspectorActive: () => true, + writeOutput: (_stream, line) => lines.push(line), + }); + await execute(makeRequest(fixtureFunction('childState', 'countCalls'), [0])); + expect(lines.join('\n')).toContain('Debugger listening on ws://127.0.0.1:'); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should not time out a call while debugging, so a breakpoint can hold it', + async () => { + const debugLines: string[] = []; + const execute = makeExecutor({ + isInspectorActive: () => true, + log: getMockLogger({ debug: (text: string) => debugLines.push(text) }), + }); + let settled = false; + const call = execute( + makeRequest(fixtureFunction('childState', 'waitForever'), [], { + timeoutMs: 300, + }), + ).finally(() => { + settled = true; + }); + call.catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 1500)); + expect(settled).toBe(false); + killLocalExecutionChildren(); + await expect(call).rejects.toThrow('ended before returning a result'); + expect(debugLines.join('\n')).toContain('(pid '); + }, + CALL_TIMEOUT_MS, + ); + }); + describe('Module loading over IPC', () => { + const loadOverIpc = fixtureFunction('childSandbox', 'loadOverIpc'); + + test( + "Should load a plugin's virtual module", + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(fixtureFunction('childVirtual', 'readVirtualModule'), []), + ); + expect(result.data).toEqual({ virtualValue: 42, slashValue: 7 }); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should load a module inside the project over the same channel', + async () => { + const execute = makeExecutor(); + const result = await execute( + makeRequest(loadOverIpc, [`${path.join(FIXTURE_ROOT, 'package.json')}?raw`]), + ); + expect(JSON.stringify(result.data)).toContain('apps_backend_project'); + }, + CALL_TIMEOUT_MS, + ); + + describe('import.meta', () => { + let runnerDir = ''; + + beforeAll(() => { + const tmpPrefix = path.join(os.tmpdir(), 'dd-apps-fake-runner-'); + runnerDir = fs.mkdtempSync(tmpPrefix); + }); + + afterAll(() => { + fs.rmSync(runnerDir, { recursive: true, force: true }); + }); + + // Vite 7 and 8's module runner resolves import.meta.resolve only through its + // createImportMeta option; this repo's tests run Vite 6, whose runner has none. + const FAKE_VITE_7_RUNNER = ` +import { pathToFileURL } from 'node:url'; +const createDefaultImportMeta = (modulePath) => ({ + url: pathToFileURL(modulePath).href, + resolve() { + throw new Error('[module runner] "import.meta.resolve" is not supported.'); + }, +}); +export const createNodeImportMeta = async (modulePath) => ({ + ...createDefaultImportMeta(modulePath), + resolve: (specifier) => new URL(specifier, pathToFileURL(modulePath)).href, +}); +export class ESModulesEvaluator {} +export class ModuleRunner { + constructor(options) { + this.options = options; + } + async import(url) { + const createImportMeta = this.options.createImportMeta ?? createDefaultImportMeta; + const meta = await createImportMeta(url.split('?')[0]); + return { resolveSibling: () => meta.resolve('./sibling.js') }; + } +} +`; + + test( + 'Should give a backend function a working import.meta.resolve', + async () => { + const fakeRunnerPath = path.join(runnerDir, 'module-runner.mjs'); + fs.writeFileSync(fakeRunnerPath, FAKE_VITE_7_RUNNER); + const execute = makeExecutor({ moduleRunnerPath: fakeRunnerPath }); + const func = fixtureFunction('childImportMeta', 'resolveSibling'); + const result = await execute(makeRequest(func, [])); + const siblingUrl = String(result.data); + expect(fileURLToPath(siblingUrl)).toBe(path.join(FIXTURE_ROOT, 'sibling.js')); + }, + CALL_TIMEOUT_MS, + ); + }); + }); + + describe('Return values', () => { + const returning = (name: string) => fixtureFunction('childReturnValues', name); + + test.each([ + { name: 'returnCircular', message: /returnCircular.*can't be serialized to JSON/ }, + { name: 'returnBigInt', message: /returnBigInt.*can't be serialized to JSON/ }, + { name: 'returnFunction', message: /returnFunction.*JSON.stringify silently drops/ }, + { name: 'returnMap', message: /returnMap.*silently flattens/ }, + { name: 'returnNestedSet', message: /returnNestedSet.*silently flattens/ }, + { name: 'returnNestedNaN', message: /returnNestedNaN.*silently converts to "null"/ }, + { name: 'returnInfinity', message: /returnInfinity.*silently converts to "null"/ }, + { name: 'returnSymbolKey', message: /returnSymbolKey.*Symbol-keyed property/ }, + { + name: 'returnSymbolKeyViaToJson', + message: /returnSymbolKeyViaToJson.*Symbol-keyed property/, + }, + { + name: 'returnSymbolInArray', + message: /returnSymbolInArray.*symbol inside an array.*silently converts to null/, + }, + { + name: 'returnUndefinedInArray', + message: + /returnUndefinedInArray.*undefined inside an array.*silently converts to null/, + }, + ])( + 'Should reject what $name returns, which JSON would corrupt', + async ({ name, message }) => { + const execute = makeExecutor(); + await expect(execute(makeRequest(returning(name), []))).rejects.toThrow(message); + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { + name: 'returnUndefinedProperties', + data: { status: 'ok' }, + }, + { name: 'returnUndefined', data: undefined }, + { name: 'returnCountingToJson', data: { toJsonCalls: 1 } }, + ])( + 'Should return what $name returns as JSON would send it', + async ({ name, data }) => { + const execute = makeExecutor(); + const result = await execute(makeRequest(returning(name), [])); + expect(result).toEqual({ data }); + }, + CALL_TIMEOUT_MS, + ); + }); + + describe('$.Actions', () => { + const record = () => { + const calls: Array<{ fqn: string; inputs: unknown; connectionId?: string }> = []; + const executeAction: ExecuteAction = async (fqn, inputs, connectionId) => { + calls.push({ fqn, inputs, connectionId }); + return { echoedConnection: connectionId ?? null }; + }; + return { calls, executeAction }; + }; + + test( + 'Should dispatch through the dev server with the connection the function is allowed', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'callHttp', ['conn-a']); + const result = await execute(makeRequest(func, ['conn-a'], { executeAction })); + expect(result.data).toEqual({ echoedConnection: 'conn-a' }); + expect(calls).toEqual([ + { + fqn: 'com.datadoghq.http.request', + inputs: { url: 'https://example.com' }, + connectionId: 'conn-a', + }, + ]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should scope each of two overlapping calls to its own allowed connections', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const funcA = fixtureFunction('childActions', 'callHttp', ['conn-a']); + const funcB = { ...fixtureFunction('childActions', 'callHttp', ['conn-b']) }; + const [aOwn, bOwn, aUsingB, bUsingA] = await Promise.allSettled([ + execute(makeRequest(funcA, ['conn-a', 300], { executeAction })), + execute(makeRequest(funcB, ['conn-b', 300], { executeAction })), + execute(makeRequest(funcA, ['conn-b', 300], { executeAction })), + execute(makeRequest(funcB, ['conn-a', 300], { executeAction })), + ]); + expect(aOwn).toEqual({ + status: 'fulfilled', + value: { data: { echoedConnection: 'conn-a' } }, + }); + expect(bOwn).toEqual({ + status: 'fulfilled', + value: { data: { echoedConnection: 'conn-b' } }, + }); + expect(aUsingB).toEqual({ + status: 'rejected', + reason: new Error( + `Action $.Actions.http.request used connection "conn-b", which is not in this function's allowed connections: [conn-a]`, + ), + }); + expect(bUsingA).toEqual({ + status: 'rejected', + reason: new Error( + `Action $.Actions.http.request used connection "conn-a", which is not in this function's allowed connections: [conn-b]`, + ), + }); + expect(calls.map((c) => c.connectionId).sort()).toEqual(['conn-a', 'conn-b']); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should refuse a forged IPC action request outside the allowed connections', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'forgeActionMessage', ['conn-a']); + const result = await execute(makeRequest(func, ['conn-evil'], { executeAction })); + expect(result.data).toEqual({ + type: 'action-result', + id: 999_999, + ok: false, + message: `Action forged used connection "conn-evil", which is not in this function's allowed connections: [conn-a]`, + }); + expect(calls).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should answer a malformed IPC action request with an error instead of leaving it hanging', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'forgeActionMessage', ['conn-a']); + const result = await execute(makeRequest(func, [null], { executeAction })); + expect(result.data).toEqual({ + type: 'action-result', + id: 999_999, + ok: false, + message: expect.stringContaining('Malformed $.Actions request'), + }); + expect(calls).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should still time out a call that keeps retrying a refused connection', + async () => { + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'retryRefusedConnection', ['conn-a']); + await expect(execute(makeRequest(func, [], { timeoutMs: 2000 }))).rejects.toThrow( + 'Local execution of "retryRefusedConnection" timed out after 2000ms', + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should end a call at the absolute ceiling while its $.Actions call is still in flight', + async () => { + const longPolling = { + ...DEFAULT_LONG_POLLING_CONFIG, + maxRetries: 1, + timeoutMs: 1000, + }; + const { totalExecutionTimeoutMs } = deriveActionTimeouts(longPolling); + const execute = makeExecutor(); + const neverSettles: ExecuteAction = () => new Promise(() => {}); + const func = fixtureFunction('childActions', 'callHttp'); + await expect( + execute(makeRequest(func, [], { executeAction: neverSettles, longPolling })), + ).rejects.toThrow( + `Local execution of "callHttp" exceeded the ${totalExecutionTimeoutMs}ms execution ceiling since its module loaded or its latest $.Actions call started.`, + ); + }, + CALL_TIMEOUT_MS, + ); + + test( + "Should fail with an action's error it can't send back to the call, instead of timing out", + async () => { + const { fork: realFork } = + jest.requireActual('node:child_process'); + const forkMock = jest.mocked(fork); + forkMock.mockImplementation((modulePath, forkArgs, forkOptions) => { + const child = realFork(modulePath, forkArgs, forkOptions); + const relay = child.send.bind(child); + Object.defineProperty(child, 'send', { + value: (message: unknown, callback: unknown) => { + const isActionFailure = + isIndexableRecord(message) && + message.type === 'action-result' && + message.ok === false; + if (isActionFailure) { + throw new Error('IPC write failed'); + } + return Reflect.apply(relay, child, [message, callback]); + }, + }); + return child; + }); + const failingAction: ExecuteAction = async () => { + throw new Error('Slack said no'); + }; + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'callHttp'); + try { + await expect( + execute( + makeRequest(func, [], { + executeAction: failingAction, + timeoutMs: 3000, + }), + ), + ).rejects.toThrow('Slack said no'); + } finally { + forkMock.mockImplementation(realFork); + } + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { + reply: "an action's error", + isDropped: (message: Record) => + message.type === 'action-result' && message.ok === false, + expected: 'Slack said no', + }, + { + reply: 'a module load', + isDropped: (message: Record) => message.type === 'invoke-result', + expected: `Couldn't reply to "callHttp"'s child process: IPC write failed`, + }, + ])( + 'Should fail when sending $reply back to the call reports an error, instead of timing out', + async ({ isDropped, expected }) => { + const { fork: realFork } = + jest.requireActual('node:child_process'); + const forkMock = jest.mocked(fork); + forkMock.mockImplementation((modulePath, forkArgs, forkOptions) => { + const child = realFork(modulePath, forkArgs, forkOptions); + const relay = child.send.bind(child); + Object.defineProperty(child, 'send', { + value: (message: unknown, callback: unknown) => { + if ( + isIndexableRecord(message) && + isDropped(message) && + typeof callback === 'function' + ) { + setImmediate(() => callback(new Error('IPC write failed'))); + return true; + } + return Reflect.apply(relay, child, [message, callback]); + }, + }); + return child; + }); + const failingAction: ExecuteAction = async () => { + throw new Error('Slack said no'); + }; + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'callHttp'); + try { + await expect( + execute( + makeRequest(func, [], { + executeAction: failingAction, + timeoutMs: 3000, + }), + ), + ).rejects.toThrow(expected); + } finally { + forkMock.mockImplementation(realFork); + } + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { reply: 'is sent after the result', isSentLate: true }, + { reply: 'was sent before the result', isSentLate: false }, + ])( + 'Should return a result when replying to an action it left running fails, if the reply $reply', + async ({ isSentLate }) => { + const { fork: realFork } = + jest.requireActual('node:child_process'); + const forkMock = jest.mocked(fork); + let hasReturned = false; + let markReturned = () => {}; + const returned = new Promise((resolve) => { + markReturned = () => { + hasReturned = true; + resolve(); + }; + }); + forkMock.mockImplementation((modulePath, forkArgs, forkOptions) => { + const child = realFork(modulePath, forkArgs, forkOptions); + child.on('message', (message: unknown) => { + if (isIndexableRecord(message) && message.type === 'result') { + markReturned(); + } + }); + const relay = child.send.bind(child); + Object.defineProperty(child, 'send', { + value: (message: unknown, callback: unknown) => { + const isActionReply = + isIndexableRecord(message) && message.type === 'action-result'; + if (isActionReply && hasReturned) { + throw new Error('IPC write failed'); + } + if (isActionReply && typeof callback === 'function') { + const failWrite = () => callback(new Error('IPC write failed')); + returned.then(() => setImmediate(failWrite)); + return true; + } + return Reflect.apply(relay, child, [message, callback]); + }, + }); + return child; + }); + const leftRunning: ExecuteAction = async () => { + if (isSentLate) { + await returned; + } + return { late: true }; + }; + const execute = makeExecutor(); + const func = fixtureFunction('childActions', 'callHttpWithoutAwaiting'); + try { + await expect( + execute(makeRequest(func, [], { executeAction: leftRunning })), + ).resolves.toEqual({ data: 'returned' }); + } finally { + forkMock.mockImplementation(realFork); + } + }, + CALL_TIMEOUT_MS, + ); + + test( + 'Should run a typed action-catalog call through the same scope check', + async () => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + const allowed = fixtureFunction('actionCatalogCall', 'postMessage', ['conn-1']); + const disallowed = fixtureFunction('actionCatalogCall', 'postMessage', []); + await expect(execute(makeRequest(allowed, [], { executeAction }))).resolves.toEqual( + { data: { echoedConnection: 'conn-1' } }, + ); + await expect( + execute(makeRequest(disallowed, [], { executeAction })), + ).rejects.toThrow( + `Action "com.datadoghq.slack.chat.postMessage" used connection "conn-1", which is not in this function's allowed connections: []`, + ); + expect(calls).toEqual([ + { + fqn: 'com.datadoghq.slack.chat.postMessage', + inputs: { text: 'hi' }, + connectionId: 'conn-1', + }, + ]); + }, + CALL_TIMEOUT_MS, + ); + + test.each([ + { + name: 'callHttpWithMap', + message: + 'Inputs to action $.Actions.http.request contains a Map (at "headers"), which JSON.stringify silently flattens to "{}" instead of serializing its entries — use a plain array or object instead.', + }, + { + name: 'callHttpDeclaringScope', + message: + "Action $.Actions.http.request must not declare its own allowedConnectionIds in inputs — this function's own allowlist already governs which connections it can use.", + }, + { + name: 'callHttpDeclaringScopeViaToJson', + message: + "Action $.Actions.http.request must not declare its own allowedConnectionIds in inputs — this function's own allowlist already governs which connections it can use.", + }, + { + name: 'callHttpWithTopLevelToJson', + message: + 'must be a plain object after JSON round-tripping, but a custom toJSON() changed its top-level shape to string', + }, + ])( + 'Should reject $name in the child before it reaches the dev server', + async ({ name, message }) => { + const { calls, executeAction } = record(); + const execute = makeExecutor(); + await expect( + execute( + makeRequest(fixtureFunction('childActions', name), [], { executeAction }), + ), + ).rejects.toThrow(message); + expect(calls).toEqual([]); + }, + CALL_TIMEOUT_MS, + ); + }); + + describe('Logs', () => { + test( + 'Should stream each line as it is written, not after the call ends', + async () => { + const lines: Array<{ stream: OutputStream; line: string; at: number }> = []; + const execute = makeExecutor({ + writeOutput: (stream, line) => lines.push({ stream, line, at: Date.now() }), + }); + const delayMs = 800; + await execute(makeRequest(fixtureFunction('childState', 'countCalls'), [0])); + const result = await execute( + makeRequest(fixtureFunction('childState', 'logLines'), [delayMs]), + ); + const endedAt = Date.now(); + + expect(result.data).toBe('logged'); + expect(lines.map(({ stream, line }) => ({ stream, line }))).toEqual([ + { stream: 'stdout', line: 'first line' }, + { stream: 'stderr', line: 'second line' }, + { stream: 'stdout', line: 'no trailing newline' }, + ]); + expect(endedAt - lines[0].at).toBeGreaterThanOrEqual(delayMs * 0.75); + }, + CALL_TIMEOUT_MS, + ); + }); +}); diff --git a/packages/plugins/apps/src/vite/local-execution-process.test.ts b/packages/plugins/apps/src/vite/local-execution-process.test.ts new file mode 100644 index 000000000..79f4d7b53 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-process.test.ts @@ -0,0 +1,156 @@ +// 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 { getMockLogger } from '@dd/tests/_jest/helpers/mocks'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { PassThrough } from 'stream'; + +import type { BackendFunction } from '../backend/types'; + +import { + createChildProcessExecutor, + forwardLines, + MODULE_RUNNER_UNRESOLVED_MESSAGE, + resolveViteModuleRunner, + YARN_PNP_UNSUPPORTED_MESSAGE, +} from './local-execution-process'; +import { DEFAULT_LONG_POLLING_CONFIG } from './local-execution'; + +describe('Local execution process helpers', () => { + describe('Line forwarding', () => { + test('Should split lines across chunks and flush the last one at the end', () => { + const stream = new PassThrough(); + const lines: string[] = []; + forwardLines(stream, (line) => lines.push(line)); + for (const chunk of ['a', 'b\nc', '\r\n', 'd\ne', 'f']) { + stream.write(chunk); + } + stream.end(); + return new Promise((resolve) => { + stream.on('end', () => { + expect(lines).toEqual(['ab', 'c', 'd', 'ef']); + resolve(); + }); + stream.resume(); + }); + }); + }); + + describe('Unavailable local execution', () => { + const previousPnp = process.versions.pnp; + const func: BackendFunction = { + relativePath: 'src/example', + name: 'example', + absolutePath: '/project/src/example.backend.ts', + allowedConnectionIds: [], + }; + + afterEach(() => { + if (previousPnp === undefined) { + delete process.versions.pnp; + } else { + process.versions.pnp = previousPnp; + } + }); + + test.each([ + { + setup: "a Yarn Plug'n'Play dev server", + pnp: '3', + moduleRunnerPath: '/vite/module-runner.js', + message: YARN_PNP_UNSUPPORTED_MESSAGE, + }, + { + setup: 'no resolvable vite/module-runner', + pnp: undefined, + moduleRunnerPath: undefined, + message: MODULE_RUNNER_UNRESOLVED_MESSAGE, + }, + ])( + 'Should warn once and fail every call, before running it, on $setup', + async ({ pnp, moduleRunnerPath, message }) => { + if (pnp === undefined) { + delete process.versions.pnp; + } else { + process.versions.pnp = pnp; + } + const log = getMockLogger({ warn: jest.fn() }); + const getRuntimeContext = jest.fn(async () => ({})); + const executor = createChildProcessExecutor({ + invokeChannel: { handleInvoke: async () => ({}) }, + projectRoot: '/project', + childEntryPath: '/unused/local-execution-child.mjs', + moduleRunnerPath, + log, + }); + const call = () => + executor({ + func, + args: [], + executeAction: async () => ({}), + getRuntimeContext, + getAllowedConnectionIds: async () => [], + timeoutMs: 1000, + longPolling: DEFAULT_LONG_POLLING_CONFIG, + }); + + await expect(call()).rejects.toThrow(message); + await expect(call()).rejects.toThrow(message); + expect(log.warn).toHaveBeenCalledTimes(1); + expect(log.warn).toHaveBeenCalledWith(message); + expect(getRuntimeContext).not.toHaveBeenCalled(); + }, + ); + }); + + describe('Vite module runner', () => { + let projectsDir = ''; + + beforeAll(() => { + const tmpPrefix = path.join(os.tmpdir(), 'dd-apps-vite-runner-'); + const createdDir = fs.mkdtempSync(tmpPrefix); + projectsDir = fs.realpathSync.native(createdDir); + }); + + afterAll(() => { + fs.rmSync(projectsDir, { recursive: true, force: true }); + }); + + const projectWithVite = (version: string) => { + const projectRoot = path.join(projectsDir, version); + const viteDir = path.join(projectRoot, 'node_modules', 'vite'); + fs.mkdirSync(viteDir, { recursive: true }); + fs.writeFileSync(path.join(projectRoot, 'package.json'), '{}'); + fs.writeFileSync( + path.join(viteDir, 'package.json'), + JSON.stringify({ + name: 'vite', + version, + exports: { + './module-runner': './dist/node/module-runner.js', + './package.json': './package.json', + }, + }), + ); + return { projectRoot, runner: path.join(viteDir, 'dist', 'node', 'module-runner.js') }; + }; + + test.each(['7.0.0', '7.3.7', '8.0.0-beta.1', '8.3.3'])( + 'Should use the module runner of Vite %s', + (version) => { + const { projectRoot, runner } = projectWithVite(version); + expect(resolveViteModuleRunner(projectRoot)).toBe(runner); + }, + ); + + test('Should resolve nothing when Vite exports no module runner', () => { + const { projectRoot } = projectWithVite('7.3.7-no-runner'); + const vitePackageJson = path.join(projectRoot, 'node_modules', 'vite', 'package.json'); + fs.writeFileSync(vitePackageJson, JSON.stringify({ name: 'vite', exports: {} })); + expect(resolveViteModuleRunner(projectRoot)).toBeUndefined(); + }); + }); +}); diff --git a/packages/plugins/apps/src/vite/local-execution-process.ts b/packages/plugins/apps/src/vite/local-execution-process.ts new file mode 100644 index 000000000..79ab95db4 --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-process.ts @@ -0,0 +1,584 @@ +// 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. + +/** Runs each local backend function call in a fresh Node child process, the way production gives each call its own process. */ + +import { readJsonSync, rm, rmSync } from '@dd/core/helpers/fs'; +import type { Logger } from '@dd/core/types'; +import { type ChildProcess, fork } from 'node:child_process'; +import fs from 'node:fs'; +import inspector from 'node:inspector'; +import { createRequire } from 'node:module'; +import os from 'node:os'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import { StringDecoder } from 'node:string_decoder'; +import { pathToFileURL } from 'node:url'; +import type { HotPayload } from 'vite'; + +import { isActionCatalogInstalled, isDatadogAppsBackendInstalled } from '../backend/shared'; +import type { BackendFunction, BackendOutputs } from '../backend/types'; +import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; +import type { LongPollingOptions } from '../types'; + +import type { ChildStartConfig, ParentMessage } from './local-execution-protocol'; +import { describeError, isIndexableRecord, validateActionCall } from './local-execution-shared'; +import type { ExecuteAction, GetRuntimeContext } from './local-execution'; +import { + assertValidRuntimeContext, + deriveActionTimeouts, + enqueue, + localExecutionResolutionContext, + withTimeout, +} from './local-execution'; + +const DEFAULT_FLUSH_TIMEOUT_MS = 1000; +/** Approximates production's 256 MiB per call. */ +export const CHILD_MAX_OLD_SPACE_MB = 256; + +/** The dev server side of Vite's module runner transport: `server.environments.ssr.hot`. */ +export interface InvokeChannel { + handleInvoke(payload: HotPayload): Promise; +} + +export type OutputStream = 'stdout' | 'stderr'; + +export interface LocalExecutionRequest { + func: BackendFunction; + args: unknown[]; + executeAction: ExecuteAction; + getRuntimeContext: GetRuntimeContext; + getAllowedConnectionIds: (entryId: string) => Promise; + timeoutMs: number; + longPolling: Required; +} + +export type LocalExecutor = (request: LocalExecutionRequest) => Promise; +/** `close` ends this executor's calls, for when its dev server closes or restarts. */ +export type ChildProcessExecutor = LocalExecutor & { close: () => void }; + +export interface ChildProcessExecutorOptions { + invokeChannel: InvokeChannel; + projectRoot: string; + childEntryPath: string; + moduleRunnerPath: string | undefined; + log: Logger; + writeOutput?: (stream: OutputStream, line: string) => void; + isInspectorActive?: () => boolean; + /** How long a finished call waits for its output to flush before returning anyway. */ + flushTimeoutMs?: number; +} + +const INVOKE_NAMES = new Set(['fetchModule', 'getBuiltins']); + +type ChildTmpDirs = Map; + +const liveChildren: ChildTmpDirs = new Map(); +let killsChildrenOnExit = false; + +function killChildren(children: ChildTmpDirs): void { + children.forEach((_tmpDir, child) => child.kill('SIGKILL')); + children.forEach((tmpDir) => { + try { + rmSync(tmpDir); + } catch { + // Best effort while the dev server exits; the OS cleans its temp dir eventually. + } + }); +} + +export function killLocalExecutionChildren(): void { + killChildren(liveChildren); +} + +// A child stuck in a synchronous loop never sees its IPC channel close when the dev server exits. +function trackChild(child: ChildProcess, tmpDir: string, ownChildren: ChildTmpDirs): void { + liveChildren.set(child, tmpDir); + ownChildren.set(child, tmpDir); + const untrack = () => { + liveChildren.delete(child); + ownChildren.delete(child); + }; + child.once('close', untrack); + // 'error' also reports a failed kill or send on a child that is still running. + child.once('error', () => { + if (child.pid === undefined) { + untrack(); + } + }); + if (!killsChildrenOnExit) { + killsChildrenOnExit = true; + process.once('exit', killLocalExecutionChildren); + } +} + +const writeToTerminal = (stream: OutputStream, line: string) => { + process[stream].write(`${line}\n`); +}; + +const isDebuggerAttached = () => inspector.url() !== undefined; + +// V8's own fatal error line, so a call's output that mentions memory isn't mistaken for it. +const V8_OUT_OF_MEMORY_LINE = /^FATAL ERROR: .*heap out of memory/; + +export const YARN_PNP_UNSUPPORTED_MESSAGE = + 'Local execution of backend functions needs a node_modules install: set `nodeLinker: node-modules` in .yarnrc.yml, or use `dev:verify` to run them in the cloud.'; + +export const MODULE_RUNNER_UNRESOLVED_MESSAGE = + "Local execution of backend functions couldn't resolve vite/module-runner from the project's Vite."; + +/** Vite's module runner from the project's own Vite, which serves it modules over `handleInvoke`. */ +export function resolveViteModuleRunner(projectRoot: string): string | undefined { + try { + const projectPackageJson = path.join(projectRoot, 'package.json'); + const projectRequire = createRequire(projectPackageJson); + const vitePackageJson = projectRequire.resolve('vite/package.json'); + const { exports } = readJsonSync(vitePackageJson); + const entry: unknown = isIndexableRecord(exports) ? exports['./module-runner'] : undefined; + const importEntry = isIndexableRecord(entry) ? entry.import ?? entry.default : entry; + const target = isIndexableRecord(importEntry) ? importEntry.default : importEntry; + const viteDir = path.dirname(vitePackageJson); + return typeof target === 'string' ? path.join(viteDir, target) : undefined; + } catch { + return undefined; + } +} + +// Node's own TLS and time zone settings, which a corporate proxy or a test may need; never app keys. +const NODE_RUNTIME_ENV = ['NODE_EXTRA_CA_CERTS', 'SSL_CERT_FILE', 'SSL_CERT_DIR', 'TZ']; + +/** Production gives each call a fresh environment without the developer's variables or keys. */ +function buildChildEnv(tmpDir: string): typeof process.env { + const env: typeof process.env = { TMPDIR: tmpDir }; + for (const name of NODE_RUNTIME_ENV) { + if (process.env[name] !== undefined) { + env[name] = process.env[name]; + } + } + if (process.platform === 'win32') { + env.TEMP = tmpDir; + env.TMP = tmpDir; + env.SystemRoot = process.env.SystemRoot; + } + return env; +} + +const LINE_BREAK = /\r\n|\n|\r(?!$)/; + +export function forwardLines(stream: Readable, onLine: (line: string) => void): void { + const decoder = new StringDecoder('utf8'); + let partial = ''; + const take = (text: string) => { + const lines = (partial + text).split(LINE_BREAK); + partial = lines.pop() ?? ''; + for (const line of lines) { + onLine(line); + } + }; + stream.on('data', (chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : decoder.write(chunk); + take(text); + }); + stream.on('end', () => { + const rest = decoder.end(); + take(rest); + const lastLine = partial.endsWith('\r') ? partial.slice(0, -1) : partial; + if (lastLine) { + onLine(lastLine); + } + }); +} + +interface InvokeRequest { + invokeId: string; + name: string; + args: unknown[]; +} + +function invokeRequest(message: Record): InvokeRequest | undefined { + const { payload } = message; + if (!isIndexableRecord(payload) || !isIndexableRecord(payload.data)) { + return undefined; + } + const { id: invokeId, name, data } = payload.data; + const isValid = + typeof invokeId === 'string' && + typeof name === 'string' && + INVOKE_NAMES.has(name) && + Array.isArray(data); + return isValid ? { invokeId, name, args: data } : undefined; +} + +function isActionMessage(message: Record): message is Record & { + actionId: string; + description: string; + inputs: Record; + connectionId?: string; +} { + return ( + typeof message.actionId === 'string' && + typeof message.description === 'string' && + isIndexableRecord(message.inputs) && + !Array.isArray(message.inputs) && + (message.connectionId === undefined || typeof message.connectionId === 'string') + ); +} + +interface ChildRun { + func: BackendFunction; + config: ChildStartConfig; + allowedConnectionIds: string[]; + executeAction: ExecuteAction; + timeoutMs: number; + longPolling: Required; +} + +export function createChildProcessExecutor( + options: ChildProcessExecutorOptions, +): ChildProcessExecutor { + const { + invokeChannel, + projectRoot, + childEntryPath, + moduleRunnerPath, + log, + writeOutput = writeToTerminal, + isInspectorActive = isDebuggerAttached, + flushTimeoutMs = DEFAULT_FLUSH_TIMEOUT_MS, + } = options; + // Each call fails with the reason, while the dev server keeps serving the frontend. + const unavailable = (reason: string): ChildProcessExecutor => { + log.warn(reason); + const refuse: LocalExecutor = async () => { + throw new Error(reason); + }; + return Object.assign(refuse, { close: () => {} }); + }; + // Yarn Plug'n'Play loads packages through `NODE_OPTIONS` hooks, which a call's process doesn't get. + if (process.versions.pnp !== undefined) { + return unavailable(YARN_PNP_UNSUPPORTED_MESSAGE); + } + if (moduleRunnerPath === undefined) { + return unavailable(MODULE_RUNNER_UNRESOLVED_MESSAGE); + } + const moduleRunnerUrl = pathToFileURL(moduleRunnerPath).href; + + const ownChildren: ChildTmpDirs = new Map(); + let closed = false; + const refuseIfClosed = (func: BackendFunction) => { + if (closed) { + throw new Error( + `Local execution of "${func.name}" didn't start: its dev server closed`, + ); + } + }; + + const runChild = async (run: ChildRun): Promise => { + const { func, config, allowedConnectionIds, executeAction, timeoutMs } = run; + // A call still starting when its server closed must not fork on the closed server. + refuseIfClosed(func); + const { actionCallTimeoutMs, totalExecutionTimeoutMs } = deriveActionTimeouts( + run.longPolling, + ); + const systemTmpDir = os.tmpdir(); + const tmpPrefix = path.join(systemTmpDir, 'dd-apps-exec-'); + const createdTmpDir = fs.mkdtempSync(tmpPrefix); + const tmpDir = fs.realpathSync.native(createdTmpDir); + const inspect = isInspectorActive(); + const execArgv = [`--max-old-space-size=${CHILD_MAX_OLD_SPACE_MB}`]; + if (inspect) { + execArgv.push('--inspect=127.0.0.1:0'); + } + const env = buildChildEnv(tmpDir); + let child: ChildProcess; + try { + child = fork(childEntryPath, [], { + // Not the project, so a dependency loading `.env` from the working directory finds none. + cwd: tmpDir, + env, + execArgv, + serialization: 'json', + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + }); + } catch (error) { + rmSync(tmpDir); + throw error; + } + log.debug( + `Executing "${func.name}" in a child process (pid ${child.pid}, temp dir ${tmpDir}) with args`, + ); + trackChild(child, tmpDir, ownChildren); + + let ranOutOfMemory = false; + const writeChildLine = (stream: OutputStream) => (line: string) => { + ranOutOfMemory ||= stream === 'stderr' && V8_OUT_OF_MEMORY_LINE.test(line); + writeOutput(stream, line); + }; + if (child.stdout && child.stderr) { + const writeStdoutLine = writeChildLine('stdout'); + const writeStderrLine = writeChildLine('stderr'); + forwardLines(child.stdout, writeStdoutLine); + forwardLines(child.stderr, writeStderrLine); + } + + const resolutionScope = new Set(); + return new Promise((resolve, reject) => { + let settled = false; + let loaded = false; + let outcome: { data: unknown } | undefined; + let pendingActionCalls = 0; + let loadTimer: ReturnType | undefined; + let hangTimer: ReturnType | undefined; + let absoluteTimer: ReturnType | undefined; + + const finish = (error: Error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(loadTimer); + clearTimeout(hangTimer); + clearTimeout(absoluteTimer); + child.kill('SIGKILL'); + reject(error); + }; + const fail = (message: string) => { + const error = new Error(message); + finish(error); + }; + // A paused breakpoint would otherwise count against the call's time budget. + const isTimed = !inspect; + // Loading gets its own budget before the call's. + const startLoadTimer = () => { + if (!isTimed) { + return; + } + loadTimer = setTimeout(() => { + fail( + `Loading "${func.relativePath}/${func.name}" timed out after ${timeoutMs}ms`, + ); + }, timeoutMs); + }; + const scheduleHangTimer = () => { + clearTimeout(hangTimer); + if (!isTimed || !loaded || pendingActionCalls > 0) { + return; + } + hangTimer = setTimeout(() => { + fail(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`); + }, timeoutMs); + }; + const rearmAbsoluteTimer = () => { + clearTimeout(absoluteTimer); + if (!isTimed || !loaded) { + return; + } + absoluteTimer = setTimeout(() => { + fail( + `Local execution of "${func.name}" exceeded the ${totalExecutionTimeoutMs}ms execution ceiling since its module loaded or its latest $.Actions call started.`, + ); + }, totalExecutionTimeoutMs); + }; + const failToReply = (error: unknown) => { + const errorMessage = describeError(error); + fail(`Couldn't reply to "${func.name}"'s child process: ${errorMessage}`); + }; + // An unsent reply fails the call: the child would otherwise wait for it until it times out. + const reply = (message: ParentMessage, onUnsent = failToReply) => { + if (settled || outcome || !child.connected) { + return; + } + try { + child.send(message, (error) => { + if (error && !settled && !outcome) { + onUnsent(error); + } + }); + } catch (error) { + onUnsent(error); + } + }; + + const inResolutionScope = (work: () => T): T => + localExecutionResolutionContext.run(resolutionScope, work); + + const onInvoke = async (id: number, request: InvokeRequest) => { + const hotPayload: HotPayload = { + type: 'custom', + event: 'vite:invoke', + data: { id: request.invokeId, name: request.name, data: request.args }, + }; + const response = await inResolutionScope(() => + invokeChannel.handleInvoke(hotPayload), + ); + reply({ type: 'invoke-result', id, response }); + }; + + const onAction = async ( + id: number, + message: { + actionId: string; + description: string; + inputs: Record; + connectionId?: string; + }, + ) => { + const replyWithError = (error: unknown) => { + const errorMessage = describeError(error); + const failCall = () => fail(errorMessage); + reply( + { type: 'action-result', id, ok: false, message: errorMessage }, + failCall, + ); + }; + let validated: { + inputs: Record; + connectionId: string | undefined; + }; + // Before the timers, so a refused call can't keep extending the call's time budget. + try { + validated = validateActionCall( + message, + allowedConnectionIds, + message.description, + ); + } catch (error) { + replyWithError(error); + return; + } + const { inputs, connectionId } = validated; + pendingActionCalls += 1; + clearTimeout(hangTimer); + rearmAbsoluteTimer(); + try { + const actionCall = executeAction(message.actionId, inputs, connectionId); + const value = await withTimeout( + actionCall, + actionCallTimeoutMs, + `$.Actions call to "${message.actionId}"`, + ); + reply({ type: 'action-result', id, ok: true, value }); + } catch (error) { + replyWithError(error); + } finally { + pendingActionCalls -= 1; + if (pendingActionCalls === 0 && !settled && !outcome) { + scheduleHangTimer(); + } + } + }; + + child.on('message', (message: unknown) => { + if (settled || !isIndexableRecord(message) || outcome) { + return; + } + const id = message.id; + if (message.type === 'invoke' && typeof id === 'number') { + const replyWithError = (reason: string) => { + const response = { error: { name: 'Error', message: reason } }; + reply({ type: 'invoke-result', id, response }); + }; + const request = invokeRequest(message); + if (!request) { + replyWithError('Malformed module runner request.'); + return; + } + onInvoke(id, request).catch((error: unknown) => { + const reason = describeError(error); + replyWithError(reason); + }); + } else if (message.type === 'action' && typeof id === 'number') { + if (isActionMessage(message)) { + onAction(id, message).catch(() => {}); + } else { + reply({ + type: 'action-result', + id, + ok: false, + message: + 'Malformed $.Actions request: it needs a string actionId, an inputs object and an optional string connectionId.', + }); + } + } else if (message.type === 'loaded' && !loaded) { + loaded = true; + clearTimeout(loadTimer); + scheduleHangTimer(); + rearmAbsoluteTimer(); + } else if (message.type === 'result') { + outcome = { data: message.data }; + clearTimeout(loadTimer); + clearTimeout(hangTimer); + clearTimeout(absoluteTimer); + child.kill('SIGKILL'); + } else if (message.type === 'error') { + const reason = + typeof message.message === 'string' ? message.message : 'Unknown error'; + fail(reason); + } + }); + // A process that fails to start may never emit 'close'. + child.on('error', (error) => { + rm(tmpDir).catch(() => {}); + finish(error); + }); + child.on('close', (code, signal) => { + rm(tmpDir).catch(() => {}); + if (outcome) { + settled = true; + resolve(outcome); + return; + } + const ending = signal ? `signal ${signal}` : `exit code ${code}`; + const reason = ranOutOfMemory + ? `ran out of memory (${CHILD_MAX_OLD_SPACE_MB} MB)` + : `ended before returning a result (${ending})`; + fail(`Local execution of "${func.name}" ${reason}`); + }); + + startLoadTimer(); + reply({ type: 'start', config }); + }); + }; + + const execute: LocalExecutor = (request) => + enqueue(async () => { + const { func, args, executeAction, getRuntimeContext, getAllowedConnectionIds } = + request; + refuseIfClosed(func); + const displayName = `${func.relativePath}/${func.name}`; + const runtimeContext = await getRuntimeContext(); + assertValidRuntimeContext(runtimeContext); + const connectionIdsResolving = getAllowedConnectionIds(func.absolutePath); + const allowedConnectionIds = await withTimeout( + connectionIdsResolving, + request.timeoutMs, + `Resolving allowed connections for "${displayName}"`, + ); + const actionCatalogInstalled = isActionCatalogInstalled(projectRoot); + const appsBackendInstalled = isDatadogAppsBackendInstalled(projectRoot); + return runChild({ + func, + allowedConnectionIds, + executeAction, + timeoutMs: request.timeoutMs, + longPolling: request.longPolling, + config: { + moduleRunnerUrl, + entryUrl: func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, + entryPath: func.absolutePath, + functionName: func.name, + args, + runtimeContext, + actionCatalogInstalled, + appsBackendInstalled, + flushTimeoutMs, + }, + }); + }); + const close = () => { + closed = true; + killChildren(ownChildren); + }; + return Object.assign(execute, { close }); +} diff --git a/packages/plugins/apps/src/vite/local-execution-protocol.ts b/packages/plugins/apps/src/vite/local-execution-protocol.ts new file mode 100644 index 000000000..803948c4c --- /dev/null +++ b/packages/plugins/apps/src/vite/local-execution-protocol.ts @@ -0,0 +1,58 @@ +// 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 { isIndexableRecord } from './local-execution-shared'; + +/** What the dev server sends a local execution child process to start one backend function call. */ +export interface ChildStartConfig { + moduleRunnerUrl: string; + entryUrl: string; + entryPath: string; + functionName: string; + args: unknown[]; + runtimeContext: Record; + actionCatalogInstalled: boolean; + appsBackendInstalled: boolean; + flushTimeoutMs: number; +} + +export type ParentMessage = + | { type: 'start'; config: ChildStartConfig } + | { type: 'invoke-result'; id: number; response: unknown } + | { type: 'action-result'; id: number; ok: true; value: unknown } + | { type: 'action-result'; id: number; ok: false; message: string }; + +export type ChildMessage = + | { type: 'invoke'; id: number; payload: unknown } + | { + type: 'action'; + id: number; + actionId: string; + description: string; + inputs: Record; + connectionId?: string; + } + | { type: 'loaded' } + | { type: 'result'; data: unknown } + | { type: 'error'; message: string }; + +// The child trusts the dev server, so this only tells the message kinds apart. +export function isParentMessage(message: unknown): message is ParentMessage { + if (!isIndexableRecord(message)) { + return false; + } + if (message.type === 'start') { + return isIndexableRecord(message.config); + } + if (message.type === 'invoke-result') { + return typeof message.id === 'number'; + } + if (message.type === 'action-result') { + return ( + typeof message.id === 'number' && + (message.ok === true || (message.ok === false && typeof message.message === 'string')) + ); + } + return false; +} diff --git a/packages/plugins/apps/src/vite/local-execution-shared.ts b/packages/plugins/apps/src/vite/local-execution-shared.ts index 65c69565f..9ba160929 100644 --- a/packages/plugins/apps/src/vite/local-execution-shared.ts +++ b/packages/plugins/apps/src/vite/local-execution-shared.ts @@ -4,7 +4,7 @@ /* global Proxy */ -// Must stay side-effect free: importing it must not install local-execution.ts's `globalThis.$` accessor. +// Side-effect free, so the local execution child process can bundle it. export interface ActionCallArgs { inputs: Record; @@ -18,6 +18,15 @@ export type ActionDispatch = ( actionDescription: string, ) => Promise; +/** Never throws, so reporting an odd thrown value can't itself fail and leave a call waiting. */ +export function describeError(error: unknown): string { + try { + return error instanceof Error ? error.message : String(error); + } catch { + return `A thrown ${typeof error} that can't be converted to a string`; + } +} + /** Narrows an unknown value enough to read named properties off it by key. */ export function isIndexableRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; diff --git a/packages/plugins/apps/src/vite/local-execution.fixtures.ts b/packages/plugins/apps/src/vite/local-execution.fixtures.ts deleted file mode 100644 index ab60111bf..000000000 --- a/packages/plugins/apps/src/vite/local-execution.fixtures.ts +++ /dev/null @@ -1,35 +0,0 @@ -// 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. - -// Not named `*.test.*` so `yarn test:unit` doesn't run it as its own suite. Shared by all three -// local-execution test files, including the spawned-process fixture — spawning isolates the OS -// process, not module resolution, so this import works there too. - -import type { BackendFunction } from '../backend/types'; - -import type { ExecuteAction, GetRuntimeContext, RuntimeContext } from './local-execution'; - -export const func: BackendFunction = { - relativePath: 'src/example', - name: 'example', - absolutePath: '/src/example.backend.ts', - allowedConnectionIds: [], -}; - -export const PROCESS_EXIT_FIXTURE_CODE = 7; - -export const stubExecuteAction: ExecuteAction = async (fqn) => ({ data: null, stub: true, fqn }); - -// A factory, not a shared constant: local-execution.ts spreads this shallowly onto `$`, so a -// reused instance would let one execution's mutation of `$.Source.initiator` leak into the next. -export function makePreviewRuntimeContext(): RuntimeContext { - return { - Source: { - initiator: { id: 'preview-initiator', orgId: 'preview-org' }, - runAsUser: { id: 'preview-run-as', orgId: 'preview-org' }, - }, - }; -} - -export const stubGetRuntimeContext: GetRuntimeContext = async () => makePreviewRuntimeContext(); diff --git a/packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts b/packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts deleted file mode 100644 index 8b9a2d8f8..000000000 --- a/packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts +++ /dev/null @@ -1,46 +0,0 @@ -// 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. - -// Not named `*.test.*` so `yarn test:unit` skips it — resilience.test.ts spawns it as its own Jest -// process instead, since process.exit() here would otherwise kill the parent worker. Jest still -// treats it as a test at runtime, so the eslint-disables below cover rules whose overrides key off -// the filename and don't apply here. - -// eslint-disable-next-line import/no-extraneous-dependencies -- test-only helper -import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; -import fs from 'fs'; - -import { - func, - PROCESS_EXIT_FIXTURE_CODE, - stubExecuteAction, - stubGetRuntimeContext, -} from './local-execution.fixtures'; -import { executeScriptLocally } from './local-execution'; - -// eslint-disable-next-line no-undef -- Jest injects `test` at runtime -test('process.exit fixture', async () => { - // Jest's console.log is buffered in-process and only flushed once a test result is reported — - // process.exit() never lets that happen, so the marker must go straight to the real stdout fd. - // `process.stdout.write` itself is asynchronous when stdout is a pipe (spawnSync's default) on - // POSIX, so process.exit() could still race ahead of it — fs.writeSync bypasses that entirely. - fs.writeSync(1, 'FIXTURE_STARTED\n'); - const resolveModule = moduleResolverFor(func, { - example: () => { - process.exit(PROCESS_EXIT_FIXTURE_CODE); - }, - }); - await executeScriptLocally( - func, - '/project', - [], - stubExecuteAction, - stubGetRuntimeContext, - resolveModule, - mockLogger, - 5000, - ); - // Unreachable if process.exit() truly bypasses cleanup; see note above for why this bypasses console.log. - fs.writeSync(1, 'FIXTURE_CLEANUP_RAN\n'); -}); diff --git a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts b/packages/plugins/apps/src/vite/local-execution.resilience.test.ts deleted file mode 100644 index b796281ec..000000000 --- a/packages/plugins/apps/src/vite/local-execution.resilience.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -// 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. - -/** Confirms known v1 limitations of in-process execution (vs. an isolated child process/thread) — not bugs to fix. */ - -import { mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; -import { - FIXTURE_TEST_TIMEOUT_MS, - spawnJestFixture, -} from '@dd/tests/_jest/helpers/spawnJestFixture'; - -import { - func, - PROCESS_EXIT_FIXTURE_CODE, - stubExecuteAction, - stubGetRuntimeContext, -} from './local-execution.fixtures'; -import { executeScriptLocally } from './local-execution'; - -describe('local-execution resilience (in-process execution known limitations)', () => { - // A real `while (true) {}` would hang this test forever, since nothing — not even the timeout's - // own callback — can run while the event loop is blocked synchronously. This bounded busy-wait - // proves the same point safely: the timeout can't interrupt it, so it settles once the loop ends. - test('Should NOT interrupt a synchronous CPU-bound loop with the current timeout — known, accepted v1 limitation', async () => { - const resolver = moduleResolverFor(func, { - example: () => { - const deadline = Date.now() + 800; - // eslint-disable-next-line no-empty - while (Date.now() < deadline) {} - return 'loop finished on its own'; - }, - }); - - const start = Date.now(); - - const result = await executeScriptLocally( - func, - '/project', - [], - stubExecuteAction, - stubGetRuntimeContext, - resolver, - mockLogger, - 300, - ); - - const elapsedMs = Date.now() - start; - - expect(result).toEqual({ data: 'loop finished on its own' }); - expect(elapsedMs).toBeGreaterThanOrEqual(700); - }); - - // process.exit() would kill this Jest process, so the fixture runs as its own real Jest process — - // proving runScriptLocally's try/finally offers no protection, since exit() acts at the OS level. - // The helper's default --runInBand keeps the fixture out of a worker, so its exit code surfaces. - test( - "Should confirm process.exit() inside the customer function crashes the whole process, bypassing runScriptLocally's own try/finally cleanup — known, real risk, not a safely-contained failure", - () => { - const run = spawnJestFixture('**/local-execution.process-exit.fixture.ts', [], {}); - - // A signal kill leaves status null, so this also rules one out. - if (run.status !== PROCESS_EXIT_FIXTURE_CODE) { - throw new Error(`Unexpected child exit\n${run.output}`); - } - expect(run.stdout).toContain('FIXTURE_STARTED'); - expect(run.stdout).not.toContain('FIXTURE_CLEANUP_RAN'); - }, - FIXTURE_TEST_TIMEOUT_MS, - ); -}); diff --git a/packages/plugins/apps/src/vite/local-execution.test.ts b/packages/plugins/apps/src/vite/local-execution.test.ts deleted file mode 100644 index b838eb8a9..000000000 --- a/packages/plugins/apps/src/vite/local-execution.test.ts +++ /dev/null @@ -1,3251 +0,0 @@ -// 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. - -/* global globalThis, NodeJS */ - -import { rmSync } from '@dd/core/helpers/fs'; -import type { Logger } from '@dd/core/types'; -import { mockLogFn, mockLogger, moduleResolverFor } from '@dd/tests/_jest/helpers/mocks'; -import fs from 'fs'; -import os from 'os'; -import path from 'path'; - -import * as shared from '../backend/shared'; -import type { BackendFunction } from '../backend/types'; -import { LOCAL_EXECUTION_LOAD_SUFFIX } from '../constants'; - -import { - func, - makePreviewRuntimeContext, - stubExecuteAction, - stubGetRuntimeContext, -} from './local-execution.fixtures'; -import type { ExecuteAction, LoadModule } from './local-execution'; -import { - DEFAULT_LONG_POLLING_CONFIG, - DEFAULT_TIMEOUT_MS, - deriveActionTimeouts, - executeScriptLocally as executeScriptLocallyWithRuntimeContext, -} from './local-execution'; -import { makeProbeDirOutsideTmp } from './network-guard.fixtures'; -import { ALREADY_GUARDED, FOREIGN_GUARD_MESSAGE, FS_WRITE_BLOCKED_MESSAGE } from './network-guard'; - -const funcWithConnection: BackendFunction = { ...func, allowedConnectionIds: ['conn-1'] }; - -const TEST_PROJECT_ROOT = '/project'; - -interface TestGlobalDollar { - backendFunctionArgs: unknown[]; - // Left untyped: $.Actions is a Proxy of unbounded, dynamic depth ($.Actions.....(...)), the same shape a real customer's untyped code sees. - Actions: any; - Source: { initiator: { id: string; orgId: string }; runAsUser: { id: string; orgId: string } }; - previewMetadata?: unknown; -} - -/** 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 }).$; -} - -beforeEach(() => { - // Neither optional SDK is installed by default; tests exercising the "installed" path override this. - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(false); - jest.spyOn(shared, 'isDatadogAppsBackendInstalled').mockReturnValue(false); -}); - -/** Keeps the existing test call sites concise while every invocation receives a fresh preview context. */ -function executeScriptLocally( - backendFunction: BackendFunction, - projectRoot: string, - args: unknown[], - executeAction: ExecuteAction, - loadModule: LoadModule, - log: Logger, - timeoutMs: number = DEFAULT_TIMEOUT_MS, - primedEntry?: Record, - longPolling = DEFAULT_LONG_POLLING_CONFIG, -) { - return executeScriptLocallyWithRuntimeContext( - backendFunction, - projectRoot, - args, - executeAction, - stubGetRuntimeContext, - loadModule, - log, - timeoutMs, - primedEntry, - longPolling, - ); -} - -// Per-run directory, so leftover probe files from a regressed guard can't collide across runs or workers. -let probeDir: string; -beforeAll(() => { - probeDir = makeProbeDirOutsideTmp('dd-local-execution-probes-'); -}); -afterAll(() => { - rmSync(probeDir); -}); - -/** A `loadModule` double that resolves the customer's function from a map and rejects anything else with a module-not-found error, matching the common case where neither optional package is installed. */ -function loadModuleReturning(exports: Record): LoadModule { - return moduleResolverFor(func, exports); -} - -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( - func, - TEST_PROJECT_ROOT, - [21], - stubExecuteAction, - loadModuleReturning({ example: (n: number) => n * 2 }), - mockLogger, - ); - expect(result).toEqual({ data: 42 }); - }); - - // Local execution reads the developer's real environment, like any other Node tool, both in - // the function body and while its module loads. - test("Should let the function and its module loading read the dev server's ambient process.env", async () => { - const envKey = 'DD_LOCAL_EXECUTION_AMBIENT_ENV_PROBE'; - process.env[envKey] = 'visible'; - const resolveModule = loadModuleReturning({ example: () => process.env[envKey] }); - const readsDuringLoad: Array = []; - const loadModule: LoadModule = async (...args) => { - readsDuringLoad.push(process.env[envKey]); - return resolveModule(...args); - }; - try { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ); - expect(result).toEqual({ data: 'visible' }); - expect(readsDuringLoad.length).toBeGreaterThan(0); - expect(readsDuringLoad).not.toContain(undefined); - } finally { - delete process.env[envKey]; - } - }); - - test('Should pick up a changed loadModule result on a subsequent call, not a stale cached result', async () => { - const first = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 1 }), - mockLogger, - ); - const second = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 2 }), - mockLogger, - ); - expect(first).toEqual({ data: 1 }); - expect(second).toEqual({ data: 2 }); - }); - - test('Should reject with a clear error when the named export is missing from the loaded module', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ somethingElse: () => 1 }), - mockLogger, - ), - ).rejects.toThrow(`"example" is not a function exported from ${func.absolutePath}`); - }); - - 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) { - // 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' }; - } - 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(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 executeScriptLocallyWithRuntimeContext; - 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, - stubGetRuntimeContext, - 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( - funcWithConnection, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: 'conn-1', - }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.slack.chat.postMessage', - { text: 'hi' }, - 'conn-1', - ); - }); - - 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 () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => testDollar().Actions.foo({ inputs: { text: 'hi' } }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith('com.datadoghq.foo', { text: 'hi' }, undefined); - }); - - test('Should resolve a $.Actions(...) call with no property access to a trailing-dot fqn with no action name segment', async () => { - // Documents current behavior: pathParts is empty at this call site, so `com.datadoghq.${pathParts.join('.')}` yields a malformed fqn rather than being rejected. - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => testDollar().Actions({ inputs: { text: 'hi' } }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith('com.datadoghq.', { text: 'hi' }, undefined); - }); - - test('Should resolve a deeply nested $.Actions.a.b.c.d(...) call to its full dotted fqn', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => testDollar().Actions.a.b.c.d({ inputs: { text: 'hi' } }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.a.b.c.d', - { text: 'hi' }, - undefined, - ); - }); - - test("Should reject a $.Actions call whose connectionId isn't in the function's allowedConnectionIds", async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - connectionId: 'conn-not-allowed', - }), - }), - mockLogger, - ), - ).rejects.toThrow(/not in this function's allowed connections/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - test('Should allow a $.Actions call with no connectionId regardless of allowedConnectionIds', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - }); - - test('Should reject when the action call is missing an inputs field', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => testDollar().Actions.slack.chat.postMessage({}), - }), - mockLogger, - ), - ).rejects.toThrow(/must have an inputs field/); - }); - - // A call with no connectionId skips assertConnectionIdAllowed, so a scope nested in its inputs - // would otherwise reach the destination unvalidated. - test('Should reject an action call that declares its own allowedConnectionIds inside inputs', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.datatransformation.jsFunctionWithActions({ - inputs: { script: 'malicious', allowedConnectionIds: ['not-allowed'] }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/must not declare its own allowedConnectionIds/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // The pre-serialization check above only sees the inputs object as it exists before - // JSON.stringify calls toJSON() — a toJSON() that only introduces allowedConnectionIds in its - // return value would otherwise sail through unnoticed and reach executeAction. - test('Should reject an action call whose inputs introduce allowedConnectionIds only via a custom toJSON()', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.datatransformation.jsFunctionWithActions({ - inputs: { - toJSON: () => ({ - script: 'malicious', - allowedConnectionIds: ['not-allowed'], - }), - }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/must not declare its own allowedConnectionIds/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // `in` matches inherited properties, not just inputs' own key — a polluted Object.prototype - // would otherwise make every subsequent call from every function in the process falsely appear - // to self-declare a scope, rejecting legitimate calls process-wide until restart. - test('Should not reject a legitimate call whose inputs have no own allowedConnectionIds, even when Object.prototype is polluted with that key', async () => { - Reflect.set(Object.prototype, 'allowedConnectionIds', ['hostile']); - try { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.datatransformation.jsFunctionWithActions({ - inputs: { script: 'legitimate' }, - }), - }), - mockLogger, - ); - expect(executeAction).toHaveBeenCalled(); - } finally { - Reflect.deleteProperty(Object.prototype, 'allowedConnectionIds'); - } - }); - - // validateActionCall's own `typeof inputs !== 'object'` check passes an array through - // unchanged (typeof [] === 'object'), but serializeActionInputs's shape check downstream - // rejects it — inputs is semantically a plain object of named parameters, and a caller relying - // on `Record`-shaped inputs must never actually receive an array. - test('Should reject an array as inputs, since inputs is semantically a plain object of named parameters', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ inputs: ['a', 'b'] }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*must be a plain object.*top-level shape to an array/); - }); - - test('Should reject a $.Actions call whose inputs contain a Map, instead of silently sending {} to the destination action', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: new Map([['a', 1]]) }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*silently flattens/); - }); - - test('Should reject a $.Actions call whose inputs contain NaN, instead of silently sending null to the destination action', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { score: NaN }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*silently converts to "null"/); - }); - - // Regression test: production's own JSON serialization of $.Actions inputs already silently - // omits an undefined-valued object property (e.g. `threadId: options?.threadId`) rather than - // erroring — a common optional-field pattern that must behave the same way locally. - test('Should silently omit an undefined-valued object property from $.Actions inputs, matching real JSON.stringify/production behavior', async () => { - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi', threadId: undefined }, - }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.slack.chat.postMessage', - { text: 'hi' }, - undefined, - ); - }); - - // Unlike an object property, JSON.stringify silently converts an array element's undefined to - // null instead of dropping it — real corruption, so this case still needs to be caught. - test('Should reject a $.Actions call whose inputs contain undefined inside an array, instead of silently sending null to the destination action', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { items: ['a', undefined, 'b'] }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*undefined inside an array.*silently converts to null/); - }); - - // A top-level toJSON() can change the round-tripped value's shape entirely (object -> string), - // which the JSON-corruption checks above don't catch — they validate what's inside the value, - // not what type the whole thing ends up being. Callers depend on getting a plain object back. - test('Should reject a $.Actions call whose inputs round-trip to something other than a plain object via a top-level toJSON()', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { toJSON: () => 'not an object' }, - }), - }), - mockLogger, - ), - ).rejects.toThrow(/Inputs to action.*must be a plain object.*top-level shape to string/); - }); - - test('Should reject with the thrown message when the customer function throws synchronously', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - throw new Error('boom'); - }, - }), - mockLogger, - ), - ).rejects.toThrow('boom'); - }); - - // Regression test: the "late failure" log fires only for an execution abandoned after the caller stopped waiting (see the test below) — here the caller is still waiting and gets the error via `rejects.toThrow` above. - test('Should not log a "caller had already stopped waiting" message for an ordinary, timely rejection', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - throw new Error('boom'); - }, - }), - mockLogger, - ), - ).rejects.toThrow('boom'); - - expect(mockLogFn).not.toHaveBeenCalledWith( - expect.stringContaining('already stopped waiting'), - 'debug', - ); - }); - - test('Should reject with the rejection reason when the customer function rejects asynchronously', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => Promise.reject(new Error('async boom')) }), - mockLogger, - ), - ).rejects.toThrow('async boom'); - }); - - test('Should time out a hung async function with an explicit, attributed error', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => new Promise(() => {}) }), - mockLogger, - 50, - ), - ).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; - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - new Promise((_resolve, reject) => { - rejectHung = reject; - }), - }), - mockLogger, - 50, - ), - ).rejects.toThrow(/timed out after 50ms/); - - rejectHung?.(new Error('late failure after caller stopped waiting')); - await new Promise((resolve) => setTimeout(resolve, 0)); - - expect(mockLogFn).toHaveBeenCalledWith( - expect.stringContaining('late failure after caller stopped waiting'), - 'debug', - ); - }); - - // Stands in for makeExecuteActionRemotely's long-poll, which can legitimately outlast a - // short hang-detection timeout — that's network wait, not a hung function. - test('Should not time out while a real $.Actions call is still legitimately in flight, even past the configured timeout', async () => { - const slowExecuteAction: ExecuteAction = () => - new Promise((resolve) => setTimeout(() => resolve({ ok: true }), 80)); - - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - slowExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - }), - mockLogger, - // Shorter than slowExecuteAction's own 80ms. - 50, - ); - - expect(result).toEqual({ data: { ok: true } }); - }); - - // Without a bound on the $.Actions call itself, a stalled request would wedge this - // execution and every request queued behind it via `enqueue` indefinitely. - test('Should eventually time out an in-flight $.Actions call that never settles, and not wedge subsequently queued executions', async () => { - jest.useFakeTimers(); - try { - const neverSettlingExecuteAction: ExecuteAction = () => new Promise(() => {}); - - const hungExecution = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - neverSettlingExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - }), - mockLogger, - 50, - ); - // Enqueued behind hungExecution — proves the stalled $.Actions call doesn't block the - // queue for later executions. - const queuedNext = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 'next' }), - mockLogger, - ); - - const { totalExecutionTimeoutMs } = deriveActionTimeouts(DEFAULT_LONG_POLLING_CONFIG); - const hungAssertion = (async () => { - await expect(hungExecution).rejects.toThrow( - new RegExp( - `exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling`, - ), - ); - })(); - - await jest.runAllTimersAsync(); - await hungAssertion; - - expect(await queuedNext).toEqual({ data: 'next' }); - } finally { - jest.useRealTimers(); - } - }); - - // A fire-and-forget $.Actions call pauses the per-call hang-detection timer for as long as it - // stays in flight (up to the derived per-call ceiling), even though the customer function has - // moved on — the absolute execution ceiling below must still fire well before that. - test('Should eventually time out via an absolute execution ceiling, independent of any $.Actions call still in flight', async () => { - jest.useFakeTimers(); - try { - const neverSettlingExecuteAction: ExecuteAction = () => new Promise(() => {}); - - const execution = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - neverSettlingExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }), - }), - mockLogger, - 50, - ); - - const { totalExecutionTimeoutMs } = deriveActionTimeouts(DEFAULT_LONG_POLLING_CONFIG); - const assertion = (async () => { - await expect(execution).rejects.toThrow( - new RegExp( - `exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling`, - ), - ); - })(); - - await jest.advanceTimersByTimeAsync(totalExecutionTimeoutMs); - await assertion; - } finally { - jest.useRealTimers(); - } - }); - - // The absolute ceiling re-arms on each new $.Actions call — without that, two genuinely - // healthy sequential calls (each individually within bounds) could still sum past a single - // fixed window from execution start. This doesn't weaken the hang protection above, which - // relies on the call never re-arming it at all. - test('Should not reject a function whose sequential $.Actions calls each individually stay within the absolute ceiling but sum past it', async () => { - jest.useFakeTimers(); - try { - const { totalExecutionTimeoutMs } = deriveActionTimeouts(DEFAULT_LONG_POLLING_CONFIG); - const delayedExecuteAction: ExecuteAction = () => - new Promise((resolve) => { - setTimeout(() => resolve('ok'), totalExecutionTimeoutMs - 10); - }); - - const execution = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - delayedExecuteAction, - loadModuleReturning({ - example: async () => { - await testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'first' }, - }); - await testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'second' }, - }); - return 'done'; - }, - }), - mockLogger, - ); - - await jest.advanceTimersByTimeAsync(totalExecutionTimeoutMs * 2); - await expect(execution).resolves.toEqual({ data: 'done' }); - } finally { - jest.useRealTimers(); - } - }); - - test('Should still time out a function that hangs with no $.Actions call in flight, even after an earlier call in the same run completed', async () => { - const executeAction: ExecuteAction = async () => ({ ok: true }); - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: async () => { - await testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }); - // Hangs with no further $.Actions call — the fresh timeout window from the completed call above must still expire normally. - return new Promise(() => {}); - }, - }), - mockLogger, - 50, - ), - ).rejects.toThrow(/timed out after 50ms/); - }); - - test("Should keep a hung function's own late continuation blocked after timeout, while a fresh execution afterward still works normally", async () => { - const probePath = path.join(probeDir, 'late.txt'); - let lateWriteAttempt: Promise | undefined; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - new Promise(() => { - // Scheduled, not awaited, so `example` never settles and the race - // below times out normally — fires after that 50ms timeout, not before. - setTimeout(() => { - lateWriteAttempt = fs.promises.writeFile(probePath, 'data'); - lateWriteAttempt.catch(() => undefined); - }, 100); - }), - }), - mockLogger, - 50, - ), - ).rejects.toThrow(/timed out after 50ms/); - - // Lets the hung function's own delayed continuation fire, well after the timeout above. - await new Promise((resolve) => setTimeout(resolve, 100)); - - // The abandoned continuation's own async chain stays permanently blocked (by design), so - // its late write attempt must still be rejected — an identity check on the guarded - // property can't verify this, since the wrapper never changes identity either way. - expect(lateWriteAttempt).toBeDefined(); - await expect(lateWriteAttempt).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - - // A fresh execution afterward must still work normally — the abandoned scope above must - // not permanently wedge fs/action access for everything that runs after it. - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ inputs: { text: 'hi' } }), - }), - mockLogger, - ); - expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); - }); - - test('Should preserve preview context fields while overriding invocation-owned args and Actions', async () => { - const getRuntimeContext = async () => ({ - ...makePreviewRuntimeContext(), - previewMetadata: { requestId: 'preview-request' }, - backendFunctionArgs: ['remote-placeholder'], - Actions: 'remote-placeholder', - }); - const result = await executeScriptLocallyWithRuntimeContext( - func, - TEST_PROJECT_ROOT, - ['local-argument'], - stubExecuteAction, - getRuntimeContext, - loadModuleReturning({ - example: () => { - const dollar = testDollar(); - return { - args: dollar.backendFunctionArgs, - previewMetadata: dollar.previewMetadata, - actionsIsRemotePlaceholder: dollar.Actions === 'remote-placeholder', - }; - }, - }), - mockLogger, - ); - expect(result).toEqual({ - data: { - args: ['local-argument'], - previewMetadata: { requestId: 'preview-request' }, - actionsIsRemotePlaceholder: false, - }, - }); - }); - - test('Should never expose a credential-shaped field anywhere on $, not just inside $.Source', async () => { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - const CREDENTIAL_SUBSTRINGS = [ - 'token', - 'secret', - 'key', - 'password', - 'credential', - ]; - const hasCredentialName = (key: string) => - CREDENTIAL_SUBSTRINGS.some((substring) => - key.toLowerCase().includes(substring), - ); - // Recurses into every value, but never enumerates Actions itself (a Proxy dispatch - // mechanism, not a data container) — the preview response backing the rest of $ is - // validated only for Source's shape, so nothing else stops an unexpected field - // (present now or added later) from reaching it undetected. - const containsCredentialKey = (value: unknown): boolean => - typeof value === 'object' && - value !== null && - Object.entries(value).some( - ([key, nested]) => - hasCredentialName(key) || containsCredentialKey(nested), - ); - const dollar = testDollar(); - const { Actions: _actions, ...dollarWithoutActions } = dollar; - return ( - Object.keys(globalThis).some(hasCredentialName) || - containsCredentialKey(dollarWithoutActions) - ); - }, - }), - mockLogger, - ); - expect(result).toEqual({ data: false }); - }); - - test('Should populate $.Source with the preview identity, reachable via globalThis.$', async () => { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => testDollar().Source }), - mockLogger, - ); - expect(result).toEqual({ - data: { - initiator: { id: 'preview-initiator', orgId: 'preview-org' }, - runAsUser: { id: 'preview-run-as', orgId: 'preview-org' }, - }, - }); - }); - - test('Should reject malformed preview identity before loading the customer module', async () => { - const loadModule = jest.fn(); - const getRuntimeContext = async () => ({ - Source: { - initiator: { id: 'missing-org' }, - runAsUser: { id: 'run-as', orgId: 'preview-org' }, - }, - }); - - await expect( - executeScriptLocallyWithRuntimeContext( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - getRuntimeContext, - loadModule, - mockLogger, - ), - ).rejects.toThrow( - 'The preview runtime context Source.initiator must have a non-empty string "orgId" field.', - ); - expect(loadModule).not.toHaveBeenCalled(); - }); - - test("Should give each execution its own $.Source object, so one execution mutating it can't corrupt a later execution's identity", async () => { - const first = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - testDollar().Source.initiator.id = 'hacked'; - return 'first done'; - }, - }), - mockLogger, - ); - expect(first).toEqual({ data: 'first done' }); - - const second = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => testDollar().Source }), - mockLogger, - ); - expect(second).toEqual({ - data: { - initiator: { id: 'preview-initiator', orgId: 'preview-org' }, - runAsUser: { id: 'preview-run-as', orgId: 'preview-org' }, - }, - }); - }); - - 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 { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - (globalThis as Record).$ = { reassigned: true }; - return 'done'; - }, - }), - mockLogger, - ); - expect(result).toEqual({ data: 'done' }); - expect(Object.is((globalThis as Record).$, preExisting)).toBe(true); - } finally { - (globalThis as Record).$ = undefined; - } - }); - - 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 { - 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 { - if (originalDescriptor) { - Object.defineProperty(globalThis, '$', originalDescriptor); - } - } - }); - - 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, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 'done' }), - mockLogger, - ); - 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', () => { - test('Should silently skip registration when neither package is installed', async () => { - // Confirms loadModuleReturning's rejection of other specifiers doesn't surface as an execution failure. - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 'fine' }), - mockLogger, - ); - 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(); - }); - - test('Should reuse the cached action-catalog registration across executions that share the same loadModule reference, even when each passes a different primedEntry — matching dev-server.ts, which threads one stable loadModule but a fresh per-request primed module', async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - let actionCatalogLoadCount = 0; - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === '@datadog/action-catalog/action-execution') { - actionCatalogLoadCount += 1; - return { setExecuteActionImplementation: () => {} }; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - const first = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - undefined, - { example: () => 'first' }, - ); - const second = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - undefined, - { example: () => 'second' }, - ); - - expect(first).toEqual({ data: 'first' }); - expect(second).toEqual({ data: 'second' }); - 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) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { example: () => 'unreachable' }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - // A real transform/evaluation failure, not a module-not-found error — must not be swallowed as "not installed". - throw new Error('Unexpected token in action-catalog/action-execution'); - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow('Unexpected token in action-catalog/action-execution'); - }); - - // 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); - 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: () => 'unreachable' }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - return { - setExecuteActionImplementation: ( - impl: (actionId: string, request: unknown) => Promise, - ) => { - registeredImpl = impl; - }, - }; - } - 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; - }; - - 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', - ); - }); - - test("Should reject an action-catalog typed-wrapper call whose connectionId isn't in the function's allowedConnectionIds", 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-not-allowed', - }), - }; - } - 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; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow(/not in this function's allowed connections/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - test('Should reject an action-catalog typed-wrapper call missing an inputs field, same as a raw $.Actions call', 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', { - 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; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow(/must have an inputs field/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // Mirrors the raw $.Actions path's nested-allowedConnectionIds test: validateActionCall's - // check is shared, but the typed-wrapper path reaches it through a different call site. - test('Should reject a typed-wrapper action call that declares its own allowedConnectionIds inside inputs', 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.datatransformation.jsFunctionWithActions', - { - inputs: { - script: 'malicious', - allowedConnectionIds: ['not-allowed'], - }, - }, - ), - }; - } - 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; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow(/must not declare its own allowedConnectionIds/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // Mirrors the raw $.Actions path's toJSON()-introduced-allowedConnectionIds test: the - // typed-wrapper path shares validateActionCall, but reaches it through a different call site. - test('Should reject a typed-wrapper action call whose inputs introduce allowedConnectionIds only via a custom toJSON()', 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.datatransformation.jsFunctionWithActions', - { - inputs: { - toJSON: () => ({ - script: 'malicious', - allowedConnectionIds: ['not-allowed'], - }), - }, - }, - ), - }; - } - 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; - }; - - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ), - ).rejects.toThrow(/must not declare its own allowedConnectionIds/); - expect(executeAction).not.toHaveBeenCalled(); - }); - - // Mirrors the raw $.Actions path's malicious-toJSON() test: the action-catalog typed-wrapper - // path doesn't share code with makeActionsProxy, so it needs the same coverage separately. - test("Should block a malicious toJSON() on an action-catalog typed-wrapper call's request from making a real fs write", async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - let registeredImpl: - | ((actionId: string, request: unknown) => Promise) - | undefined; - const probePath = path.join(probeDir, 'toJSON-catalog.txt'); - let writeAttempt: Promise | undefined; - const maliciousRequest = { - inputs: { - text: 'hi', - toJSON() { - writeAttempt = fs.promises.writeFile(probePath, 'exfiltrated'); - return { text: 'hi' }; - }, - }, - connectionId: 'conn-1', - }; - - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { - example: async () => - registeredImpl?.( - 'com.datadoghq.slack.chat.postMessage', - maliciousRequest, - ), - }; - } - 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, - [], - stubExecuteAction, - loadModule, - mockLogger, - ); - - expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); - expect(writeAttempt).toBeDefined(); - await expect(writeAttempt).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - // 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('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 reject with a clear, attributed error when the result contains a BigInt', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - 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: () => function notSerializable() {} }), - mockLogger, - ), - ).rejects.toThrow(/example.*JSON.stringify silently drops/); - }); - - 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/); - }); - - 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, - [], - stubExecuteAction, - 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, - [], - stubExecuteAction, - loadModuleReturning({ example: () => Infinity }), - mockLogger, - ), - ).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/); - }); - - // A pre-stringify scan of the original value would miss this: toJSON() only runs during - // JSON.stringify itself, so the symbol-keyed object it returns must be checked where the - // replacer actually sees it, not on the value returned from the customer function. - test('Should reject a Symbol-keyed property introduced only by a custom toJSON(), not present on the original value', async () => { - const secretSymbol = Symbol('secret'); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => ({ - status: 'ok', - toJSON: () => ({ replaced: true, [secretSymbol]: 'leaked' }), - }), - }), - mockLogger, - ), - ).rejects.toThrow(/example.*Symbol-keyed property/); - }); - - // Production's own HTTP serialization of a function's return value already silently omits - // an undefined-valued object property rather than erroring, so local execution must match. - test('Should silently omit an undefined nested inside a plain object from the return value, matching production, not just at the top level', async () => { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => ({ status: 'ok', extra: undefined }) }), - mockLogger, - ); - expect(result).toEqual({ data: { status: 'ok' } }); - }); - - test('Should silently omit an undefined at a property literally named the empty string, not mistake it for the JSON root', async () => { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => ({ '': undefined, other: 'ok' }) }), - mockLogger, - ); - expect(result).toEqual({ data: { other: 'ok' } }); - }); - - 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.*symbol inside an array.*silently converts to null/); - }); - - // Unlike an object property, JSON.stringify silently converts an array element's undefined - // to null instead of dropping it — real corruption, so this must still be caught. - test('Should reject an undefined nested inside an array of the return value, instead of silently sending null', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => [1, undefined, 2] }), - mockLogger, - ), - ).rejects.toThrow(/example.*undefined inside an array.*silently converts to null/); - }); - - 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('fs write/subprocess guard', () => { - test('Should reject when the customer function tries a raw fs write', async () => { - const probePath = path.join(probeDir, 'raw-write.txt'); - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const rawFs = require('fs'); - return rawFs.promises.writeFile(probePath, 'data'); - }, - }), - mockLogger, - ), - ).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - test('Should reject when the customer function tries to spawn a subprocess', async () => { - await expect( - executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const child_process = require('child_process'); - return child_process.execSync('curl https://example.com'); - }, - }), - mockLogger, - ), - ).rejects.toThrow(/Spawning a subprocess is not allowed/); - }); - - test('Should still let a real $.Actions call through while the rest of the function is blocked', async () => { - const probePath = path.join(probeDir, 'after-action.txt'); - const executeAction = jest.fn().mockResolvedValue({ ok: true }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModuleReturning({ - example: async () => { - const actionResult = await testDollar().Actions.slack.chat.postMessage({ - inputs: { text: 'hi' }, - }); - // A write right after a $.Actions call must still be blocked. - const blockedWrite = fs.promises.writeFile(probePath, 'data'); - await expect(blockedWrite).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - return actionResult; - }, - }), - mockLogger, - ); - expect(result).toEqual({ data: { ok: true } }); - expect(executeAction).toHaveBeenCalledWith( - 'com.datadoghq.slack.chat.postMessage', - { text: 'hi' }, - undefined, - ); - }); - - test('Should refuse path writes from an execution abandoned by its timeout', async () => { - const workPrefix = path.join(os.tmpdir(), 'dd-local-execution-abandoned-'); - const workDir = fs.mkdtempSync(workPrefix); - const lateFile = path.join(workDir, 'late.txt'); - let lateWriteError: unknown; - let lateWriteDone: (() => void) | undefined; - const lateWriteFinished = new Promise((resolve) => { - lateWriteDone = resolve; - }); - const loadModule = loadModuleReturning({ - example: async () => { - setTimeout(() => { - try { - fs.writeFileSync(lateFile, 'data'); - } catch (err) { - lateWriteError = err; - } - lateWriteDone?.(); - }, 60); - await new Promise(() => {}); - }, - }); - try { - const abandoned = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - 20, - ); - - await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - await lateWriteFinished; - const written = fs.existsSync(lateFile); - - expect(lateWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(written).toBe(false); - } finally { - rmSync(workDir); - } - }); - - test('Should let the function write and read a file under os.tmpdir()', async () => { - const tmpFile = path.join( - os.tmpdir(), - `dd-local-execution-${process.pid}-${Date.now()}.json`, - ); - const loadModule = loadModuleReturning({ - example: async () => { - await fs.promises.writeFile(tmpFile, '{"cached":true}'); - return fs.promises.readFile(tmpFile, 'utf8'); - }, - }); - try { - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - ); - - expect(result).toEqual({ data: '{"cached":true}' }); - } finally { - rmSync(tmpFile); - } - }); - - // The dispatch reaches code the customer can replace, like globalThis.fetch. - test("Should keep the $.Actions dispatch inside the function's blocked scope", async () => { - const dispatchWritePath = path.join(probeDir, 'dispatch-write.txt'); - let dispatchWriteError: unknown; - const executeAction: ExecuteAction = async () => { - try { - fs.writeFileSync(dispatchWritePath, 'cached'); - } catch (err) { - dispatchWriteError = err; - } - return { ok: true }; - }; - const loadModule = loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ inputs: { text: 'hi' } }), - }); - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - executeAction, - loadModule, - mockLogger, - ); - const dispatchWritten = fs.existsSync(dispatchWritePath); - - expect(result).toEqual({ data: { ok: true } }); - expect(dispatchWriteError).toMatchObject({ message: FS_WRITE_BLOCKED_MESSAGE }); - expect(dispatchWritten).toBe(false); - }); - - test('Should block a malicious toJSON() on $.Actions inputs from making a real fs write', async () => { - // toJSON() must be synchronous, so its write attempt can't be awaited there — capture the outcome and assert once the whole execution settles. - const probePath = path.join(probeDir, 'toJSON.txt'); - let writeAttempt: Promise | undefined; - const maliciousInputs = { - text: 'hi', - toJSON() { - writeAttempt = fs.promises.writeFile(probePath, 'exfiltrated'); - return { text: 'hi' }; - }, - }; - - const result = await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ - example: () => - testDollar().Actions.slack.chat.postMessage({ - inputs: maliciousInputs, - }), - }), - mockLogger, - ); - - expect(result).toEqual({ data: { data: null, stub: true, fqn: expect.any(String) } }); - expect(writeAttempt).toBeDefined(); - await expect(writeAttempt).rejects.toThrow(FS_WRITE_BLOCKED_MESSAGE); - }); - - test('Should restore fs write access after execution, for whatever the dev server itself does next', async () => { - await executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModuleReturning({ example: () => 'fine' }), - mockLogger, - ); - const afterPath = path.join(probeDir, 'after.txt'); - fs.writeFileSync(afterPath, 'written'); - const content = fs.readFileSync(afterPath, 'utf8'); - expect(content).toBe('written'); - }); - - // Adapter resolution goes through Vite's own module loader, which can write caches. - test("Should not block fs writes while loading the action-catalog adapter, since that's Vite's own module resolution, not customer code", async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const cachePath = path.join(probeDir, 'vite-dep-cache.json'); - const loadModule: LoadModule = async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return { example: () => 'fine' }; - } - if (specifier === '@datadog/action-catalog/action-execution') { - await fs.promises.writeFile(cachePath, '{}'); - return { setExecuteActionImplementation: () => {} }; - } - 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, - ); - const cacheWritten = fs.existsSync(cachePath); - - expect(result).toEqual({ data: 'fine' }); - expect(cacheWritten).toBe(true); - }); - - test('Should not load the adapter packages for an execution already abandoned while its own module was still loading', async () => { - jest.spyOn(shared, 'isActionCatalogInstalled').mockReturnValue(true); - const requestedSpecifiers: string[] = []; - const loadModule: LoadModule = async (specifier: string) => { - requestedSpecifiers.push(specifier); - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - await new Promise((resolve) => setTimeout(resolve, 60)); - return { example: () => 'fine' }; - } - return { setExecuteActionImplementation: () => {} }; - }; - - const abandoned = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - loadModule, - mockLogger, - 20, - ); - await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - // The zombie logs this once it gives up, so waiting for it means it has passed the registration step. - const zombieGaveUp = () => - mockLogFn.mock.calls.some(([text]) => - String(text).includes('was abandoned after timing out before it could start'), - ); - for (let attempt = 0; attempt < 100 && !zombieGaveUp(); attempt++) { - await new Promise((resolve) => setTimeout(resolve, 10)); - } - const gaveUp = zombieGaveUp(); - - expect(gaveUp).toBe(true); - expect(requestedSpecifiers).toEqual([func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX]); - }); - }); - - describe('loadCustomerModuleEntry', () => { - // The guards must be installed before customer top-level code runs, or it could keep raw - // fs/child_process references. Isolated modules keep an earlier test's install from satisfying it. - test("Should install the guards before evaluating the customer module's top-level code", async () => { - const calls: string[] = []; - - await jest - .isolateModulesAsync(async () => { - jest.doMock('./network-guard', () => ({ - assertNoForeignGuard: () => undefined, - installGuards: () => { - calls.push('guards-installed'); - }, - runBlocked: async (fn: () => Promise) => fn(), - })); - - const { - loadCustomerModuleEntry: isolatedLoadCustomerModuleEntry, - } = require('./local-execution'); - - const loadModule: LoadModule = async () => { - calls.push('customer-module-loaded'); - return { example: () => 'done' }; - }; - - const mod = await isolatedLoadCustomerModuleEntry( - loadModule, - func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, - ); - expect(mod).toEqual({ example: expect.any(Function) }); - }) - .finally(() => { - jest.dontMock('./network-guard'); - }); - - expect(calls).toEqual(['guards-installed', 'customer-module-loaded']); - }); - - test("Should refuse before evaluating the customer module when another release's guard is installed", async () => { - const loadModule = jest.fn, Parameters>(); - - await jest.isolateModulesAsync(async () => { - const isolatedGuard: typeof import('./network-guard') = require('./network-guard'); - const foreignGetter = () => () => 'real'; - Object.defineProperty(foreignGetter, ALREADY_GUARDED, { value: true }); - const target = {}; - Object.defineProperty(target, 'value', { get: foreignGetter, configurable: true }); - isolatedGuard.installGuardedProperty( - target, - 'value', - (getReal: () => unknown) => getReal, - ); - const { - loadCustomerModuleEntry: isolatedLoadCustomerModuleEntry, - } = require('./local-execution'); - - const loading = isolatedLoadCustomerModuleEntry( - loadModule, - func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX, - ); - - await expect(loading).rejects.toThrow(FOREIGN_GUARD_MESSAGE); - }); - - expect(loadModule).not.toHaveBeenCalled(); - }); - }); - - 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); - }); - - // An abandoned execution's loadModule can resolve late, after a newer one is already inside the guards — it must not corrupt the newer state. - test("Should never let an abandoned execution's late-resolving loadModule enter the guards while a newer execution is still inside them", async () => { - const makeLoadModule = (mainDelayMs: number): LoadModule => { - return async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - if (mainDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, mainDelayMs)); - } - return { - example: async () => { - // B's own body: still running when A's slow loadModule resolves, so any state A corrupts on its way in would be visible here. - await new Promise((resolve) => setTimeout(resolve, 200)); - return 'b-result'; - }, - }; - } - const notFoundError: NodeJS.ErrnoException = new Error( - `Cannot find module '${specifier}'`, - ); - notFoundError.code = 'MODULE_NOT_FOUND'; - throw notFoundError; - }; - }; - - // A times out at 20ms, well before its own 150ms-delayed loadModule resolves. - const abandoned = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - makeLoadModule(150), - mockLogger, - 20, - ); - await expect(abandoned).rejects.toThrow(/timed out after 20ms/); - - // B starts as soon as the queue frees, and is still running its own 200ms body when A's loadModule resolves at the ~150ms mark. - const second = executeScriptLocally( - func, - TEST_PROJECT_ROOT, - [], - stubExecuteAction, - makeLoadModule(0), - mockLogger, - ); - - await expect(second).resolves.toEqual({ data: 'b-result' }); - }); - }); -}); diff --git a/packages/plugins/apps/src/vite/local-execution.ts b/packages/plugins/apps/src/vite/local-execution.ts index 441890abf..467b12199 100644 --- a/packages/plugins/apps/src/vite/local-execution.ts +++ b/packages/plugins/apps/src/vite/local-execution.ts @@ -2,31 +2,14 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -/* global Proxy, globalThis */ +/** Contracts and limits local execution shares between the dev server and each call's process. */ -/** Executes a backend function's file directly in-process inside the Vite dev server — a drop-in alternate to dev-server.ts's executeScriptViaDatadog, mirroring its `BackendOutputs` contract. */ - -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'; import type { LongPollingOptions } from '../types'; import { resolveLongPolling } from '../validate'; -import { createEpochGuard } from './execution-epoch'; -import type { ActionCallArgs } from './local-execution-shared'; -import { - assertJsonRoundTrippable, - assertNoSelfDeclaredScope, - isIndexableRecord, - makeActionsProxy, - returnValueSubject, - serializeActionInputs, - validateActionCall, -} from './local-execution-shared'; -import { assertNoForeignGuard, installGuards, runBlocked } from './network-guard'; +import { isIndexableRecord } from './local-execution-shared'; import { getTotalRetryDelayBudgetMs } from './retry-delay'; type RuntimeUser = { @@ -43,91 +26,9 @@ export type RuntimeContext = Record & { }; }; -type BackendGlobals = RuntimeContext & { - backendFunctionArgs: unknown[]; - Actions: unknown; -}; - -/** 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 — 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 "between executions," where the undefined-returning fallback below applies instead). Carries its own mutable box so a top-level `$` write (e.g. `zx/globals`) is scoped to this load, not the shared `globalDollarOutsideExecution` slot a later, unrelated load would also read. */ -const customerModuleLoadContext = new AsyncLocalStorage<{ assigned: boolean; value: unknown }>(); - -/** Scopes vite/index.ts's suffixed-subgraph tracking to one entry's own module-graph traversal — every caller funnels through `loadCustomerModuleEntry`, so scoping here reaches every path uniformly. Without this, a process-wide Set would let a helper module stay marked for the dev server's whole lifetime, so a later unrelated SSR resolution of that helper would inherit the marker and serve real backend code instead of the frontend RPC-proxy stub. */ +/** Scopes vite/index.ts's suffixed-subgraph tracking to one call's module loads, so a later unrelated SSR resolution of a helper never inherits the marker and serves real backend code instead of the frontend proxy stub. */ export const localExecutionResolutionContext = new AsyncLocalStorage>(); -/** Backs `globalThis.$` outside any execution box; 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, - }); -} - -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 global until main() assigns it, so it reads as undefined - // rather than throwing, even though it's a real accessor property here, not absent. - return undefined; - } - return globalDollarOutsideExecution; -} - -function dollarSetter(value: unknown): void { - const box = backendGlobalsContext.getStore(); - if (box) { - box.value = value; - return; - } - const loadBox = customerModuleLoadContext.getStore(); - if (loadBox) { - // Scoped to this load, not globalDollarOutsideExecution — 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; -} - -ensureDollarAccessorInstalled(); - -/** What the stable, once-ever-registered adapters below need to dispatch a call to whichever execution is live on the AsyncLocalStorage stack — kept out of `BackendGlobals` since that's 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(); - function assertRuntimeUser(value: unknown, label: string): asserts value is RuntimeUser { if (!isIndexableRecord(value) || Array.isArray(value)) { throw new Error(`The preview runtime context ${label} must be an object.`); @@ -197,24 +98,6 @@ export function deriveActionTimeouts(longPolling: LongPollingConfig): { }; } -/** Loads a module by specifier, resolved against the customer's own project rather than build-plugins' dependency tree — the dev server passes its Vite instance's `ssrLoadModule` here. */ -export type LoadModule = (specifier: string) => Promise>; - -/** Loads a customer module under the same top-level `$`-scoping `runScriptLocally` uses, for callers (like `executeColdActionLocally`'s priming) that evaluate it before `runScriptLocally`. Its top-level code runs before `runBlocked`, so it isn't guarded; installing first still hands it the guarded fs/child_process functions. */ -export async function loadCustomerModuleEntry( - loadModule: LoadModule, - entrySpecifier: string, -): Promise> { - installGuards(); - // Checked before the customer's top-level code runs, since runBlocked would refuse anyway. - assertNoForeignGuard(); - return localExecutionResolutionContext.run(new Set(), () => - customerModuleLoadContext.run({ assigned: false, value: undefined }, () => - loadModule(entrySpecifier), - ), - ); -} - /** Executes a real `$.Actions.foo.bar(...)` call; the dev server supplies the implementation using its own auth, so this module never holds or sees a credential itself. */ export type ExecuteAction = ( fqn: string, @@ -225,28 +108,10 @@ export type ExecuteAction = ( /** Resolves a fresh serializable `$` snapshot from an authenticated preview execution. */ export type GetRuntimeContext = () => Promise; -/** Shared validate → serialize → dispatch sequence for both $.Actions entry points, so a contract change can't reach one and miss the other. */ -async function invokeAction( - executeAction: ExecuteAction, - actionId: string, - call: Partial, - allowedConnectionIds: string[], - actionDescription: string, -): Promise { - const { inputs, connectionId } = validateActionCall( - call, - allowedConnectionIds, - actionDescription, - ); - const serializedInputs = serializeActionInputs(inputs, actionDescription); - assertNoSelfDeclaredScope(serializedInputs, actionDescription); - return executeAction(actionId, serializedInputs, connectionId); -} - -/** Serializes local executions — a customer function deleting `globalThis.$` mid-flight would otherwise break `$` access for any other execution concurrently in progress (see `ensureDollarAccessorInstalled`). */ +/** Runs local executions one at a time, in arrival order. */ let queueTail: Promise = Promise.resolve(); -function enqueue(run: () => Promise): Promise { +export function enqueue(run: () => Promise): Promise { const result = queueTail.then(run); queueTail = result.then( () => undefined, @@ -255,23 +120,7 @@ function enqueue(run: () => Promise): Promise { return result; } -// Shared wording for the "no longer current" rejection at every abandonment call site. -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, so a - * timed-out `fn()` keeps running (abandoned, not canceled). `isCurrent()` rejects that zombie's - * later dispatch once a newer scope takes over; `concludeIfCurrent()` only clears the generation if - * its own scope is still active, so delayed cleanup can't clobber a newer scope. - */ -const executionEpoch = createEpochGuard(); - -/** Bounds a promise that could otherwise hang forever — a `loadModule` call against a broken/circular graph, or a `$.Actions` call with no deadline of its own. Doesn't cancel the underlying promise, so late side effects can still fire if it eventually settles; see each call site for why that's harmless there. `label` is the full subject of the timeout message (e.g. `` `Loading ${specifier}` ``), not a suffix on a fixed prefix. */ +/** Bounds a promise that could otherwise hang forever, such as a `$.Actions` call with no deadline of its own, without cancelling it. `label` is the full subject of the timeout message. */ export function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -289,455 +138,3 @@ export function withTimeout(promise: Promise, timeoutMs: number, label: st ); }); } - -/** Shared once-ever-registration wrapper for both adapters below: no-ops if uninstalled (re-checked uncached, so a mid-session install is picked up on the next execution), caches by `loadModule` identity (true once-ever for a real dev server's reused `ssrLoadModule`, isolated per test), and evicts a rejection so the next execution retries instead of staying poisoned. */ -function registerOnceIfInstalled( - isInstalled: (projectRoot: string) => boolean, - registrations: WeakMap>, - registerOnce: (loadModule: LoadModule, timeoutMs: number) => Promise, - loadModule: LoadModule, - projectRoot: string, - timeoutMs: number, -): Promise { - if (!isInstalled(projectRoot)) { - return Promise.resolve(); - } - 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. */ -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, - 'Loading @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 : {}; - return invokeAction( - dispatch.executeAction, - actionId, - call, - dispatch.allowedConnectionIds, - `"${actionId}"`, - ); - }); -} - -/** 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 stack, rather than binding to one execution's `$` at registration time. */ -function registerBackendRuntimeIfInstalled( - loadModule: LoadModule, - projectRoot: string, - timeoutMs: number, -): Promise { - 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, - 'Loading @datadog/apps-backend/runtime', - ); - const buildRuntimeFromJsFunctionWithActions = - jsFunctionWithActionsModule.buildRuntimeFromJsFunctionWithActions; - const setBackend = runtimeModule.setBackend; - if ( - typeof buildRuntimeFromJsFunctionWithActions !== 'function' || - typeof setBackend !== 'function' - ) { - return; - } - // 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); -} - -function assertJsonSerializable(result: unknown, func: BackendFunction): unknown { - const subject = returnValueSubject(func.name); - return assertJsonRoundTrippable(result, subject); -} - -/** - * Test-only entry point: exercises `runScriptLocally`'s queue/execution behavior with priming - * already done via `primedEntry`. Production goes through `executeColdActionLocally` instead, which - * primes inside the same `enqueue()` call — kept separate so `primedEntry` keeps its current - * meaning for the tests calling this directly. - */ -export async function executeScriptLocally( - func: BackendFunction, - projectRoot: string, - args: unknown[], - executeAction: ExecuteAction, - getRuntimeContext: GetRuntimeContext, - loadModule: LoadModule, - log: Logger, - timeoutMs: number = DEFAULT_TIMEOUT_MS, - primedEntry?: Record, - longPolling: LongPollingConfig = DEFAULT_LONG_POLLING_CONFIG, -): Promise { - return enqueue(async () => { - const runtimeContext = await getRuntimeContext(); - assertValidRuntimeContext(runtimeContext); - return runScriptLocally( - func, - projectRoot, - args, - executeAction, - runtimeContext, - loadModule, - log, - timeoutMs, - primedEntry, - longPolling, - ); - }); -} - -/** - * Cold-function entry point: collects `allowedConnectionIds`, then primes and runs the entry in - * one `enqueue()` call — priming runs real top-level code, so doing it outside the queue would let - * two cold functions run in parallel. Connection IDs are collected first, executing no code, so a - * banned import is rejected before the entry runs. - */ -export async function executeColdActionLocally( - func: BackendFunction, - projectRoot: string, - args: unknown[], - executeAction: ExecuteAction, - getRuntimeContext: GetRuntimeContext, - loadModule: LoadModule, - getAllowedConnectionIds: (entryId: string) => Promise, - log: Logger, - timeoutMs: number = DEFAULT_TIMEOUT_MS, - longPolling: LongPollingConfig = DEFAULT_LONG_POLLING_CONFIG, -): Promise { - const displayName = `${func.relativePath}/${func.name}`; - return enqueue(async () => { - const runtimeContext = await getRuntimeContext(); - assertValidRuntimeContext(runtimeContext); - // Each step needs its own timeout bound independent of runScriptLocally's, which only - // starts once its body begins. - const connectionIdsPromise = getAllowedConnectionIds(func.absolutePath); - const allowedConnectionIds = await withTimeout( - connectionIdsPromise, - timeoutMs, - `Resolving allowed connections for "${displayName}"`, - ); - const entrySpecifier = func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX; - const primingPromise = loadCustomerModuleEntry(loadModule, entrySpecifier); - const primedEntry = await withTimeout( - primingPromise, - timeoutMs, - `Loading "${displayName}"`, - ); - // Calls runScriptLocally directly, not executeScriptLocally, to avoid enqueueing twice. - return runScriptLocally( - { ...func, allowedConnectionIds }, - projectRoot, - args, - executeAction, - runtimeContext, - loadModule, - log, - timeoutMs, - primedEntry, - longPolling, - ); - }); -} - -async function runScriptLocally( - func: BackendFunction, - projectRoot: string, - args: unknown[], - executeAction: ExecuteAction, - runtimeContext: RuntimeContext, - loadModule: LoadModule, - log: Logger, - timeoutMs: number, - primedEntry: Record | undefined, - longPolling: LongPollingConfig, -): 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`); - - const { actionCallTimeoutMs, totalExecutionTimeoutMs } = deriveActionTimeouts(longPolling); - - // A timed-out execution is abandoned, not canceled — its fn() may keep running, so isCurrent() - // gates both its $.Actions closure and the shared adapters against acting under a stale identity. - const scope = executionEpoch.start(); - - // A long-poll can legitimately outlast timeoutMs — pausing the timer while a call is in flight - // and restarting it once all settle keeps real progress from being penalized while a genuine - // hang still times out normally. - let timer: ReturnType | undefined; - let rejectTimeout: ((error: Error) => void) | undefined; - let pendingActionCalls = 0; - let absoluteTimeoutTimer: ReturnType | undefined; - // Promise.race abandons a hung fn without cancelling it, so this closes its blocked scope instead. - const blockedScopeController = new AbortController(); - const failWithTimeout = (message: string) => { - concludeExecution(); - blockedScopeController.abort(); - const timeoutError = new Error(message); - rejectTimeout?.(timeoutError); - }; - - const scheduleTimeout = () => { - timer = setTimeout(() => { - failWithTimeout(`Local execution of "${func.name}" timed out after ${timeoutMs}ms`); - }, timeoutMs); - }; - - // Re-armed (not just set once) from each new $.Actions call below, so a function making - // several sequential calls — each within its own actionCallTimeoutMs — isn't killed for - // exceeding a ceiling sized for only one of them. - const rearmAbsoluteTimeout = () => { - clearTimeout(absoluteTimeoutTimer); - absoluteTimeoutTimer = setTimeout(() => { - failWithTimeout( - `Local execution of "${func.name}" exceeded the absolute ${totalExecutionTimeoutMs}ms execution ceiling, regardless of any $.Actions call in flight.`, - ); - }, totalExecutionTimeoutMs); - }; - - const guardedExecuteAction: ExecuteAction = async (fqn, inputs, connectionId) => { - if (!scope.isCurrent()) { - // Wording stays conclusion-neutral, not "timed out" — a concluded scope may have - // ended for another reason. - throw abandonedExecutionError(func.name, `run "${fqn}"`); - } - pendingActionCalls += 1; - clearTimeout(timer); - rearmAbsoluteTimeout(); - try { - const actionCallPromise = executeAction(fqn, inputs, connectionId); - return await withTimeout( - actionCallPromise, - actionCallTimeoutMs, - `$.Actions call to "${fqn}"`, - ); - } finally { - pendingActionCalls -= 1; - if (pendingActionCalls === 0 && scope.isCurrent()) { - scheduleTimeout(); - } - } - }; - - const concludeExecution = () => { - scope.concludeIfCurrent(); - }; - - // The preview response is the same context already available to cloud backend code, but it - // belongs to a bootstrap invocation. The platform owns that context's safe-field contract; - // this executor never merges its API/App keys or OAuth token. Target-invocation-owned values - // are always replaced locally. - const $: BackendGlobals = { - ...runtimeContext, - backendFunctionArgs: args, - Actions: makeActionsProxy((fqn, call, actionDescription) => - invokeAction( - guardedExecuteAction, - fqn, - call, - func.allowedConnectionIds, - actionDescription, - ), - ), - }; - - const dispatch: ExecutionDispatch = { - executeAction: guardedExecuteAction, - allowedConnectionIds: func.allowedConnectionIds, - isAbandoned: () => !scope.isCurrent(), - functionName: func.name, - $, - }; - - const run = async (): Promise => { - // Wraps the whole body so a module-load failure also concludes the scope — otherwise - // activeGeneration stays pinned to this execution until the next start() overwrites it. - try { - // Loads the module before installing $ and the SDK bridges, matching production's - // ordering. A caller that already primed this entry passes the resolved module - // directly, keeping loadModule's identity stable for the registration caches below. - const mod = - primedEntry ?? - (await loadCustomerModuleEntry( - 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}`, - ); - } - - // Reinstalls the accessor if a prior execution's code deleted globalThis.$ — only - // closes the gap between executions, not a mid-flight deletion by a concurrent one. - ensureDollarAccessorInstalled(); - - // Scopes globalThis.$ and the dispatch info to this call's own async continuation chain. - return await backendGlobalsContext.run({ value: $ }, () => - executionDispatchContext.run(dispatch, async () => { - const rejectIfAbandoned = () => { - if (!scope.isCurrent()) { - throw new Error( - `Execution of "${func.name}" was abandoned after timing out before it could start.`, - ); - } - }; - // The module load above can already have crossed the timeout. - rejectIfAbandoned(); - // 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]); - // Registration's loadModule() calls can take long enough to cross the timeout, - // and the customer function must never run once abandoned. - rejectIfAbandoned(); - // assertJsonSerializable runs inside runBlocked's callback, not after, since its toJSON()/getter calls must run while access is still blocked. - const data = await runBlocked( - async () => { - const result = await fn(...args); - return assertJsonSerializable(result, func); - }, - { signal: blockedScopeController.signal }, - ); - return { data }; - }), - ); - } finally { - // However this execution ends, mark it concluded so any further dispatch through it — direct or via the shared adapters — is rejected. - concludeExecution(); - } - }; - - const timeout = new Promise((_resolve, reject) => { - rejectTimeout = reject; - scheduleTimeout(); - }); - - rearmAbsoluteTimeout(); - - // Racing the timeout only stops the caller from waiting — run() keeps executing afterward, - // since true cancellation would require terminating a Worker thread. - const runPromise = run(); - // Lets the handler below tell an abandoned rejection from an ordinary one. - let raceSettled = false; - // Nothing awaits runPromise once the timeout wins, so a later rejection would otherwise - // crash as unhandled — logged instead so a slow real failure stays diagnosable. - runPromise.catch((error: unknown) => { - if (!raceSettled) { - return; - } - const message = error instanceof Error ? error.message : String(error); - log.debug(`"${func.name}" failed after its caller had already stopped waiting: ${message}`); - }); - - try { - return await Promise.race([runPromise, timeout]); - } finally { - raceSettled = true; - clearTimeout(timer); - clearTimeout(absoluteTimeoutTimer); - } -} diff --git a/packages/plugins/apps/src/vite/network-guard.test.ts b/packages/plugins/apps/src/vite/network-guard.test.ts index 923dd6618..cae808580 100644 --- a/packages/plugins/apps/src/vite/network-guard.test.ts +++ b/packages/plugins/apps/src/vite/network-guard.test.ts @@ -18,16 +18,13 @@ import worker_threads from 'worker_threads'; import { makeProbeDirOutsideTmp } from './network-guard.fixtures'; import { ALREADY_GUARDED, - exemptFetchFromBlockedScope, FOREIGN_GUARD_MESSAGE, FS_WRITE_BLOCKED_MESSAGE, getSharedContext, - gracefulFsPredatesGuards, guardWorker, installGuardedProperty, installGuards, networkGuardSymbol, - NON_STRING_MODULE_ID_MESSAGE, runBlocked, } from './network-guard'; @@ -2013,37 +2010,6 @@ describe('installGuards', () => { expect(attempts).toEqual(['threw', 'threw']); }); - // graceful-fs publishes this queue on the real fs when it loads, and its clone copies fs's functions. - test.each([ - { marker: true, expected: true }, - { marker: false, expected: false }, - ])( - 'Should report whether graceful-fs patched fs before the guards installed (marker: $marker)', - ({ marker, expected }) => { - const bundlePath = buildGuardBundle('network-guard-graceful-marker.bundle.cjs'); - const probeScript = [ - "const fs = require('fs');", - "if (process.argv[2] === 'true') Object.defineProperty(fs, Symbol.for('graceful-fs.queue'), { get: () => [] });", - 'const guard = require(process.argv[1]);', - 'guard.installGuards();', - 'console.log(JSON.stringify(guard.gracefulFsPredatesGuards()));', - ].join('\n'); - - const output = child_process.execFileSync( - process.execPath, - ['-e', probeScript, bundlePath, String(marker)], - { - encoding: 'utf8', - env: { PATH: process.env.PATH }, - }, - ); - const predates: unknown = JSON.parse(output); - - expect(predates).toBe(expected); - expect(gracefulFsPredatesGuards).toEqual(expect.any(Function)); - }, - ); - // The null-device handle that hands over FileHandle's prototype is closed at install, unawaited. test('Should not crash the process when closing the FileHandle it inspects at install rejects', () => { const bundlePath = buildGuardBundle('network-guard-close-rejects.bundle.cjs'); @@ -2139,43 +2105,6 @@ describe('mixed plugin versions', () => { }); }); -describe('exemptFetchFromBlockedScope', () => { - test("Should run a wrapped module fetch outside the run's blocked scope while the run itself stays blocked", async () => { - const hookOutput = path.join(probeDir, 'fetch-hook.txt'); - const afterFetchPath = path.join(probeDir, 'after-fetch.txt'); - const fetchModule = async (id: string) => { - fs.writeFileSync(hookOutput, id); - return id; - }; - const exemptFetch = exemptFetchFromBlockedScope(fetchModule); - - const fetched = await runBlocked(async () => { - const result = await exemptFetch('/src/lazy.ts'); - expect(() => fs.writeFileSync(afterFetchPath, 'data')).toThrow( - FS_WRITE_BLOCKED_MESSAGE, - ); - return result; - }); - const hookContent = fs.readFileSync(hookOutput, 'utf8'); - const afterFetchWritten = fs.existsSync(afterFetchPath); - - expect(fetched).toBe('/src/lazy.ts'); - expect(hookContent).toBe('/src/lazy.ts'); - expect(afterFetchWritten).toBe(false); - }); - - test('Should reject a non-string module id without calling the real fetch', async () => { - const fetchModule = jest.fn(async (id: string) => id); - const exemptFetch = exemptFetchFromBlockedScope(fetchModule); - const nonStringId: unknown = { toString: () => '/src/lazy.ts' }; - - const fetching = Reflect.apply(exemptFetch, undefined, [nonStringId]); - - await expect(fetching).rejects.toThrow(NON_STRING_MODULE_ID_MESSAGE); - expect(fetchModule).not.toHaveBeenCalled(); - }); -}); - describe('installGuardedProperty resilience', () => { // A wrapper closure over the previous guard (some mocking libraries' pattern, distinct from the // direct-reassignment case the WeakMap handles) would otherwise recurse into itself forever, diff --git a/packages/plugins/apps/src/vite/network-guard.ts b/packages/plugins/apps/src/vite/network-guard.ts index f20770af4..2f2098942 100644 --- a/packages/plugins/apps/src/vite/network-guard.ts +++ b/packages/plugins/apps/src/vite/network-guard.ts @@ -1035,28 +1035,15 @@ function installFsGuards(): void { realTmpDirs ??= captureRealTmpDirs(); } -// graceful-fs publishes its queue on the real fs when it loads; a clone it (or fs-extra) made before -// the install copied fs's original functions, so writes through it skip the guards. -const GRACEFUL_FS_QUEUE = Symbol.for('graceful-fs.queue'); -let gracefulFsLoadedFirst = false; - -export const GRACEFUL_FS_UNGUARDED_WARNING = - 'fs-extra or graceful-fs was loaded before the dev server installed the backend function sandbox guards, so filesystem writes a backend function makes through it are not blocked.'; - -export function gracefulFsPredatesGuards(): boolean { - return gracefulFsLoadedFirst; -} - let guardsInstalled = false; -// Patches process-wide built-ins, so it's called explicitly (by the Vite dev server and before -// each local execution) rather than at import: every bundler that loads this plugin imports this file. +// Patches process-wide built-ins, so a call's process calls it explicitly rather than at import: +// every bundler that loads this plugin imports this file. export function installGuards(): void { if (guardsInstalled) { installPromisesOpenGuard(); startGuardingFileHandles(); return; } - gracefulFsLoadedFirst ||= Object.prototype.hasOwnProperty.call(fs, GRACEFUL_FS_QUEUE); // Each property install is a no-op once done, so a call after a failed attempt retries the rest. installSubprocessGuards(); installFsGuards(); @@ -1070,39 +1057,13 @@ export function installGuards(): void { // Captured at load, since Jest's fake timers replace process.nextTick and would stall a run's close. const { nextTick } = process; -// Runs `fn` outside any run's blocked scope — for work done on a run's behalf that isn't customer -// code, like Vite resolving and transforming a module the run dynamically imports. -export function runOutsideBlockedScope(fn: () => T): T { - return getBlockedContext().exit(fn); -} - -export const NON_STRING_MODULE_ID_MESSAGE = 'Module id must be a string'; - -// Vite and its plugins call methods on the id, so a customer object's own toString or startsWith -// would otherwise run exempt. Vite's methods are async, so a rejection matches how they fail on one. -function runExemptForStringId(id: unknown, call: () => Promise): Promise { - if (typeof id !== 'string') { - const idError = new TypeError(NON_STRING_MODULE_ID_MESSAGE); - return Promise.reject(idError); - } - return runOutsideBlockedScope(call); -} - -// Wraps Vite's module fetch (resolve, load, transform) so project plugins doing that work for a -// run's dynamic import aren't blocked; evaluating the fetched module still runs in the run's scope. -export function exemptFetchFromBlockedScope( - fetchModule: (id: string, ...rest: Rest) => Promise, -): (id: string, ...rest: Rest) => Promise { - return (id, ...rest) => runExemptForStringId(id, () => fetchModule(id, ...rest)); -} - function closeScope(scope: BlockedScope): void { scope.closed = true; deregisterIfIdle(scope); } // Runs `fn` with subprocesses, worker_threads and fs writes outside the OS temp dir blocked; wraps -// the customer's function body in `local-execution.ts`'s `runScriptLocally`. +// a call's module load and function in its own process. // Aborting `signal` closes the scope for a caller that gives up on a `fn` that never settles. export async function runBlocked( fn: () => Promise, diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childActions.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childActions.backend.ts new file mode 100644 index 000000000..67bea56db --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childActions.backend.ts @@ -0,0 +1,62 @@ +// 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. + +declare const $: { Actions: { http: { request(call: unknown): Promise } } }; + +export async function callHttp(connectionId?: string, delayMs = 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return $.Actions.http.request({ inputs: { url: 'https://example.com' }, connectionId }); +} + +export async function callHttpWithoutAwaiting() { + $.Actions.http.request({ inputs: { url: 'https://example.com' } }).catch(() => {}); + return 'returned'; +} + +export async function callHttpWithMap() { + return $.Actions.http.request({ inputs: { headers: new Map([['a', 'b']]) } }); +} + +export async function callHttpDeclaringScope() { + return $.Actions.http.request({ inputs: { allowedConnectionIds: ['other'] } }); +} + +export async function callHttpDeclaringScopeViaToJson() { + const inputs = { url: 'https://example.com', toJSON: () => ({ allowedConnectionIds: ['other'] }) }; + return $.Actions.http.request({ inputs }); +} + +export async function callHttpWithTopLevelToJson() { + return $.Actions.http.request({ inputs: { toJSON: () => 'not an object' } }); +} + +// Sends an action request straight over the IPC channel, skipping $.Actions' own checks. +export async function forgeActionMessage(connectionId: string) { + const id = 999_999; + return new Promise((resolve) => { + process.on('message', (message: { type?: string; id?: number }) => { + if (message?.type === 'action-result' && message.id === id) { + resolve(message); + } + }); + process.send?.({ + type: 'action', + id, + actionId: 'com.datadoghq.http.request', + description: 'forged', + inputs: {}, + connectionId, + }); + }); +} + +export async function retryRefusedConnection() { + for (;;) { + try { + return await $.Actions.http.request({ inputs: {}, connectionId: 'not-allowed' }); + } catch { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + } +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childLoadTime.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childLoadTime.backend.ts new file mode 100644 index 000000000..c9174474e --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childLoadTime.backend.ts @@ -0,0 +1,21 @@ +// 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. + +declare const $: { Actions: { http: { request(call: unknown): Promise } } } | undefined; + +const dollarAtLoad = $ === undefined ? 'undefined' : 'set'; + +let earlyAction: Promise | undefined; +// Fires while the dev server is still registering the SDK adapters, before the call starts. +setTimeout(() => { + earlyAction = $?.Actions.http.request({ inputs: { early: true } }); +}, 0); + +export async function readDollarAtLoad() { + return { dollarAtLoad, dollarAtCall: typeof $ }; +} + +export async function awaitEarlyAction() { + return earlyAction; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childReturnValues.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childReturnValues.backend.ts new file mode 100644 index 000000000..a4b348bee --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childReturnValues.backend.ts @@ -0,0 +1,70 @@ +// 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. + +const secret = Symbol('secret'); + +export async function returnCircular() { + const value: Record = {}; + value.self = value; + return value; +} + +export async function returnBigInt() { + return BigInt(10); +} + +export async function returnFunction() { + return function notSerializable() {}; +} + +export async function returnMap() { + return new Map([['a', 1]]); +} + +export async function returnNestedSet() { + return [1, new Set([1, 2, 3])]; +} + +export async function returnNestedNaN() { + return { score: NaN }; +} + +export async function returnInfinity() { + return Infinity; +} + +export async function returnSymbolKey() { + return { status: 'ok', [secret]: 'leaked' }; +} + +export async function returnSymbolKeyViaToJson() { + return { status: 'ok', toJSON: () => ({ replaced: true, [secret]: 'leaked' }) }; +} + +export async function returnSymbolInArray() { + return [1, Symbol('unsupported')]; +} + +export async function returnUndefinedInArray() { + return [1, undefined, 2]; +} + +export async function returnUndefinedProperties() { + return { status: 'ok', extra: undefined, '': undefined }; +} + +export async function returnUndefined() { + return undefined; +} + +let toJsonCalls = 0; + +export async function returnCountingToJson() { + return { + toJSON() { + toJsonCalls += 1; + return { toJsonCalls }; + }, + }; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts new file mode 100644 index 000000000..b3eb34077 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandbox.backend.ts @@ -0,0 +1,13 @@ +// 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 { forgeInvoke, probeSandbox } from './childSandboxProbes'; + +export async function runSandboxProbes(projectFile: string, fetchUrl: string, sharedTmpFile: string) { + return probeSandbox(projectFile, fetchUrl, sharedTmpFile); +} + +export async function loadOverIpc(url: string, importer?: string) { + return forgeInvoke(url, importer); +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childSandboxProbes.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandboxProbes.ts new file mode 100644 index 000000000..75f989fae --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childSandboxProbes.ts @@ -0,0 +1,75 @@ +// 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 { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { Worker } from 'node:worker_threads'; + +function attempt(run: () => string): string { + try { + return `ok: ${run()}`; + } catch (error) { + if (!(error instanceof Error)) { + return `refused: ${String(error)}`; + } + const code = 'code' in error && typeof error.code === 'string' ? error.code : undefined; + return `refused: ${code ?? error.message}`; + } +} + +export async function probeSandbox(projectFile: string, fetchUrl: string, sharedTmpFile: string) { + const tempFile = path.join(os.tmpdir(), 'sandbox-probe.txt'); + const result: Record = { + sharedTmpWrite: attempt(() => { + fs.writeFileSync(sharedTmpFile, 'x'); + return 'written'; + }), + projectWrite: attempt(() => { + fs.writeFileSync(projectFile, 'x'); + return 'written'; + }), + tempWrite: attempt(() => { + fs.writeFileSync(tempFile, 'x'); + return fs.readFileSync(tempFile, 'utf-8'); + }), + spawn: attempt(() => { + const spawned = spawnSync('echo', ['hi']); + if (spawned.error) { + throw spawned.error; + } + return String(spawned.stdout); + }), + worker: attempt(() => { + const worker = new Worker('setTimeout(() => {}, 10)', { eval: true }); + worker.terminate(); + return 'started'; + }), + }; + try { + const response = await fetch(fetchUrl); + result.fetch = `ok: ${response.status} ${await response.text()}`; + } catch (error) { + result.fetch = `refused: ${error instanceof Error ? error.message : String(error)}`; + } + return result; +} + +// Asks the dev server for a module straight over the IPC channel, skipping the module runner. +export function forgeInvoke(url: string, importer?: string) { + const id = 999_998; + return new Promise((resolve) => { + process.on('message', (message: { type?: string; id?: number; response?: unknown }) => { + if (message?.type === 'invoke-result' && message.id === id) { + resolve(message.response); + } + }); + process.send?.({ + type: 'invoke', + id, + payload: { type: 'custom', event: 'vite:invoke', data: { id: 'send', name: 'fetchModule', data: [url, importer, {}] } }, + }); + }); +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childSlowLoad.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childSlowLoad.backend.ts new file mode 100644 index 000000000..897d63afa --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childSlowLoad.backend.ts @@ -0,0 +1,10 @@ +// 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. + +await new Promise((resolve) => setTimeout(resolve, 1200)); + +export async function runAfterSlowLoad(delayMs: number) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return 'done'; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childState.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childState.backend.ts new file mode 100644 index 000000000..c1257d233 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childState.backend.ts @@ -0,0 +1,67 @@ +// 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. + +declare global { + var childStateCalls: number | undefined; +} + +let calls = 0; + +export async function countCalls(delayMs = 0) { + const startedAt = Date.now(); + calls += 1; + globalThis.childStateCalls = (globalThis.childStateCalls ?? 0) + 1; + await new Promise((resolve) => setTimeout(resolve, delayMs)); + return { calls, globalCalls: globalThis.childStateCalls, pid: process.pid, startedAt, endedAt: Date.now() }; +} + +export async function waitForever() { + await new Promise(() => {}); +} + +export function spinForever() { + // eslint-disable-next-line no-empty + for (;;) {} +} + +export async function logLines(delayMs: number) { + console.log('first line'); + await new Promise((resolve) => setTimeout(resolve, delayMs)); + console.error('second line'); + process.stdout.write('no trailing newline'); + return 'logged'; +} + +export async function readEnvNames() { + return Object.keys(process.env).sort(); +} + +export async function exitEarly() { + process.exit(3); +} + +export async function throwUnprintable() { + throw Object.create(null); +} + +export async function logOutOfMemoryAndExit() { + console.error('retrying: heap out of memory upstream'); + process.exit(1); +} + +export async function allocateUntilOutOfMemory() { + const chunks: string[][] = []; + for (;;) { + chunks.push(new Array(1_000_000).fill('x')); + } +} + +export async function silenceStdout() { + process.stdout.write = () => true; + return 'quiet'; +} + +export async function readWorkingDirectory() { + return { cwd: process.cwd(), cwdIsTmpdir: process.cwd() === process.env.TMPDIR }; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWrite.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWrite.backend.ts new file mode 100644 index 000000000..8039464f9 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWrite.backend.ts @@ -0,0 +1,9 @@ +// 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 { writeAtLoad } from './childTopLevelWriteHelper'; + +export async function afterTopLevelWrite() { + return writeAtLoad; +} diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWriteHelper.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWriteHelper.ts new file mode 100644 index 000000000..8e1235582 --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childTopLevelWriteHelper.ts @@ -0,0 +1,12 @@ +// 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 fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Runs while the module loads, before the backend function is called. +fs.writeFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'top-level-probe.txt'), 'x'); + +export const writeAtLoad = 'written'; diff --git a/packages/tests/src/_jest/fixtures/apps_backend_project/childVirtual.backend.ts b/packages/tests/src/_jest/fixtures/apps_backend_project/childVirtual.backend.ts new file mode 100644 index 000000000..90c98478b --- /dev/null +++ b/packages/tests/src/_jest/fixtures/apps_backend_project/childVirtual.backend.ts @@ -0,0 +1,11 @@ +// 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. + +// Only the child-process test server defines these modules. +import { virtualValue } from 'virtual:dd-child-test'; +import { slashValue } from '/~dd-child-test-virtual.js'; + +export async function readVirtualModule() { + return { virtualValue, slashValue }; +} diff --git a/packages/tests/src/_jest/helpers/mocks.ts b/packages/tests/src/_jest/helpers/mocks.ts index 6a51a4f57..48b596f6c 100644 --- a/packages/tests/src/_jest/helpers/mocks.ts +++ b/packages/tests/src/_jest/helpers/mocks.ts @@ -2,11 +2,6 @@ // This product includes software developed at Datadog (https://www.datadoghq.com/). // Copyright 2019-Present Datadog, Inc. -/* global NodeJS */ - -import type { BackendFunction } from '@dd/apps-plugin/backend/types'; -import { LOCAL_EXECUTION_LOAD_SUFFIX } from '@dd/apps-plugin/constants'; -import type { LoadModule } from '@dd/apps-plugin/vite/local-execution'; import { DEFAULT_SITE } from '@dd/core/constants'; import { checkFile, @@ -128,25 +123,6 @@ export const getMockTimeLogger = (overrides: Partial = {}): TimeLogg return mockTimer; }; -/** - * Builds a `loadModule`-shaped resolver returning `exports` for `func`'s suffixed absolute - * path (see `LOCAL_EXECUTION_LOAD_SUFFIX`) and rejecting any other specifier, matching a real - * loader when only the target module is resolvable. - */ -export const moduleResolverFor = ( - func: BackendFunction, - exports: Record, -): LoadModule => { - return async (specifier: string) => { - if (specifier === func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX) { - return exports; - } - const error: NodeJS.ErrnoException = new Error(`Cannot find module '${specifier}'`); - error.code = 'MODULE_NOT_FOUND'; - throw error; - }; -}; - /** * Create a mock same-origin JSON POST from the dev server's own page. */ diff --git a/packages/tests/src/_jest/processEnvLintRule.test.ts b/packages/tests/src/_jest/processEnvLintRule.test.ts index 64d725430..78212c8e6 100644 --- a/packages/tests/src/_jest/processEnvLintRule.test.ts +++ b/packages/tests/src/_jest/processEnvLintRule.test.ts @@ -2162,12 +2162,12 @@ describe('process.env lint rule for tests', () => { }, { description: 'apply to Jest fixture files', - file: 'packages/plugins/apps/src/vite/local-execution.process-exit.fixture.ts', + file: 'packages/tests/src/_jest/nockResetA.fixture.ts', expected: 1, }, { description: 'apply to Jest fixtures modules', - file: 'packages/plugins/apps/src/vite/local-execution.fixtures.ts', + file: 'packages/plugins/apps/src/vite/local-execution-process.fixtures.ts', expected: 1, }, ];