Skip to content

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

Draft
tyffical wants to merge 3 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 3 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 the direct-import in-process execution mechanism itself but explicitly deferred hardening (both tracked as follow-ups in its own Out of Scope table). This PR adds it.
  • The single biggest correctness risk of running backend functions in-process (vs. production's fresh-Deno-subprocess-per-execution model): @datadog/action-catalog's setExecuteActionImplementation and @datadog/apps-backend's setBackend both register runtime context via a shared, module-level setter. Without serialization, a second concurrent execution's registration could silently redirect the first's still-in-flight typed-import calls to the wrong identity — with no error at all. See the RFC's Decisions and Trade-Offs.
  • Serialization alone doesn't close the whole gap: a timed-out execution is abandoned, not cancelled — it can keep running in the background after the queue moves on. Manual QA against a real timeout surfaced this directly: an abandoned execution's later executeAction call could still run for real, attributed to whichever execution was current by then. That needed a second, independent mechanism (see Changes below) beyond the queue itself.

Changes

What changed File
Local executions are now serialized via a promise-chain queue (enqueue) — never run concurrently. A rejected execution doesn't wedge the queue for whatever's next. local-execution.ts
A returned result is now checked for JSON-serializability before being handed back — a circular reference or BigInt gets a clear, attributed error instead of an opaque downstream JSON.stringify failure; a bare function/Symbol (which JSON.stringify silently drops instead of throwing) is also caught explicitly. local-execution.ts
An abandoned (timed-out) execution's later executeAction calls are now rejected instead of silently running under a newer execution's identity. Two distinct call paths needed separate guards: a raw $.Actions call made through a reference captured before abandonment is caught by a per-closure abandoned flag; an @datadog/action-catalog typed-wrapper call is structurally different — it always invokes whichever implementation is currently registered in shared module-level state, so a per-closure guard is unreachable once a newer execution's registration overwrites it. That path is closed by proactively replacing the registration with a rejecting stub as soon as an execution concludes: unconditionally on normal completion (the queue's mutex guarantees nothing newer has started yet), and on timeout only if nothing newer has started yet (checked via a per-execution epoch counter, to avoid clobbering a newer execution's own valid registration). local-execution.ts
New tests: two concurrent executions never interleave (proven via a shared globalThis order marker, not a mock); the queue keeps flowing after an earlier execution rejects; a loadModule rejection (simulating a native-module load failure) rejects cleanly; all three non-serializable-result shapes; the no-token-exposure and $.Source invariants from #479 are re-verified against the queued path; an abandoned execution's captured $.Actions reference rejects even after a newer execution has taken over; an abandoned execution's action-catalog typed-wrapper call is rejected rather than silently running under a newer registration. 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: 23 passed ✅ VERIFIED
yarn test:unit packages/plugins/apps
# Expected: Test Suites: 24 passed / Tests: 326 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 at this test 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 that hadn't picked up this branch's latest commit (prepare-link had linked an old dist/). Forcing rm -rf dist && yarn build:all-no-types before re-linking resolved it and reproduced the expected rejection. Worth flagging since it's an easy false negative to chase for anyone else QA-ing this branch 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 (326 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) Not started 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 Done Shipped in #484, stacked on this PR
Action-catalog registration can still be legitimately re-used by an abandoned execution's late call if that call happens to land while a newer execution is actively mid-flight (registration only gets poisoned once an execution concludes, not while one is running) Accepted Same class of residual gap as #484's own "hung function keeps running in background" — closing it fully needs AsyncLocalStorage-based scoping, which the RFC already defers as needing upstream changes to both @datadog/action-catalog and @datadog/apps-backend

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: 0986f4d | 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
Comment thread packages/plugins/apps/src/vite/local-execution.ts Outdated
Comment thread packages/plugins/apps/src/vite/local-execution.ts
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
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 37e1937 to fb88802 Compare August 26, 2026 02:24
@tyffical
tyffical requested a balanced review from Copilot August 26, 2026 02:55
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

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 1 comment.

Suppressed comments (1)

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

  • If either registration rejects after the other setter has succeeded, this await Promise.all exits before entering the try/finally below. The completed SDK registration therefore remains live, abandoned stays false, and typed calls can continue using the failed execution's identity. The newly preserved handle does not help unless cleanup actually runs. Enclose registration and module execution in the same try/finally so every exit calls concludeExecution().
        await Promise.all([
            registerActionCatalogIfInstalled(
                loadModule,
                guardedExecuteAction,
                func.allowedConnectionIds,
                scope.isCurrent,
            ).then((handle) => {
                reRegisterActionCatalog = handle;
            }),
            registerBackendRuntimeIfInstalled(loadModule, $, scope.isCurrent, func.name).then(
                (handle) => {
                    poisonBackendRuntime = handle;
                },
            ),
        ]);

Comment on lines +466 to +467
`Execution of "${func.name}" was abandoned after timing out; refusing to run ` +
`"${fqn}" as this stale execution to avoid using a newer execution's identity.`,

@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: fb88802e03

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

log: Logger,
timeoutMs: number = DEFAULT_TIMEOUT_MS,
): Promise<BackendOutputs> {
return enqueue(() => runScriptLocally(func, args, executeAction, loadModule, log, timeoutMs));

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 Isolate timed-out executions from the next active scope

When execution A times out but its function continues running, this releases the queue and lets execution B replace globalThis.$ and the singleton action-catalog implementation. If A resumes while B is still active, a normal fresh $.Actions lookup or typed-wrapper call therefore uses B's live proxy/implementation and never reaches A's abandoned guard, allowing A to execute an action with B's allowlist and callback. The test around local-execution.test.ts:733 deliberately avoids starting a second execution, so it only verifies the temporary poison before B overwrites it; the abandoned invocation must remain isolated while subsequent executions run.

Useful? React with 👍 / 👎.

Serializes local backend-function executions via a promise-chain queue,
since @datadog/action-catalog and @datadog/apps-backend both register
runtime context via a shared, module-level setter that isn't safe under
concurrent in-process execution. Also populates $.Source with a synthetic
local-dev identity (deferred from Milestone 0), and adds edge-case
coverage: non-serializable results, a top-level module throw, and a real
concurrent-execution test against a genuine @datadog/apps-backend typed
import confirming no cross-execution state leakage.
An execution that times out is abandoned, not cancelled — its own
runScriptLocally call may still be running (or genuinely hung) well
after a newer execution has started. Iteratively hardens every place
the shared, module-level action-catalog/apps-backend registrations
(setExecuteActionImplementation, setBackend) could still be reachable
from an abandoned execution's own in-flight work: a late registration
call overwriting a newer execution's live implementation, a late
executeAction call reaching a poisoned-but-still-callable proxy, and a
registration Promise.all whose partial completion silently dropped a
handle needed to poison it correctly.

Extracts the resulting generation-counter guard into execution-epoch.ts
— network-guard.ts and env-guard.ts reimplement the identical pattern in
later milestones, so this becomes the shared abstraction all three
independently converge on rather than three copies of the same logic.
Trims verbose comments across execution-epoch.ts, local-execution.ts, and
their tests down to the load-bearing reasoning — the concurrency/race
explanations keep their substance, just without restating mechanics the
surrounding code already makes clear.
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from fb88802 to 0986f4d Compare August 26, 2026 04:21
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