Skip to content

[APPS-2792] Add: in-process local execution for backend functions - #479

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 15 commits into
masterfrom
tiffany.trinh/apps-2792-in-process-execution
Aug 31, 2026
Merged

[APPS-2792] Add: in-process local execution for backend functions#479
gh-worker-dd-mergequeue-cf854d[bot] merged 15 commits into
masterfrom
tiffany.trinh/apps-2792-in-process-execution

Conversation

@tyffical

@tyffical tyffical commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Motivation

Architecture

executeScriptLocally (local-execution.ts) introduces three collaborating pieces: an injected loadModule standing in for server.ssrLoadModule, a globalThis.$ context populated once per call, and a $.Actions Proxy that turns nested property access into a single executeAction call.

┌──────────────────────────────────────────────────────────────────────┐
│ Vite dev server process                                              │
│                                                                        │
│ executeScriptLocally(func, args, executeAction, loadModule, log)     │
│                                                                        │
│  1. globalThis.$ = {                                                 │
│       backendFunctionArgs: args,                                     │
│       Actions: makeActionsProxy(executeAction),                      │
│       Source: LOCAL_DEV_SOURCE,                                      │
│     }                                                                 │
│              │                                                        │
│              ▼                                                        │
│  2. loadModule(specifier)  ── resolves against the customer's own    │
│     │        │                project/deps, not build-plugins'      │
│     │        │                                                        │
│     │        ├─▶ registerActionCatalogIfInstalled                    │
│     │        │     loadModule('@datadog/action-catalog/              │
│     │        │       action-execution')                              │
│     │        │     → setExecuteActionImplementation(wraps            │
│     │        │       executeAction)   (no-op if not installed)       │
│     │        │                                                        │
│     │        └─▶ registerBackendRuntimeIfInstalled                   │
│     │              loadModule('@datadog/apps-backend/runtime/…')     │
│     │              → setBackend(buildRuntimeFromJsFunctionWith       │
│     │                Actions($))       (no-op if not installed)      │
│     │                                                                 │
│     └─▶ loadModule(func.absolutePath) → customer's real              │
│           *.backend.ts module (direct import, no bundling)           │
│              │                                                        │
│              ▼                                                        │
│  3. fn = mod[func.name]; result = await fn(...args)                  │
│              │                                                        │
│              │  customer code reads globalThis.$ directly, e.g.      │
│              │  $.Actions.slack.chat.postMessage({ inputs, … })      │
│              ▼                                                        │
│     $.Actions Proxy (makeActionsProxy)                               │
│       get()   → walks the nested path: ['slack','chat','postMessage']│
│       apply() → fqn = `com.datadoghq.${path.join('.')}`              │
│                → executeAction(fqn, inputs, connectionId)            │
│              │                                                        │
│              ▼                                                        │
│     executeAction (injected — dev server's real single-action call,  │
│     or a caller-supplied stub in tests)                              │
└────────────────────────────────────────────────────────────────────┘

Changes

What changed File
Added executeScriptLocally, which imports a backend function's real file directly via an injected loadModule (the dev server's real server.ssrLoadModule, or a test double) — no bundling, no wrapper module, no data: URL. local-execution.ts
Ported the $.Actions Proxy from the closed fork-based prototype (nested-property-path walk → {fqn, inputs, connectionId}) as a direct in-process call to an injected ExecuteAction. local-execution.ts
$.Actions now carries connectionId from day one instead of dropping it. local-execution.ts
Added registerActionCatalogIfInstalled/registerBackendRuntimeIfInstalled, replacing what the removed generated wrapper module used to do via text injection. local-execution.ts
Both gate on the same synchronous isActionCatalogInstalled/isDatadogAppsBackendInstalled checks production's bundler path already uses, rather than catching a loadModule failure — Vite's ssrLoadModule doesn't guarantee a stable error code for a missing bare specifier. local-execution.ts
The $ context exposed to the customer's module carries only backendFunctionArgs, Actions, and Source (verified by test), so a real auth token can later live in a module-private closure the customer's code has no way to reach. local-execution.ts
The debug log on entry no longer serializes the function's own arguments (customer data, may carry secrets/PII) — it now logs only that arguments were supplied, matching the cloud path's existing convention. local-execution.ts
BackendOutputs, previously declared identically in both this file and dev-server.ts, is now a single shared type in backend/types.ts. types.ts
Added tests covering the happy path, changed-loadModule-result correctness, $.Actions call resolution/validation (including connectionId forwarding), sync/async error propagation, timeout behavior, action-catalog typed-wrapper routing (including a real, non-"not installed" load failure), and the no-token-exposure invariant. local-execution.test.ts
The action-catalog dispatcher rejects a call missing an inputs field, same as the raw $.Actions proxy already does — both entry points funnel into the same executeAction and must reject the same malformed shape. local-execution.ts, local-execution.test.ts
getGlobalDollar reads globalThis.$ via Reflect.get instead of an as cast, matching how deleteGlobalDollar already reads/writes the same property. local-execution.ts
The abandoned-execution debug log now only fires once the caller's own timeout race has actually settled — previously it logged on every rejection, including ones the caller was still waiting on and about to receive normally. local-execution.ts, local-execution.test.ts
Extracted three inlined function-call arguments into named locals (a nested makeActionsProxy call, a buildRuntimeFromJsFunctionWithActions/setBackend pair, and the Promise.all registration array), matching this file's own convention at every other call site. local-execution.ts
Removed four as casts a later commit had reintroduced in a file an earlier commit already cleaned up — the transform-object narrowing and a new extractTransformedCode helper access the same fields without asserting their shape. index.test.ts
The transform hook's filter widens from BACKEND_FILE_RE to BACKEND_FILE_WITH_QUERY_RE so a suffixed local-execution load (?dd-local-exec) still reaches the handler; when SSR-loading that suffixed id, the handler returns the real function body instead of the frontend proxy stub. Any other query-bearing id falls through to the existing proxy-stub path, normalized back to the file's real (unsuffixed) id first so it registers under the same key a real import uses. index.ts, constants.ts
dev-server.ts's two BackendOutputs-shaped return sites now import the shared type from backend/types.ts instead of redeclaring it locally. dev-server.ts

QA Instructions

yarn install
yarn test:unit packages/plugins/apps/src/vite/local-execution.test.ts
# Expected: Test Suites: 1 passed / Tests: 31 passed, 1 skipped ✅ VERIFIED
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 29 passed / Tests: 470 passed, 1 skipped ✅ VERIFIED
yarn workspace @dd/apps-plugin run typecheck
# Expected: no output, clean exit ✅ VERIFIED
npx eslint packages/plugins/apps/src/vite/local-execution.ts packages/plugins/apps/src/vite/local-execution.test.ts --quiet
# Expected: no output, clean exit ✅ VERIFIED

No manual local or staging QA for this PR specifically: this module isn't wired into createDevServerMiddleware on its own, so there's no npm run dev request path reaching executeScriptLocally() from this diff alone. The tests above exercise a real loadModule contract (the same shape server.ssrLoadModule fulfills), not a mocked substitute for the interesting logic. Real local + staging manual QA covering this mechanism lives in #481, where the dev-server wiring landed.

Blast Radius

  • executeScriptLocally itself is net-new and not called from anywhere in the existing dev server, so it has zero effect on any currently-shipping execution path on its own.
  • The transform hook's filter does change for every existing request: widening from BACKEND_FILE_RE to BACKEND_FILE_WITH_QUERY_RE means a query-bearing id that previously skipped the handler entirely now reaches it, falls through the new query-normalization branch, and produces the same proxy-stub output as before — a real change to the live filter, but exercised by existing tests and gated on the id actually carrying a query, which no current production import does.
  • Risk: low. The widened filter's new branches are additive and covered by tests; the one behavior-changing surface (the filter regex itself) is proven equivalent for every id shape production code paths currently produce.

Out of Scope / Follow-ups

Item Status Next step
Wiring into the real dev server (handleExecuteAction, threading a real LoadModule from server.ssrLoadModule) Open Open in #481, stacked on this PR
Real auth token / closure-scoping for real $.Actions execution Blocked Needs the single-action execution endpoint (Action Platform team) to exist first — the injected ExecuteAction stays a caller-supplied stub until then
Hardening (concurrent-execution behavior, broader error-edge-case coverage) Open Open in #480, stacked on this PR
$ snapshot-and-restore runs after the customer module's own top-level evaluation, so a module that writes/deletes globalThis.$ during load can have that value mistaken for the pre-existing one; a throwing loadModule also bypasses the restoring finally Fixed downstream #480 replaces this mechanism entirely with an AsyncLocalStorage-scoped design that doesn't have this failure mode, rather than patching it here

Documentation

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-in-process-execution branch from 98ded08 to 7b74053 Compare August 7, 2026 19:15
tyffical added a commit that referenced this pull request Aug 10, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
@datadog-datadog-prod-us1-2

datadog-datadog-prod-us1-2 Bot commented Aug 10, 2026

Copy link
Copy Markdown

Tests

All CI checks and tests passed.

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 2f45138 | Docs | View more details | Give us feedback!

tyffical added a commit that referenced this pull request Aug 20, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-in-process-execution branch from 046ca9a to 2f10d6a Compare August 21, 2026 03:46
tyffical added a commit that referenced this pull request Aug 21, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
@tyffical
tyffical requested a lite review from Copilot and removed request for Copilot August 21, 2026 16:23
@DataDog DataDog deleted a comment from chatgpt-codex-connector Bot Aug 21, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Friend, this PR adds the initial in-process backend-function execution mechanism for Vite local development.

Changes:

  • Directly loads and invokes backend modules.
  • Provides globalThis.$ and SDK runtime registration.
  • Adds execution, action-routing, timeout, and concurrency tests.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
local-execution.ts Implements local execution and action proxies.
local-execution.test.ts Tests execution behavior and known race conditions.
Suppressed comments (2)

packages/plugins/apps/src/vite/local-execution.ts:198

  • The intended server.ssrLoadModule implementation will not load the real backend export here. The Apps Vite transform matches every .backend.ts ID and replaces it with the frontend RPC proxy (vite/index.ts:121-154), whose function calls globalThis.DD_APPS_RUNTIME (backend/proxy-codegen.ts:25-33). Local execution therefore invokes the proxy instead of the customer's function. Mark this load with a distinct query suffix and make the transform bypass proxy generation for that marker; cover it through the real Vite transform pipeline rather than only injected module doubles.
        const mod = await loadModule(func.absolutePath);

packages/plugins/apps/src/vite/local-execution.ts:218

  • Promise.race only stops waiting; it does not stop run(). After this timeout rejects, customer code continues in the dev-server process and can later call the injected executeAction, potentially after another execution has replaced the global SDK registrations. Track the execution as abandoned and reject all subsequent raw and action-catalog action calls (in addition to serializing executions), so timed-out work cannot perform authenticated side effects.
    try {
        return await Promise.race([run(), timeout]);
    } finally {
        clearTimeout(timer);

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2f10d6ad88

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
Comment on lines +215 to +216
try {
return await Promise.race([run(), timeout]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stop timed-out executions before reporting failure

When an asynchronous backend function exceeds the timeout but later resumes, Promise.race rejects only the caller-facing promise and leaves run() executing. That function can subsequently invoke real $.Actions side effects after the client has already received a timeout, and a retry can duplicate those effects; the timeout needs cancellation/isolation semantics, or it must not claim the execution has ended.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed — this PR's own "await Promise.race" has no cancellation/isolation semantics, exactly as described (JS can't actually cancel a settled promise's continuation). #480 (stacking on this PR) addresses this with an abandon-not-cancel model: a timed-out execution is marked abandoned and its shared action-catalog/apps-backend registrations are poisoned with rejecting stubs, so a late-resuming $.Actions call from the stale execution is rejected instead of silently running under a newer execution's identity. It doesn't literally stop the JS execution (not possible), but it does prevent the side-effect/identity-confusion risk you're flagging.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring the full fix, documenting the tradeoff instead — true cancellation of a timed-out execution requires terminating a Worker thread, which in-process execution cannot do. Added a WHY comment at the Promise.race call explaining this, and this is the reason the pending worker-pool execution model (a separate, already-planned milestone) exists: it gets real isolation/cancellation semantics that this interim model structurally cannot. Manual QA already verified the current abandon-without-crashing behavior is safe for the dev server itself; the residual risk is scoped to a local developer's own retried test run, not production.

Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

This comment was marked as resolved.

tyffical added a commit that referenced this pull request Aug 24, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
tyffical added a commit that referenced this pull request Aug 24, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
tyffical added a commit that referenced this pull request Aug 25, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
tyffical added a commit that referenced this pull request Aug 25, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
tyffical added a commit that referenced this pull request Aug 25, 2026
…xecution.test.ts

The actual ssrLoadModule/frontend-proxy-bypass fix this commit
originally introduced now lives further down the stack (PR #479) —
only the switch to the shared moduleResolverFor helper (extracted in
the previous commit) remains here.
tyffical added a commit that referenced this pull request Aug 25, 2026
Runs the readOwnArgsAfterDelay concurrency check through the real,
serialized executeScriptLocally entrypoint (its test.skip counterpart
against PR #479's un-serialized base fails with cross-contaminated
args). Passing here confirms the enqueue/queueTail promise-chain mutex
actually closes the globalThis.$ race, not just reorders interleaved
work.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-in-process-execution branch from fe6ccca to 7a90c5e Compare August 28, 2026 22:02
@tyffical
tyffical requested a review from oliverli August 31, 2026 16:38
Executes a backend function's file directly in-process inside the Vite dev
server, mirroring executeScriptViaDatadog's BackendOutputs contract as a
drop-in alternate implementation for local dev.
A customer function returning $.Actions.foo.bar without calling it
made the outer await treat the callable Proxy as a thenable (its get
trap returned another callable Proxy for .then too), hanging until
the timeout instead of just returning the value. Also converts the
apply trap to async, since the manual Promise.reject/try-catch
wrapping was only there to turn a synchronous throw into a rejection.
…ng it

The regex re-typed BACKEND_FILE_RE's extension list and the literal
suffix as an independent pattern, so a change to either one could
silently stop matching real backend files without any compiler or
lint signal.
Replace direct globalThis.$ assignment/deletion and Partial<ActionCallArgs>
casts with helper functions and a type guard, since TypeScript can narrow
these without an assertion.
…dule before installing $

The suffix alone was spoofable from frontend source (e.g. a literal
./secrets.backend.ts?dd-local-exec import); requiring SSR context too means
a spoofed client-side import still gets the safe RPC-proxy stub instead of
the real backend module body. Also loads and evaluates the customer module
before installing $ and the SDK bridges, matching production's own ordering,
so code that reaches for $ during its own top-level evaluation fails the
same way locally as it would in Datadog.
… avoid unhandled rejection

A spoofed client-side import falling through to buildProxyModule still
carried the ?dd-local-exec suffix in its id, so BACKEND_FILE_RE (anchored to
end-of-string) never stripped it — the function registered under a
corrupted relativePath/query-name distinct from the file's real
registration. Strips the suffix first so it dedupes onto the same entry.

Also attaches a no-op catch to run()'s promise once the timeout has already
settled the race, since nothing else awaits it — a customer function that
rejects after its own timeout would otherwise be an unhandled rejection
that crashes the whole dev server. Replaces a remaining raw as-cast in
registerActionCatalogIfInstalled with the file's existing isIndexableRecord
guard.
…owing it

runPromise.catch(() => {}) discarded the rejection reason from a
hung customer function once the outer timeout race already settled,
leaving the real cause of a slow failure undiagnosable.
The raw $.Actions proxy already rejects a call missing an inputs field;
the action-catalog dispatcher funnels into the same executeAction but
skipped this check, silently forwarding inputs: undefined instead.
…ints

The raw proxy and the action-catalog dispatcher each re-implemented
the same inputs/connectionId validation by hand — the exact duplication
that let the action-catalog path drift out of sync and skip the
inputs check in the first place. Extracted into one validateActionCall
helper both entry points now call, so the two can no longer diverge.

Also corrects a test comment referencing Object.defineProperty, which
this file doesn't use — globalThis.$ is set via setGlobalDollar's
Object.assign.
getGlobalDollar read globalThis.$ via an `as Record<string, unknown>`
cast; Reflect.get reads it without one, matching the pattern
deleteGlobalDollar already used for the same property.

The "caller had already stopped waiting" debug log fired on every
run() rejection, not just ones abandoned after the timeout race
already settled, since the .catch handler had no way to tell the two
cases apart. Gates it on whether the race has settled yet.

Also restores the BackendOutputs doc comment's explanation of why the
shape is `{ data: unknown }` (mirrors the app-builder query response),
dropped when the type was consolidated into backend/types.ts.
… filter

An include filter scoped only to no-query and the exact `?dd-local-exec`
suffix lets an unrecognized query (e.g. `?x`, or a malformed
`?dd-local-exec&x`) bypass the transform filter entirely, so Vite falls
back to its default loader instead of the safe RPC-proxy stub. Matching
every query on a backend file and deciding safety in the handler closes
that gap.
Locks in current behavior for two coverage gaps: depth-1 and zero-depth
$.Actions proxy calls (the latter yields a trailing-dot fqn), a
5-segment deep chain, and validateActionCall accepting an array as
inputs since typeof [] === 'object'.
Matches this file's own no-inlined-call-argument convention at every
other call site — three spots (a nested makeActionsProxy call, a
buildRuntimeFromJsFunctionWithActions/setBackend pair, and the
Promise.all registration array) still inlined a call directly as
another call's argument. Also tightens one comment to state the
current invariant rather than narrating a past bug.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-in-process-execution branch from 26c4e47 to 3d9cc89 Compare August 31, 2026 17:07
The transform-object narrowing extracted into getTransformObject
handles the filter/handler access without a cast; extractTransformedCode
narrows the awaited transform result to its object form the same way,
avoiding four `as` casts a later commit had reintroduced in a file an
earlier commit already cleaned up.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-in-process-execution branch from 3d9cc89 to c748efd Compare August 31, 2026 17:09

@oliverli oliverli left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consolidated review of the local-execution module and the widened transform filter. All comments verified against the PR head.

// Restores whatever globalThis.$ held before this call (or removes it) once the execution settles, so a pre-existing global (e.g. zx/globals) isn't clobbered and this execution's context isn't left reachable afterward.
const hadPreviousDollar = Object.prototype.hasOwnProperty.call(globalThis, '$');
const previousDollar = getGlobalDollar();
setGlobalDollar($);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

globalThis.$ contamination under concurrent executions (P1)

globalThis.$ is a single mutable global shared across executions. When two executions overlap, execution B's setGlobalDollar($B) overwrites $A while A's customer code is mid-flight, so A reads B's backendFunctionArgs and Source — in a dev server serving concurrent browser requests (multiple tabs, HMR re-fires, parallel fetches), request A's response can contain request B's input data. The test.skip at local-execution.test.ts ("Should let each concurrent call see its OWN backendFunctionArgs...") documents this, but the damage is worse than that test suggests:

The save/restore here is only correct for strictly nested lifetimes. Concrete interleaving: A times out and keeps running (the race only stops the caller from waiting); B installs $B (capturing prev = $A), completes, restores $A; C installs $C (capturing prev = $A); abandoned A then settles, and its finally sees hadPreviousDollar = false and deletes globalThis.$ — destroying C's context mid-execution, crashing C's customer code. Late side effects of abandoned executions likewise run against whatever $ is currently installed.

Suggested fix: serialize executeScriptLocally bodies through a promise-chain mutex/queue. That is local to this module, fixes the whole class of interleavings, and lets the skipped test be un-skipped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed downstream in #480: globalThis.$ is now scoped per execution via AsyncLocalStorage (backendGlobalsContext), and the action-catalog/apps-backend dispatchers resolve the calling execution's identity from executionDispatchContext.getStore() at call time rather than binding to whichever execution registered last — so a zombie execution can never read or dispatch under a newer execution's identity.

/** 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<BackendGlobalsBox>();

/** What the stable, once-ever-registered adapters below need to dispatch a call to whichever execution is on the AsyncLocalStorage call stack — kept out of `BackendGlobals` since that object is also `globalThis.$`, visible to customer code. */
type ExecutionDispatch = {
executeAction: ExecuteAction;
allowedConnectionIds: string[];
isAbandoned: () => boolean;
functionName: string;
$: BackendGlobals;
};
/** Distinct from `backendGlobalsContext` so dispatch-only fields (the real `executeAction`, `allowedConnectionIds`) never leak onto `globalThis.$`. */
const executionDispatchContext = new AsyncLocalStorage<ExecutionDispatch>();

#480

if (typeof setExecuteActionImplementation !== 'function') {
return;
}
setExecuteActionImplementation(async (actionId: string, request: unknown) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Registrations are last-write-wins and never unregistered (P2)

registerActionCatalogIfInstalled and registerBackendRuntimeIfInstalled both install a closure capturing this execution's allowedConnectionIds (here) or $ (apps-backend), and neither is ever unregistered. Two consequences:

  1. Concurrency: under overlapping executions this is last-write-wins. Function A (allowedConnectionIds: []) can have its typed-wrapper call validated against function B's closure (allowedConnectionIds: ['conn-1']), so a connection A's function does not allow passes assertConnectionIdAllowed and reaches executeAction — silently defeating the connection-scoping guard this module's comments claim mirrors the cloud path.
  2. Staleness: the registration persists after the execution settles. The action-catalog module retains the last closure (with the last function's allowedConnectionIds and the dev server's executeAction) indefinitely; anything invoking the wrapper later runs with stale scoping.

The mutex from the globalThis.$ thread fixes the concurrency half. For the stale half, unregister/reset the implementation after run() settles (or guard registration with a per-execution token).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed downstream in the same #480 PR: registerActionCatalogIfInstalled/registerBackendRuntimeIfInstalled now register ONE stable dispatcher/proxy for the process lifetime that resolves the current execution from executionDispatchContext at call time, rather than binding to whichever execution's registration happened to run last — so a later registration can no longer clobber dispatch for an execution already in flight.

/** Shared once-ever-registration wrapper for both adapters below: no-ops if uninstalled (re-checked uncached on every call, so a mid-session install is picked up on the very next execution), reuses the WeakMap-cached registration keyed by `loadModule` identity (true once-ever registration for a real dev server's reused `ssrLoadModule`, isolated per closure for each test), and evicts a rejection so the next execution retries instead of staying permanently poisoned. */

function registerActionCatalogIfInstalled(
loadModule: LoadModule,
projectRoot: string,
timeoutMs: number,
): Promise<void> {
return registerOnceIfInstalled(
isActionCatalogInstalled,
actionCatalogRegistrations,
registerActionCatalogOnce,
loadModule,
projectRoot,
timeoutMs,
);
}
async function registerActionCatalogOnce(loadModule: LoadModule, timeoutMs: number): Promise<void> {
const loadPromise = loadModule('@datadog/action-catalog/action-execution');
const mod = await withTimeout(
loadPromise,
timeoutMs,
'@datadog/action-catalog/action-execution',
);
const setExecuteActionImplementation = mod.setExecuteActionImplementation;
if (typeof setExecuteActionImplementation !== 'function') {
return;
}
setExecuteActionImplementation(async (actionId: string, request: unknown) => {
const dispatch = executionDispatchContext.getStore();

#480

Comment thread packages/plugins/apps/src/vite/index.ts Outdated

/* global Proxy, globalThis */

/** Executes a backend function's file directly in-process inside the Vite dev server, mirroring executeScriptViaDatadog's `BackendOutputs` contract in dev-server.ts as a drop-in alternate implementation. */

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Drop-in mirror of executeScriptViaDatadog" contract claim is unverifiable from this PR (P2)

The success path here returns { data: result }, matching BackendOutputs. But every failure mode throws: missing export, action-call validation, disallowed connectionId, customer sync/async throw, and timeout. No hunk in this PR touches executeScriptViaDatadog's body, so whether the cloud path also throws — or resolves error payloads inside BackendOutputs, which is common for HTTP-wrapped RPC — cannot be confirmed from the diff.

If the cloud path resolves errors rather than rejecting, the "drop-in alternate implementation" claim is wrong, and a future swap silently changes rejection semantics for callers. Either confirm the cloud path's error behavior and cite it here, or soften the claim (e.g. "returns the same BackendOutputs success shape").

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verified accurate against the now-landed dev-server.ts (#481): executeScriptViaDatadog throws when the response is missing a data field, and pollQueryExecution throws on any API error response — both match this file's own throw-based error contract rather than returning an ambiguous value on failure.

const outputs = await pollQueryExecution(receiptId, auth, doAuthenticatedRequest, log);
if (typeof outputs !== 'object' || outputs === null || !('data' in outputs)) {
throw new Error('Query execution completed without a "data" field in its outputs');
}
return outputs;

// Check for error responses.
if (result.errors?.length) {
const details = result.errors.map((e) => e.detail || e.title).join('; ');
throw new Error(`Query execution failed: ${details}`);
}

@oliverli

Copy link
Copy Markdown
Collaborator

otherwise looks good

…export id reaches the transform handler

BACKEND_FILE_WITH_QUERY_RE deliberately matches a backend file with any
query, so an id that isn't the customer's own real re-transform (e.g. some
other Vite load hook resolving to zero-export content) can still reach the
"no exported functions" branch, which unconditionally cleared this file's
registered functions. That's correct for a genuine no-query re-transform
(the case HMR relies on), but wrong for a query-bearing id reaching this
branch for an unrelated reason — it would silently and permanently break
the file's real (unsuffixed) registration until a file edit or server
restart, over an import that never touched its real source.

Restricts the destructive clear (and its warning) to the exact no-query
case. Vite's own ?raw/?url/?worker load hooks all produce a default export,
which enumerateBackendExports already rejects with a loud throw before this
branch is reached, so this covers whatever else might legitimately produce
zero exports without throwing.
@tyffical
tyffical requested a review from oliverli August 31, 2026 20:31
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit 0b58dc2 into master Aug 31, 2026
5 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the tiffany.trinh/apps-2792-in-process-execution branch August 31, 2026 20:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants