[APPS-2792] Add: harden the in-process local execution path - #480
Conversation
|
✅ All CI checks and tests passed. 🎉 All green!🧪 All tests passed 🔗 Commit SHA: 0986f4d | Docs | View more details | Give us feedback! |
6e85225 to
64c7a61
Compare
64c7a61 to
41a772e
Compare
59e9b78 to
6a19936
Compare
6a19936 to
24c072f
Compare
This comment was marked as outdated.
This comment was marked as outdated.
There was a problem hiding this comment.
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.
This comment was marked as resolved.
This comment was marked as resolved.
d2bd2a5 to
54c6843
Compare
ec2a07f to
10a8c9c
Compare
37e1937 to
fb88802
Compare
|
@codex review |
There was a problem hiding this comment.
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.allexits before entering thetry/finallybelow. The completed SDK registration therefore remains live,abandonedstays 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 sametry/finallyso every exit callsconcludeExecution().
await Promise.all([
registerActionCatalogIfInstalled(
loadModule,
guardedExecuteAction,
func.allowedConnectionIds,
scope.isCurrent,
).then((handle) => {
reRegisterActionCatalog = handle;
}),
registerBackendRuntimeIfInstalled(loadModule, $, scope.isCurrent, func.name).then(
(handle) => {
poisonBackendRuntime = handle;
},
),
]);
| `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.`, |
There was a problem hiding this comment.
💡 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)); |
There was a problem hiding this comment.
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.
fb88802 to
0986f4d
Compare
Motivation
@datadog/action-catalog'ssetExecuteActionImplementationand@datadog/apps-backend'ssetBackendboth 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.executeActioncall 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
enqueue) — never run concurrently. A rejected execution doesn't wedge the queue for whatever's next.BigIntgets a clear, attributed error instead of an opaque downstreamJSON.stringifyfailure; a bare function/Symbol(whichJSON.stringifysilently drops instead of throwing) is also caught explicitly.executeActioncalls are now rejected instead of silently running under a newer execution's identity. Two distinct call paths needed separate guards: a raw$.Actionscall made through a reference captured before abandonment is caught by a per-closureabandonedflag; an@datadog/action-catalogtyped-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).globalThisorder marker, not a mock); the queue keeps flowing after an earlier execution rejects; aloadModulerejection (simulating a native-module load failure) rejects cleanly; all three non-serializable-result shapes; the no-token-exposure and$.Sourceinvariants from #479 are re-verified against the queued path; an abandoned execution's captured$.Actionsreference 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.QA Instructions
yarn test:unit packages/plugins/apps/src/vite/local-execution.test.ts # Expected: Test Suites: 1 passed / Tests: 23 passed ✅ VERIFIEDyarn test:unit packages/plugins/apps # Expected: Test Suites: 24 passed / Tests: 326 passed ✅ 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 ✅ VERIFIEDManual QA — real scaffolded app, real dev server, real timeout
This module isn't independently reachable from
npm run devon its own (that requires #481) — exercised via a real scaffolded app running the full stack (npm link'd@datadog/vite-pluginbuilt from this stack's tip):Added a backend function that captures
$.Actionsup 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. ✅ VERIFIEDNote for anyone repeating this: the first attempt at this test showed the call going out for real (a genuine
preview-asyncrequest reachingapi.datadoghq.com, rejected only by the server'sACTION_NOT_FOUND, not by this fix) — traced to a stalenpm link'd build that hadn't picked up this branch's latest commit (prepare-linkhad linked an olddist/). Forcingrm -rf dist && yarn build:all-no-typesbefore 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
local-execution.tsstill isn't called from anywhere in the existing dev server.Out of Scope / Follow-ups
handleExecuteAction, threading a realLoadModule,/__dd/executeActionViaCloudsplit, realpreview-asynccalls)$.Actionsexecutionnet.Socket.prototype.connect,fetch, andchild_process'sspawn/exec/execSyncfor the duration of a local execution, exempted only around the internal$.Actions→executeActioncallAsyncLocalStorage-based scoping, which the RFC already defers as needing upstream changes to both@datadog/action-catalogand@datadog/apps-backendDocumentation