[APPS-2792] Add: in-process local execution for backend functions - #479
[APPS-2792] Add: in-process local execution for backend functions#479tyffical wants to merge 11 commits into
Conversation
98ded08 to
7b74053
Compare
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.
|
✅ All CI checks and tests passed. 🎉 All green!🧪 All tests passed 🔗 Commit SHA: ba70a6e | Docs | View more details | Give us feedback! |
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.
046ca9a to
2f10d6a
Compare
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.
There was a problem hiding this comment.
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.ssrLoadModuleimplementation will not load the real backend export here. The Apps Vite transform matches every.backend.tsID and replaces it with the frontend RPC proxy (vite/index.ts:121-154), whose function callsglobalThis.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.raceonly stops waiting; it does not stoprun(). After this timeout rejects, customer code continues in the dev-server process and can later call the injectedexecuteAction, 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.
There was a problem hiding this comment.
💡 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".
| try { | ||
| return await Promise.race([run(), timeout]); |
There was a problem hiding this comment.
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 👍 / 👎.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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.
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.
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.
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.
…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.
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.
| registerBackendRuntimeIfInstalled(loadModule, projectRoot, $), | ||
| ]); | ||
|
|
||
| const mod = await loadModule(func.absolutePath + LOCAL_EXECUTION_LOAD_SUFFIX); |
There was a problem hiding this comment.
Same finding as the earlier Codex comment on this file (index.ts:139) — already answered there: the transitive-propagation fix (a resolveId hook that propagates LOCAL_EXECUTION_LOAD_SUFFIX onto every nested *.backend.ts import reached from a suffixed entry) already exists in tiffany.trinh/apps-2792-wire-into-dev-server (#481), which #479 must be merged into before it takes effect. Verified live on #481's current tip, not stale. Leaving the fix scoped there per this stack's P0/P1-only root-PR policy, given severity here is P2.
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.
495d2dc to
4e40aae
Compare
…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.
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
… 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.
Motivation
child_process.fork()-based isolation entirely: the Vite dev server is already the isolation boundary from production, so a crash or hang in a customer's own local dev server is a contained, recoverable failure, not something that needs a separate forked child process.data:URL, the dev server can just directly import the customer's real*.backend.tsfile. This PR ships that simplified design from the start, rather than shipping the bundle-based version and rewriting it later.Architecture
executeScriptLocally(local-execution.ts) introduces three collaborating pieces: an injectedloadModulestanding in forserver.ssrLoadModule, aglobalThis.$context populated once per call, and a$.ActionsProxy that turns nested property access into a singleexecuteActioncall.Changes
executeScriptLocally, which imports a backend function's real file directly via an injectedloadModule(the dev server's realserver.ssrLoadModule, or a test double) — no bundling, no wrapper module, nodata:URL.$.ActionsProxy from the closed fork-based prototype (nested-property-path walk →{fqn, inputs, connectionId}) as a direct in-process call to an injectedExecuteAction.$.Actionsnow carriesconnectionIdfrom day one instead of dropping it.registerActionCatalogIfInstalled/registerBackendRuntimeIfInstalled, replacing what the removed generated wrapper module used to do via text injection.isActionCatalogInstalled/isDatadogAppsBackendInstalledchecks production's bundler path already uses, rather than catching aloadModulefailure — Vite'sssrLoadModuledoesn't guarantee a stable error code for a missing bare specifier.$context exposed to the customer's module carries onlybackendFunctionArgs,Actions, andSource(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.BackendOutputs, previously declared identically in both this file anddev-server.ts, is now a single shared type inbackend/types.ts.loadModule-result correctness,$.Actionscall resolution/validation (includingconnectionIdforwarding), 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.inputsfield, same as the raw$.Actionsproxy already does — both entry points funnel into the sameexecuteActionand must reject the same malformed shape.getGlobalDollarreadsglobalThis.$viaReflect.getinstead of anascast, matching howdeleteGlobalDollaralready reads/writes the same property.QA Instructions
yarn test:unit packages/plugins/apps/src/vite/local-execution.test.ts # Expected: Test Suites: 1 passed / Tests: 27 passed, 1 skipped ✅ VERIFIEDyarn test:unit packages/plugins/apps # Expected: Test Suites: 24 passed / Tests: 316 passed, 1 skipped ✅ VERIFIEDyarn workspace @dd/apps-plugin run typecheck # Expected: no output, clean exit ✅ VERIFIEDnpx eslint packages/plugins/apps/src/vite/local-execution.ts packages/plugins/apps/src/vite/local-execution.test.ts --quiet # Expected: no output, clean exit ✅ VERIFIEDNo manual local or staging QA for this PR specifically: this module isn't wired into
createDevServerMiddlewareyet, so there's nonpm run devrequest path that reachesexecuteScriptLocally()— nothing a human can click through yet, matching the same situation the original fork-based prototype (#461) was in. The tests above exercise a realloadModulecontract (the same shapeserver.ssrLoadModulefulfills), not a mocked substitute for the interesting logic. Real local + staging manual QA becomes possible once this is wired into the dev server (follow-up PR, #481).Blast Radius
Out of Scope / Follow-ups
handleExecuteAction, threading a realLoadModulefromserver.ssrLoadModule)$.ActionsexecutionExecuteActionstays a caller-supplied stub until thenDocumentation