Skip to content

[APPS-2792] Add: harden the in-process local execution path - #480

Draft
tyffical wants to merge 4 commits into
tiffany.trinh/apps-2792-in-process-executionfrom
tiffany.trinh/apps-2792-harden-local-execution-v2
Draft

[APPS-2792] Add: harden the in-process local execution path#480
tyffical wants to merge 4 commits into
tiffany.trinh/apps-2792-in-process-executionfrom
tiffany.trinh/apps-2792-harden-local-execution-v2

Conversation

@tyffical

@tyffical tyffical commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Motivation

  • Part of APPS-2792 — local Node execution for App Builder backend functions. Milestone 1 in the Kickoff doc, stacked on Milestone 0 ([APPS-2792] Add: in-process local execution for backend functions #479).
  • [APPS-2792] Add: in-process local execution for backend functions #479 shipped in-process execution but explicitly deferred hardening (see its Out of Scope table). This PR adds it.
  • Biggest risk: @datadog/action-catalog and @datadog/apps-backend register runtime context via a shared, module-level setter. A concurrent execution's registration could silently redirect an in-flight call to the wrong identity, with no error. See the RFC's Decisions and Trade-Offs.
    • Fix: serialize all local executions through one queue (see Architecture below).
  • Serialization alone isn't enough: a timed-out execution is abandoned, not cancelled, and keeps running in the background. Manual QA against a real timeout confirmed an abandoned execution's executeAction call could still fire for real, attributed to whichever execution was current by then.
    • Fix: poison a concluded execution's registration/scope so its later calls reject.
  • Poisoning the registration alone doesn't close every gap: globalThis.$ was one shared mutable property, so a zombie's fresh read of it (not one captured before abandonment) still resolved to whichever $ a newer execution had most recently installed — letting a stale execution act under a newer execution's identity/allowedConnectionIds, a confused-deputy risk.
    • Fix: scope globalThis.$ per execution via AsyncLocalStorage.
  • A getter-only AsyncLocalStorage accessor breaks any customer module that assigns to globalThis.$ (e.g. importing zx/globals, which does exactly this) — it would throw instead of working as it did before.
    • Fix: box the AsyncLocalStorage value so it's read/write, still isolated per execution.
  • The abandon-tracking and the registration/runtime-build logic were each reimplemented ad hoc per execution rather than reusing or memoizing what's already available: a hand-rolled flag duplicating the already-built execution-epoch.ts guard, and the apps-backend runtime/registration rebuilt on every execution/access instead of once.
    • Fix: adopt execution-epoch.ts's EpochGuard, and memoize registration and the runtime build.

Architecture

enqueue serializes every local execution through one promise chain; within each slot, AsyncLocalStorage scopes that execution's own identity, and a shared EpochGuard marks it superseded the moment a later one starts. Two adapters registered once, for the process's lifetime, resolve identity dynamically at call time rather than at registration time — so a zombie's call always resolves to its own, now-invalid scope, never a newer execution's.

┌─ enqueue(): one execution in flight at a time ───────────────────────┐
│                                                                        │
│  execution A                                execution B (queued)      │
│  scope = executionEpoch.start()  (gen 1)                              │
│  AsyncLocalStorage.run({ $: A-box, dispatch: A-dispatch }, fn)        │
│       │                                                                │
│       ▼                                                                │
│  fn(...args) TIMES OUT                                                │
│  → scope.concludeIfCurrent()   (A now stale)                          │
│  → fn keeps running as a "zombie" — not killed, just abandoned        │
│                                                                        │
│                                     scope = executionEpoch.start()     │
│                                     (gen 2 — auto-supersedes A)        │
│                                     AsyncLocalStorage.run(             │
│                                       { $: B-box, dispatch: B-dispatch│
│                                       }, fn)                          │
└────────────────────────────────────────────────────────────────────────┘

A's zombie code calling $.Actions.foo.bar() later:

  zombie A's closure                 stable, process-lifetime adapter
  ───────────────────                (action-catalog / apps-backend,
                                       registered once, memoized by
                                       loadModule identity)
  $.Actions.foo.bar(...)  ───────▶   reads executionDispatchContext
                                      .getStore() at CALL time
                                          │
                                          ▼
                                     dispatch = A's dispatch
                                     dispatch.isAbandoned()
                                       = !scope.isCurrent()
                                       = true (B superseded A)
                                          │
                                          ▼
                                     reject: "already concluded"

Changes

What changed File
Local executions now serialize via a promise-chain queue (enqueue) instead of running concurrently. local-execution.ts
A rejected execution no longer wedges the queue for whatever's next. local-execution.ts
A returned result is now checked for JSON-serializability before being handed back, with a clear, attributed error for a circular reference, a BigInt, or a bare function/Symbol (which JSON.stringify would otherwise silently drop). local-execution.ts
An abandoned (timed-out) execution's later $.Actions calls now reject instead of running under a newer execution's identity, checked via whether its own scope is still current. local-execution.ts
An abandoned execution's @datadog/action-catalog typed-wrapper call is guarded separately, since it always invokes whichever implementation is currently registered in shared state: the registration is proactively replaced with a rejecting stub once an execution concludes. local-execution.ts
globalThis.$ is now scoped per execution via AsyncLocalStorage instead of a plain mutable property, so a zombie's fresh $ read always resolves to its own identity, never a newer execution's. local-execution.ts
Reads/writes to globalThis.$ are boxed per execution, so a customer module assigning to it (e.g. importing zx/globals) only shadows it for that execution — the prior value is visible again once the execution completes, with no throw. local-execution.ts
Action-catalog/apps-backend registration now runs inside the same try/finally as the customer function call, so a genuine failure in one no longer skips poisoning an already-succeeded sibling. local-execution.ts
Both registrations are now memoized by loadModule identity, so a real dev server (which reuses the same ssrLoadModule) pays the install-check/load cost once per process instead of on every execution; each test still gets an isolated run since it constructs its own loadModule. local-execution.ts
The apps-backend runtime is now built once per execution (cached by dispatch identity) instead of on every accessor call. local-execution.ts
New tests: concurrent executions never interleave (via a shared globalThis order marker, not a mock); the queue keeps flowing after a rejection; a loadModule rejection surfaces cleanly; all three non-serializable-result shapes; the no-token-exposure and $.Source invariants from #479 re-verified against the queued path. local-execution.test.ts
New tests: an abandoned execution's captured $.Actions reference and its action-catalog typed-wrapper call both reject instead of running under a newer registration; a zombie's fresh globalThis.$ read resolves to its own identity mid-flight. local-execution.test.ts
New tests: a customer module can assign to globalThis.$ without throwing, the prior value restores after the execution completes, and one execution's override never leaks into a later one; a genuinely failing sibling registration still poisons the completed one. local-execution.test.ts

QA Instructions

yarn install
yarn test:unit packages/plugins/apps/src/vite/local-execution.test.ts
# Expected: Test Suites: 1 passed / Tests: 42 passed ✅ VERIFIED
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 25 passed / Tests: 338 passed ✅ 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

Manual QA — real scaffolded app, real dev server, real timeout

This module isn't independently reachable from npm run dev on its own (that requires #481) — exercised via a real scaffolded app running the full stack (npm link'd @datadog/vite-plugin built from this stack's tip).

Added a backend function that captures $.Actions up front, sleeps 15s (past the 10s default timeout), then attempts a real $.Actions.foo.bar(...) call:

{"success":false,"error":"Local execution of \"hangThenCallAction\" timed out after 10000ms"}

Confirmed via the dev server's own log that the abandoned call, ~5s later, was rejected immediately with "...was abandoned after timing out; refusing to run \"com.datadoghq.foo.bar\"..." — no real HTTP call to Datadog's API went out. ✅ VERIFIED

Note for anyone repeating this: the first attempt showed the call going out for real (a genuine preview-async request reaching api.datadoghq.com, rejected only by the server's ACTION_NOT_FOUND, not by this fix) — traced to a stale npm link'd build (prepare-link had linked an old dist/). rm -rf dist && yarn build:all-no-types before re-linking fixed it. Worth flagging since it's an easy false negative to chase after a rebase.

Blast Radius

  • No behavior change for any currently-shipping code path: local-execution.ts still isn't called from anywhere in the existing dev server.
  • Risk: low. All changes are additive/internal to a module with no external callers yet; full existing test suite (338 tests) passes.

Out of Scope / Follow-ups

Item Status Next step
Wiring into the real dev server (handleExecuteAction, threading a real LoadModule, /__dd/executeActionViaCloud split, real preview-async calls) In progress Milestone 2, stacked on this PR (#481)
Real auth token / closure-scoping for real $.Actions execution Blocked Same as #479 — needs the single-action execution endpoint (Action Platform team)
Runtime network/subprocess guard: block net.Socket.prototype.connect, fetch, and child_process's spawn/exec/execSync for the duration of a local execution, exempted only around the internal $.ActionsexecuteAction call In progress Open in #484, stacked on this PR

Documentation

@datadog-datadog-us1-prod

datadog-datadog-us1-prod Bot commented Aug 7, 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: 65222cc | Docs | View more details | Give us feedback!

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 6e85225 to 64c7a61 Compare August 7, 2026 15:17
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 64c7a61 to 41a772e Compare August 7, 2026 19:55
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 59e9b78 to 6a19936 Compare August 20, 2026 23:37
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 6a19936 to 24c072f Compare August 21, 2026 03:50
@tyffical
tyffical requested a balanced review from Copilot August 21, 2026 16:24
@DataDog DataDog deleted a comment from chatgpt-codex-connector Bot Aug 21, 2026
@chatgpt-codex-connector

This comment was marked as outdated.

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 hardens in-process backend execution with serialization, stale-context guards, and JSON-result validation.

Changes:

  • Serializes local executions and poisons concluded runtime registrations.
  • Validates returned values for JSON serialization.
  • Expands concurrency, timeout, registration, and result tests.

Reviewed changes

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

File Description
packages/plugins/apps/src/vite/local-execution.ts Adds execution hardening and result validation.
packages/plugins/apps/src/vite/local-execution.test.ts Adds hardening regression coverage.

💡 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[bot]

This comment was marked as resolved.

@chatgpt-codex-connector

This comment was marked as resolved.

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch 4 times, most recently from d2bd2a5 to 54c6843 Compare August 25, 2026 04:29
@tyffical
tyffical requested a balanced review from Copilot August 25, 2026 15:42

This comment was marked as resolved.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from ec2a07f to 10a8c9c Compare August 25, 2026 16:49

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

Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: ddc2709e12

ℹ️ 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".

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch 2 times, most recently from eb29e16 to ddb4722 Compare August 26, 2026 17:55
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from ddb4722 to 0e4c540 Compare August 26, 2026 19:14
@tyffical
tyffical requested a balanced review from Copilot August 26, 2026 20:28
chatgpt-codex-connector[bot]

This comment was marked as resolved.

This comment was marked as resolved.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch 6 times, most recently from 0eb79ba to d7263a5 Compare August 27, 2026 05:00
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch 2 times, most recently from f554d9b to 0456c5b Compare August 27, 2026 05:44
@tyffical
tyffical requested a balanced review from Copilot August 27, 2026 05:46
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@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: 0456c5b29a

ℹ️ 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".


/** Backs `globalThis.$` for reads/writes that happen with no execution box on the AsyncLocalStorage-scoped call stack (e.g. this module's own import-time state) — an ordinary mutable slot, since there's no per-execution box to isolate it into. */
let globalDollarOutsideExecution: unknown;

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 Preserve an existing global dollar during accessor installation

When another dependency such as zx/globals defines globalThis.$ before this module is imported, this import-time defineProperty replaces that value while globalDollarOutsideExecution is still undefined. The restoration test only assigns its “pre-existing” value after this module has already loaded, so it misses the real initialization order; after loading the plugin, unrelated code outside an execution observes undefined instead of its original $. Initialize the outside-execution slot from the existing property before installing the accessor (and account for an incompatible descriptor).

Useful? React with 👍 / 👎.

@tyffical tyffical Aug 27, 2026

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 in a5a9d26 — seeded globalDollarOutsideExecution from Reflect.get(globalThis, '$') before installing the accessor, and added a regression test using jest.isolateModules to verify a pre-existing $ survives module load.

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

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

Suppressed comments (11)

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

  • Avoid introducing another any escape hatch for $; testDollar() already centralizes and types this runtime-only global.
                        () => resolve((globalThis as Record<string, any>).$.backendFunctionArgs),

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

  • Use the existing typed testDollar() helper instead of adding a Record<string, any> assertion, in line with the repository's no-any convention.
                        const { Actions } = (globalThis as Record<string, any>).$;

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

  • This is another avoidable any assertion for the runtime global. Reuse testDollar() so access remains centralized and typed.
                        const $ = (globalThis as Record<string, any>).$;

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

  • The timeout handler no longer poisons or replaces registeredImpl; this is the stable adapter, which rejects because the calling async context is concluded. Update the explanation to match the implementation.
            // registeredImpl now points at the abandoned execution's own implementation, poisoned by the timeout handler — deliberately no second execution here, to isolate the poison step.

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

  • There is no poison stub for a newer registration to overwrite anymore. This regression protects the stable adapter's call-time context resolution, so the current explanation is obsolete.
        // Poisoning only protects the window before a newer execution registers — once it does, its own register() call (correctly, from its own perspective) overwrites the poison stub. A zombie action-catalog call made after that point must still be rejected, not routed through the newer execution's identity/allowedConnectionIds.

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

  • Execution B does not self-poison on completion in the new design; it only concludes its epoch scope. Remove the obsolete poisoning description so the timing rationale matches the stable-adapter implementation.
            // Times out at 20ms, then calls the typed wrapper ~60ms in — squarely inside funcB's own in-flight window (funcB registers immediately but doesn't complete, and self-poison, until 80ms) — using conn-B, a connection funcA itself is never allowed to use.

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

  • This still says B self-poisons its registration, but conclusion now only invalidates B's scope and leaves the stable registration in place.
            // Starts as soon as the queue frees, registers immediately, but doesn't complete (and self-poison on conclusion) until 80ms — overlapping funcA's 60ms zombie wakeup.

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

  • Late cleanup no longer re-poisons registration; it only attempts to conclude the abandoned execution's epoch scope. The comment should describe that current invariant.
        // An abandoned execution's fn() can settle normally later — its finally block must not re-poison the registration over whatever a newer execution already put there.

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

  • B's conclusion no longer poisons the registration. This assertion captures the stable adapter and verifies that A's later cleanup does not replace it, so the comment currently describes behavior that cannot occur.
            // Captures whatever B's own conclusion left registered — B poisoning its own registration on completion is fine; nothing else must overwrite it.

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

  • This explanation refers to removed handle-publishing and poisoning logic. Both registrations are started immediately by Promise.all; action-catalog can independently finish installing its stable adapter even though apps-backend never settles.
        // The apps-backend loadModule call hangs forever here — a post-Promise.all destructuring assignment would never run, so publishing each handle via .then() is what lets the completed action-catalog registration still get poisoned.

packages/plugins/apps/src/vite/execution-epoch.ts:16

  • The example names network-guard.ts, which is not present in this PR or the current repository. Keep this API documentation generic until that consumer exists.
    /** True if some started scope hasn't yet been concluded or superseded (e.g. for `network-guard.ts`'s `runAllowed`). */

const backendGlobalsContext = new AsyncLocalStorage<BackendGlobalsBox>();

/** Backs `globalThis.$` for reads/writes that happen with no execution box on the AsyncLocalStorage-scoped call stack (e.g. this module's own import-time state) — an ordinary mutable slot, since there's no per-execution box to isolate it into. */
let globalDollarOutsideExecution: unknown;

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 in a5a9d26 — same root cause as the sibling Codex finding on this line; seeded from Reflect.get(globalThis, "$") before installing the accessor.

Comment thread packages/plugins/apps/src/vite/local-execution.test.ts Outdated
expect(executeAction).not.toHaveBeenCalled();
});

// Action-catalog holds one executeAction implementation in shared module state — a per-closure abandoned guard can't protect a typed-wrapper call once a newer execution re-registers, so poisonActionCatalogRegistration proactively replaces it with a rejecting stub on conclusion.

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 in a5a9d26 — reworded this and the other 7 flagged locations to describe the actual call-time AsyncLocalStorage dispatch resolution, not the removed poisoning mechanism.

// 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 `network-guard.ts`, `env-guard.ts`, `local-execution.ts`). */

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 in a5a9d26 — dropped the network-guard.ts/env-guard.ts mentions from both doc comments; this file only describes local-execution.ts as a consumer today.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from a5a9d26 to 24f39d2 Compare August 27, 2026 06:43
Serializes concurrent executions to prevent one call's globalThis.$/registration
state from leaking into another, gives each execution its own $.Source object,
and closes confused-deputy and zombie-execution registration-poisoning gaps
where a completed or abandoned execution could still influence a later one's
action-catalog or apps-backend dispatch. Also treats .toJSON as a probed
property on the $.Actions proxy so JSON.stringify($) doesn't hang.
The stable Proxy wrapped every property access in a synthetic callable,
assuming the real @datadog/apps-backend runtime is a flat set of methods.
It isn't — e.g. user identity is a nested `.user.getExecutionUser()`
namespace — so any nested accessor threw "is not a function". Forward
each property straight through to the real, dispatch-cached runtime
instead.
…tale docs

Seeds globalDollarOutsideExecution from any globalThis.$ already installed
before this module loads (e.g. zx/globals), so installing the accessor
doesn't silently discard a pre-existing value. Replaces 4 remaining any-casts
in the test file with the existing testDollar() helper, and rewords 10
comments across local-execution.test.ts and execution-epoch.ts that still
described the removed poisoning mechanism or named consumer files that don't
exist yet.
… to {}

JSON.stringify(new Map(...)) and JSON.stringify(new Set(...)) both return
'{}' — a defined string, not undefined — so assertJsonSerializable's
existing undefined-check never caught them, silently dropping all of a
Map's/Set's entries instead of surfacing the same clear error given to
other non-serializable shapes (BigInt, functions, circular references).
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 24f39d2 to 65222cc Compare August 27, 2026 07:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants