Skip to content

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

Draft
tyffical wants to merge 6 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 6 commits into
tiffany.trinh/apps-2792-in-process-executionfrom
tiffany.trinh/apps-2792-harden-local-execution-v2

Conversation

@tyffical

@tyfficaltyffical 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 changedFile
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

ItemStatusNext step
Wiring into the real dev server (handleExecuteAction, threading a real LoadModule, /__dd/executeActionViaCloud split, real preview-async calls)Not startedMilestone 2, stacked on this PR (#481)
Real auth token / closure-scoping for real $.Actions executionBlockedSame 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 callDoneShipped 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)AcceptedSame 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

This comment has been minimized.

@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 6e85225 to 64c7a61CompareAugust 7, 2026 15:17
@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 64c7a61 to 41a772eCompareAugust 7, 2026 19:55
@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 59e9b78 to 6a19936CompareAugust 20, 2026 23:37
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.
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.
An abandoned (timed-out) execution can keep running in the background
after enqueue() lets the next execution start. Its late executeAction
call previously ran for real, under whatever execution's identity
happened to be current — with no error at all.
Two distinct paths needed separate guards. A raw $.Actions call made
through a reference captured before abandonment closes over its own
execution's executeAction, so a simple abandoned flag inside that
closure is enough to reject it. An @datadog/action-catalog typed-wrapper
call is structurally different: action-catalog holds exactly one
executeAction implementation in shared, module-level state, and always
invokes whichever is currently registered — a per-closure guard is
unreachable once a newer execution's registration overwrites it. That
path needs the registration itself proactively replaced with a
rejecting stub as soon as an execution concludes: unconditionally on
normal completion (enqueue's mutex guarantees nothing newer has started
yet), and on timeout only if nothing newer has started (checked via a
per-execution epoch counter, to avoid clobbering a newer execution's
own valid registration).
Code review of the epoch-gate/poisoning mechanism found three real gaps:
- run()'s finally block poisoned the action-catalog registration
unconditionally, contradicting its own "always safe" comment — when an
abandoned execution's fn() settles late (after losing the timeout race),
this could overwrite a newer, currently-valid execution's registration.
Both the finally block and the timeout handler now share one
epoch-gated concludeExecution() helper.
- The initial registration calls themselves (not just the later poison
step) were unguarded: a slow-to-resolve registration from an abandoned
execution could land after a newer execution had already registered.
run() now re-poisons immediately if it discovers it was abandoned while
those calls were still in flight.
- registerBackendRuntimeIfInstalled (apps-backend's setBackend) had no
poisoning at all, despite this file's own comment naming it as sharing
the identical shared-module-level-setter hazard as action-catalog's
setExecuteActionImplementation. It now mirrors the same
register/poison pattern.
Also wraps both poison calls in try/catch — an external package's setter
throwing on re-registration must not be allowed to mask a successful
result via finally, or abort the timeout's setTimeout callback before it
can reject.
@tyffical
tyfficalforce-pushed the tiffany.trinh/apps-2792-harden-local-execution-v2 branch from 6a19936 to 24c072fCompareAugust 21, 2026 03:50
@tyffical
tyffical requested a balanced review from CopilotAugust 21, 2026 16:24
@chatgpt-codex-connector

This comment was marked as outdated.

CopilotAI 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.

FileDescription
packages/plugins/apps/src/vite/local-execution.tsAdds execution hardening and result validation.
packages/plugins/apps/src/vite/local-execution.test.tsAdds hardening regression coverage.

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

Comment threadpackages/plugins/apps/src/vite/local-execution.ts
Comment threadpackages/plugins/apps/src/vite/local-execution.ts Outdated
Comment threadpackages/plugins/apps/src/vite/local-execution.ts
@tyffical

Copy link
Copy Markdown
ContributorAuthor

@cursor review
@codex review

@chatgpt-codex-connectorchatgpt-codex-connectorBot 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:24c072fd57

ℹ️ 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 threadpackages/plugins/apps/src/vite/local-execution.ts Outdated
Comment threadpackages/plugins/apps/src/vite/local-execution.ts
- runScriptLocally now checks abandoned right before invoking the
customer function, not just after the initial registration
Promise.all — a slow loadModule call could otherwise still let an
already-known-abandoned execution's function run pointlessly.
- The finally block poisoning the shared registrations now covers
loadModule and export validation too, not just the fn() call itself
— a rejection from either of those previously skipped
concludeExecution() entirely, leaving the registrations live.
- assertJsonSerializable now returns the JSON-round-tripped value
instead of the original — dev-server.ts serializes the result again
for the HTTP response, so returning the original ran any custom
toJSON()/getters a second time, which can throw or produce a
different value than what was just validated.
…ed poisoning
registerActionCatalogIfInstalled/registerBackendRuntimeIfInstalled's
loadModule call is async and can be slow enough for a newer execution to
start and validly register its own implementation while the older one is
still in flight. Once it resolves, the older execution's initial register()
call fired unconditionally, overwriting the newer execution's valid
registration with its own stale one — and poisoning afterward can't undo
that, since poisoning only replaces whatever's currently installed, not
restore what was there before. Moves the epoch-currency check inside
register() itself so every call (the initial registration and any later
poison call) re-validates freshness at the moment it actually runs, instead
of trusting a check done earlier.
Separately, the backend-runtime poison object (a Proxy that throws on any
property access) was being routed back through
buildRuntimeFromJsFunctionWithActions, which synchronously reads and
validates $.Source before producing a runtime — so the poison's own throw
fired during that validation, before setBackend was ever reached, silently
swallowed by the best-effort catch and leaving the previous, stale runtime
installed. Poisoning now calls setBackend directly with an already
runtime-shaped object whose accessors reject when actually invoked, instead
of routing a broken $ back through the validating factory.
@tyffical
tyffical requested a balanced review from CopilotAugust 21, 2026 19:25
@tyffical

Copy link
Copy Markdown
ContributorAuthor

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

setExecuteActionImplementation(async(actionId: string,request: unknown)=>{
const{ inputs, connectionId }=(request??{})asPartial<ActionCallArgs>;
returnnextExecuteAction(actionId,inputs,connectionId);

P2 Badge Preserve poisoning across the next typed registration

When execution A times out, enqueue releases execution B even though A's customer promise may still be running; B then calls this setter and replaces A's rejecting implementation. If A subsequently invokes an action-catalog typed wrapper while B is in flight, the wrapper resolves the now-current implementation, reaches B's non-abandoned guardedExecuteAction, and executes A's action through B's request-scoped callback instead of rejecting it. The test in local-execution.test.ts explicitly avoids starting a second execution at lines 666-671, so it does not cover this overwrite window; dispatch must retain caller ownership across async execution contexts or otherwise prevent a timed-out caller from inheriting B's registration.

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

CopilotAI 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 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

Previously missed (1) — in code that hasn't changed since the last review.

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

  • This assertion trusts the shape of an external unknown request, contrary to the repository's type-safety rule against as casts. Narrow the object and connectionId explicitly before calling nextExecuteAction; this also prevents a malformed runtime request from passing a non-string connection ID through a string-typed API.
 setExecuteActionImplementation(async (actionId: string, request: unknown) => {
const { inputs, connectionId } = (request ?? {}) as Partial<ActionCallArgs>;
return nextExecuteAction(actionId, inputs, connectionId);
});

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

  • The cleanup handles are assigned only after both registration promises settle. If one SDK probe registers successfully while the other hangs, the timeout calls concludeExecution() while both handles are still undefined; if the hanging probe never settles, the successful module-level registration is never poisoned. Likewise, a rejection after the sibling has registered skips the later cleanup block. Capture and poison each registration independently as soon as it settles, and ensure registration-phase failures also run conclusion cleanup.
 [reRegisterActionCatalog, poisonBackendRuntime] = await Promise.all([
registerActionCatalogIfInstalled(loadModule, guardedExecuteAction, isCurrent),
registerBackendRuntimeIfInstalled(loadModule, $, isCurrent, func.name),
]);

Sign up for freeto 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

@tyffical