Skip to content

[APPS-2961] Update: run each local backend function call in a fresh process - #608

Merged
gh-worker-dd-mergequeue-cf854d[bot] merged 1 commit into
masterfrom
tiffany.trinh/apps-2961-child-process-execution
Oct 28, 2026
Merged

gh-worker-dd-mergequeue-cf854d[bot] merged 1 commit into
masterfrom
tiffany.trinh/apps-2961-child-process-execution

Conversation

@tyffical

@tyffical tyffical commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Architecture

sequenceDiagram
    participant B as "Browser"
    participant D as "Vite dev server"
    participant C as "Call process"
    participant API as "Datadog API"
    B->>D: "POST /__dd/executeAction"
    D->>API: "Hydrate $ (preview-async)"
    D->>C: "Fork a fresh process: stripped env, 256 MB heap"
    loop "Each module the call imports"
        C->>D: "fetchModule over IPC"
        D-->>C: "Module code"
    end
    C->>D: "$.Actions call"
    D->>D: "Allowlist check, add credentials"
    D->>API: "Single-action query"
    D-->>C: "action-result"
    C-->>D: "stdout and stderr lines"
    C->>D: "result or error"
    D->>C: "SIGKILL"
    D-->>B: "Response"
Loading
  • The dev server keeps the credentials and the connection allowlist.

Changes

10 changes across local-execution-process.ts, local-execution-child.ts, local-execution.ts, index.ts, the README and tests
What changed File
Forks one process per call, one at a time, with a 256 MB heap, its own temp dir and no developer variables. local-execution-process.ts
Serves modules and runs $.Actions for the call over IPC, and fails the call when a reply can't be sent before the call returns, whether send throws or reports the error to its callback. local-execution-process.ts
Streams output by line, and kills calls on timeout, close, restart and exit. local-execution-process.ts, index.ts
The child runs the project's Vite ModuleRunner with Node's import.meta.resolve, under the in-process guards. local-execution-child.ts, local-execution-protocol.ts
Removes the in-process executor, its tests, and the old-Vite and module-cache fallbacks. local-execution.ts, dev-server.ts, dev-server-module-graph.ts
Keeps tracking a started process after its 'error' event, such as a failed kill, so exit still kills it. local-execution-process.ts
Fails each call with a setup hint in a Yarn Plug'n'Play project or when the project's Vite has no module runner. local-execution-process.ts
Describes a thrown value that can't be converted to a string instead of throwing again. local-execution-shared.ts
Builds the child entry and documents local execution. package.json, README.md
Tests, including return-value validation through the child; they mock node:child_process, since a sandbox guard from another test file can leave fork unspyable. local-execution-process.integration.test.ts, local-execution-process.test.ts, dev-server.test.ts, dev-server.integration.test.ts, index.test.ts

QA Instructions

  • All 9 local checks pass on Node 20.19.4 through the real dev server with a mocked Datadog API: 7 in a Vite 6.4.3 app with this branch's built plugin and 2 in a Vite 7.3.7 app with its packed plugin, verified at c78647b9, as are the staging checks below. Setup: build-plugins: Testing and QA Guide.
Check Result Evidence
Backend function call 200, in its own process {"hello":"qa","pid":21506}, dev server pid 21486
Module state over 3 calls Fresh each call calls: 1 three times, three pids
Call that runs process.exit(3) 500, next call 200 ended before returning a result (exit code 3)
Synchronous loop 500 after 10 s, next call 200 timed out after 10000ms in 10114 ms
Unawaited $.Actions call settling after the return 200 {"returned":true,...}
Variables a call sees, with DD_API_KEY set Only its own ["TMPDIR","__CF_USER_TEXT_ENCODING"]
Processes and temp dirs left after server.close() None all 10 call pids gone (ps -p), every dd-apps-exec-* dir removed
import.meta.resolve('vite'), Vite 7.3.7 200 with a file URL file://…/app7/node_modules/vite/dist/node/index.js
Same app with nodeLinker: pnp Startup warning, each call 500 Local execution of backend functions needs a node_modules install: …
NO_TYPES=1 yarn workspace @datadog/vite-plugin build
# <qa>/app: the files in "Local QA app" below, with node_modules/vite and
# node_modules/@datadog/vite-plugin linked to this checkout's node_modules/vite and packages/published/vite-plugin.
# Run from the checkout so Volta picks its Node 20.19.4:
DD_API_KEY=qa-fake DD_APP_KEY=qa-fake node <qa>/dev-qa.mjs <qa>/app "$PWD"
# greet -> 200 with a pid other than the dev server's; countCalls -> calls 1 each time; crash -> 500 exit code 3;
# spinForever -> 500 after 10 s; fireAndForget -> 200; envKeys -> TMPDIR only (plus macOS's own)
# Vite 7 and Plug'n'Play checks: see "Vite 7 packed-app QA" below.
Local QA app, helper script and output
// vite.config.js
import { datadogVitePlugin } from '@datadog/vite-plugin/dist/src/index.mjs';
import { defineConfig } from 'vite';

export default defineConfig({
    plugins: [
        datadogVitePlugin({
            logLevel: 'debug',
            auth: { site: 'datadoghq.com', apiKey: 'qa-fake-api-key', appKey: 'qa-fake-app-key' },
            apps: { enable: true, identifier: 'qa-app', name: 'qa-app' },
        }),
    ],
});
// src/qa.backend.js, imported by src/main.js
let calls = 0;

export async function greet(name) {
    return { hello: name, pid: process.pid };
}

export async function countCalls() {
    calls += 1;
    return { calls, pid: process.pid };
}

export async function crash() {
    process.exit(3);
}

export function spinForever() {
    for (;;) {}
}

export async function fireAndForget() {
    $.Actions.http.request({ inputs: { url: 'https://example.com' } }).catch(() => {});
    return { returned: true, pid: process.pid };
}

export async function envKeys() {
    return Object.keys(process.env).sort();
}
// Starts the QA app's real Vite dev server with only the Datadog API mocked, then calls its
// backend functions through POST /__dd/executeAction.
// Usage: node dev-qa.mjs <appDir> <buildPluginsRepo>
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import path from 'node:path';

const [appDir, repoDir] = process.argv.slice(2);
const appRequire = createRequire(path.join(appDir, 'package.json'));
const repoRequire = createRequire(path.join(repoDir, 'package.json'));
const { createServer } = await import(appRequire.resolve('vite'));
const nock = repoRequire('nock');

const api = nock('https://api.datadoghq.com').persist();
api.post('/api/v2/app-builder/queries/preview-async').reply((_uri, body) => {
    const fqn = body?.data?.attributes?.query?.properties?.spec?.fqn;
    const id = fqn === 'com.datadoghq.datatransformation.jsFunctionWithActions' ? 'rc' : 'act';
    return [200, { data: { id } }];
});
api.get('/api/v2/app-builder/queries/execution-long-polling/rc').reply(200, {
    data: {
        attributes: {
            done: true,
            outputs: {
                data: {
                    Source: {
                        initiator: { id: 'qa-user', orgId: 'qa-org' },
                        runAsUser: { id: 'qa-user', orgId: 'qa-org' },
                    },
                },
            },
        },
    },
});
api.get('/api/v2/app-builder/queries/execution-long-polling/act')
    .delay(300)
    .reply(200, { data: { attributes: { done: true, outputs: { status: 200 } } } });
nock.enableNetConnect('127.0.0.1');

const server = await createServer({
    root: appDir,
    configFile: path.join(appDir, 'vite.config.js'),
    logLevel: 'warn',
    server: { host: '127.0.0.1', port: 5198, strictPort: true },
});
await server.listen();
const base = server.resolvedUrls.local[0].replace(/\/$/, '');
console.log(`vite dev server on ${base} (vite ${appRequire('vite/package.json').version}, node ${process.version})`);

const stub = await fetch(`${base}/src/qa.backend.js`);
console.log(`GET /src/qa.backend.js -> ${stub.status}`);

const call = async (name, args = []) => {
    const pathHash = createHash('sha256').update('src/qa').digest('hex');
    const started = Date.now();
    const response = await fetch(`${base}/__dd/executeAction`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', origin: base },
        body: JSON.stringify({ functionName: `${pathHash}.${name}`, args }),
    });
    const text = await response.text();
    console.log(`QA ${name} -> ${response.status} in ${Date.now() - started}ms ${text}`);
};

await call('greet', ['qa']);
await call('countCalls');
await call('countCalls');
await call('crash');
await call('countCalls');
await call('spinForever');
await call('countCalls');
await call('fireAndForget');
await call('countCalls');
await call('envKeys');
console.log(`dev server pid ${process.pid}`);
await server.close();
console.log('server closed');
vite dev server on http://127.0.0.1:5198 (vite 6.4.3, node v20.19.4)
GET /src/qa.backend.js -> 200
QA greet -> 200 in 204ms {"success":true,"result":{"data":{"hello":"qa","pid":21506}}}
QA countCalls -> 200 in 92ms {"success":true,"result":{"data":{"calls":1,"pid":21507}}}
QA countCalls -> 200 in 111ms {"success":true,"result":{"data":{"calls":1,"pid":21508}}}
QA crash -> 500 in 110ms {"success":false,"error":"Local execution of \"crash\" ended before returning a result (exit code 3)"}
QA countCalls -> 200 in 178ms {"success":true,"result":{"data":{"calls":1,"pid":21512}}}
QA spinForever -> 500 in 10114ms {"success":false,"error":"Local execution of \"spinForever\" timed out after 10000ms"}
QA countCalls -> 200 in 696ms {"success":true,"result":{"data":{"calls":1,"pid":21619}}}
QA fireAndForget -> 200 in 283ms {"success":true,"result":{"data":{"returned":true,"pid":21624}}}
QA countCalls -> 200 in 637ms {"success":true,"result":{"data":{"calls":1,"pid":21691}}}
QA envKeys -> 200 in 196ms {"success":true,"result":{"data":["TMPDIR","__CF_USER_TEXT_ENCODING"]}}
dev server pid 21486
server closed
# ps -p for each logged call pid (21506 21507 21508 21511 21512 21515 21619 21624 21691 21711): all gone
Vite 7 packed-app QA: import.meta.resolve and Plug'n'Play
yarn build:all && yarn workspace @datadog/vite-plugin pack -o /tmp/qa/vite-plugin.tgz
# /tmp/qa/app7: package.json with "vite": "7.3.7" and "@datadog/vite-plugin": "file:../vite-plugin.tgz",
# .yarnrc.yml with nodeLinker: node-modules (Yarn 4.10.3), and the files in the details below.
cd /tmp/qa/app7 && yarn install
DD_API_KEY=qa-fake DD_APP_KEY=qa-fake node ../dev-qa.mjs "$PWD" <build-plugins checkout>
# greet -> 200 with a pid other than the dev server's; resolveVite -> 200 with a file URL;
# callAction -> 500 "Query execution failed: QA mock: ..."
# The same app with nodeLinker: pnp, run under Yarn's hooks:
yarn install && DD_API_KEY=qa-fake DD_APP_KEY=qa-fake yarn node ../dev-qa.mjs "$PWD" <build-plugins checkout>
# A startup warning, then each call -> 500 "Local execution of backend functions needs a node_modules install: ..."
// vite.config.js
import { datadogVitePlugin } from '@datadog/vite-plugin';
import { defineConfig } from 'vite';

export default defineConfig({
    plugins: [
        datadogVitePlugin({
            auth: { site: 'datadoghq.com', apiKey: 'qa-fake-api-key', appKey: 'qa-fake-app-key' },
            apps: { enable: true, identifier: 'qa-app7', name: 'qa-app7' },
        }),
    ],
});
// src/qa.backend.js, imported by src/main.js
export async function greet(name) {
    return { hello: name, pid: process.pid };
}

export async function resolveVite() {
    return import.meta.resolve('vite');
}

export async function callAction() {
    return $.Actions.http.request({ inputs: { url: 'https://example.com' } });
}
// dev-qa.mjs
// Starts the app's real Vite dev server (its own vite.config.js, the packed plugin) with only the
// Datadog API mocked, then calls three backend functions through POST /__dd/executeAction.
// Usage: node dev-qa.mjs <appDir> <buildPluginsRepo>
import { createHash } from 'node:crypto';
import { createRequire } from 'node:module';
import path from 'node:path';

const [appDir, repoDir] = process.argv.slice(2);
const appRequire = createRequire(path.join(appDir, 'package.json'));
const repoRequire = createRequire(path.join(repoDir, 'package.json'));
const { createServer } = await import(appRequire.resolve('vite'));
const nock = repoRequire('nock');

const api = nock('https://api.datadoghq.com').persist();
api.post('/api/v2/app-builder/queries/preview-async').reply((_uri, body) => {
    const fqn = body?.data?.attributes?.query?.properties?.spec?.fqn;
    const id = fqn === 'com.datadoghq.datatransformation.jsFunctionWithActions' ? 'rc' : 'act';
    return [200, { data: { id } }];
});
api.get('/api/v2/app-builder/queries/execution-long-polling/rc').reply(200, {
    data: {
        attributes: {
            done: true,
            outputs: {
                data: {
                    Source: {
                        initiator: { id: 'qa-user', orgId: 'qa-org' },
                        runAsUser: { id: 'qa-user', orgId: 'qa-org' },
                    },
                },
            },
        },
    },
});
api.get('/api/v2/app-builder/queries/execution-long-polling/act').reply(200, {
    errors: [{ detail: 'QA mock: the HTTP action returned 503 Service Unavailable' }],
});
nock.enableNetConnect('127.0.0.1');

const server = await createServer({
    root: appDir,
    configFile: path.join(appDir, 'vite.config.js'),
    logLevel: 'warn',
    server: { host: '127.0.0.1', port: 5199 },
});
await server.listen();
const base = server.resolvedUrls.local[0].replace(/\/$/, '');
console.log(`vite dev server on ${base} (vite ${appRequire('vite/package.json').version})`);

// The plugin registers a backend function when the frontend first imports its file.
const stub = await fetch(`${base}/src/qa.backend.js`);
console.log(`GET /src/qa.backend.js -> ${stub.status}`);

const call = async (name, args) => {
    const pathHash = createHash('sha256').update('src/qa').digest('hex');
    const response = await fetch(`${base}/__dd/executeAction`, {
        method: 'POST',
        headers: { 'content-type': 'application/json', origin: base },
        body: JSON.stringify({ functionName: `${pathHash}.${name}`, args }),
    });
    const text = await response.text();
    console.log(`POST /__dd/executeAction ${name}(${JSON.stringify(args).slice(1, -1)}) -> ${response.status} ${text}`);
};

await call('greet', ['qa']);
await call('resolveVite', []);
await call('callAction', []);
console.log(`dev server pid ${process.pid}`);
await server.close();
# node_modules install
vite dev server on http://127.0.0.1:5199 (vite 7.3.7)
GET /src/qa.backend.js -> 200
POST /__dd/executeAction greet("qa") -> 200 {"success":true,"result":{"data":{"hello":"qa","pid":22604}}}
POST /__dd/executeAction resolveVite() -> 200 {"success":true,"result":{"data":"file://<scratch>/qa7/app7/node_modules/vite/dist/node/index.js"}}
[error|vite|apps] $.Actions call to "com.datadoghq.http.request" failed: Query execution failed: QA mock: the HTTP action returned 503 Service Unavailable
POST /__dd/executeAction callAction() -> 500 {"success":false,"error":"Query execution failed: QA mock: the HTTP action returned 503 Service Unavailable"}
dev server pid 22533

# nodeLinker: pnp
[warn|vite|apps] Local execution of backend functions needs a node_modules install: set `nodeLinker: node-modules` in .yarnrc.yml, or use `dev:verify` to run them in the cloud.
vite dev server on http://127.0.0.1:5199 (vite 7.3.7)
GET /src/qa.backend.js -> 200
POST /__dd/executeAction greet("qa") -> 500 {"success":false,"error":"Local execution of backend functions needs a node_modules install: set `nodeLinker: node-modules` in .yarnrc.yml, or use `dev:verify` to run them in the cloud."}
POST /__dd/executeAction resolveVite() -> 500 {"success":false,"error":"Local execution of backend functions needs a node_modules install: set `nodeLinker: node-modules` in .yarnrc.yml, or use `dev:verify` to run them in the cloud."}
POST /__dd/executeAction callAction() -> 500 {"success":false,"error":"Local execution of backend functions needs a node_modules install: set `nodeLinker: node-modules` in .yarnrc.yml, or use `dev:verify` to run them in the cloud."}
dev server pid 22863
# ps: no process left from either run
Staging QA in a Vite 8 app against a Datadog staging org, at c78647b9
Check Expected
Module state over 3 calls 1, 1, 1
Four 1000 ms calls at once 8400 ms, one after another, four pids
Hung call, synchronous loop 10 s timeout errors
Outside write, subprocess guard refusals
DD_* variables a dependency sees none
$.Actions unscoped, unlisted connection 200, refused
dev:verify runs in the cloud
  • Node 20.19.4, Vite 8.3.3, a scaffolded app with this branch's plugin and these fixtures:
// src/qaState.backend.ts
let calls = 0;

export async function countAndSleep(delayMs: number) {
    const startedAt = Date.now();
    calls += 1;
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    return { calls, pid: process.pid, startedAt, endedAt: Date.now() };
}

export async function waitForever() {
    await new Promise(() => {});
}

export function spinForever() {
    // eslint-disable-next-line no-empty
    for (;;) {}
}

export async function logSlowly(delayMs: number) {
    console.log('qa first line');
    await new Promise((resolve) => setTimeout(resolve, delayMs));
    console.error('qa second line');
    return 'logged';
}
// src/qaSandbox.backend.ts
import { probeSandbox } from 'qa-sandbox-probes';

export async function runSandboxProbes(projectFile: string) {
    return probeSandbox(projectFile);
}
// src/qaActions.backend.ts
declare const $: { Actions: { http: { request(call: unknown): Promise<unknown> } } };

export async function httpWithoutConnection() {
    const response = await $.Actions.http.request({
        inputs: { url: 'https://httpbin.org/status/204', verb: 'GET' },
    });
    return { type: typeof response, keys: Object.keys((response as object) ?? {}).sort() };
}

export async function httpWithUnlistedConnection() {
    return $.Actions.http.request({
        inputs: { url: 'https://example.com', verb: 'GET' },
        connectionId: 'qa-unlisted-connection',
    });
}
// node_modules/qa-sandbox-probes/index.js, with { "name": "qa-sandbox-probes", "main": "index.js" }
// A dependency, since the dev server's static checks refuse these imports in app code.
const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const attempt = (run) => {
    try {
        return 'ok: ' + run();
    } catch (error) {
        return 'refused: ' + (error.code || error.message);
    }
};
exports.probeSandbox = (projectFile) => ({
    projectWrite: attempt(() => (fs.writeFileSync(projectFile, 'x'), 'written')),
    tempWrite: attempt(() => {
        const file = path.join(os.tmpdir(), 'qa-probe.txt');
        fs.writeFileSync(file, 'x');
        return fs.readFileSync(file, 'utf8');
    }),
    spawn: attempt(() => {
        const spawned = spawnSync('echo', ['hi']);
        if (spawned.error) throw spawned.error;
        return String(spawned.stdout).trim();
    }),
    envKeys: Object.keys(process.env).filter((name) => /DD_|DATADOG/.test(name)).join(',') || 'none',
});
// qa-run.mjs
// Starts a QA app's Vite dev server and drives /__dd/executeAction through each APPS-2961 check.
// Usage: DD_SITE=<staging site> DD_API_KEY=... DD_APP_KEY=... node qa-run.mjs <appDir> <port> <before|after> [vite args...]
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';

const [appDir, port, label, ...viteArgs] = process.argv.slice(2);
const base = `http://localhost:${port}`;
const lines = [];
const vite = spawn(
    path.join(appDir, 'node_modules/.bin/vite'),
    ['--port', port, '--strictPort', ...viteArgs],
    { cwd: appDir, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] },
);
for (const stream of [vite.stdout, vite.stderr]) {
    let buffered = '';
    stream.on('data', (chunk) => {
        buffered += chunk;
        const parts = buffered.split('\n');
        buffered = parts.pop();
        for (const line of parts) lines.push({ at: Date.now(), line: line.replace(/\x1b\[[0-9;]*m/g, '') });
    });
}

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function waitForServer() {
    for (let i = 0; i < 120; i++) {
        try {
            await fetch(base);
            return;
        } catch {
            await sleep(500);
        }
    }
    throw new Error('dev server never came up');
}
const names = {};
async function functionName(file, exportName) {
    names[file] ??= await (await fetch(`${base}/src/${file}`)).text();
    const match = names[file].match(new RegExp(`executeBackendFunction\\("([^"]+\\.${exportName})"`));
    if (!match) throw new Error(`no proxy for ${exportName}: ${names[file].slice(0, 300)}`);
    return match[1];
}
async function call(file, exportName, args = []) {
    const started = Date.now();
    const response = await fetch(`${base}/__dd/executeAction`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ functionName: await functionName(file, exportName), args }),
    });
    const body = await response.json();
    return { status: response.status, ms: Date.now() - started, body };
}

const report = { label };
try {
    await waitForServer();
    report.freshState = [];
    for (let i = 0; i < 3; i++) {
        const { body } = await call('qaState.backend.ts', 'countAndSleep', [0]);
        report.freshState.push(body.result?.data?.calls ?? body);
    }
    const sequential = Date.now();
    await call('qaState.backend.ts', 'countAndSleep', [1000]);
    report.oneCallMs = Date.now() - sequential;
    const parallel = Date.now();
    const runs = await Promise.all([1, 2, 3, 4].map(() => call('qaState.backend.ts', 'countAndSleep', [1000])));
    report.fourParallelCallsMs = Date.now() - parallel;
    report.parallelPids = runs.map((run) => run.body.result?.data?.pid);
    report.timeout = await call('qaState.backend.ts', 'waitForever');
    if (label === 'after') {
        report.syncLoopTimeout = await call('qaState.backend.ts', 'spinForever');
    }
    // This app lives under /tmp, which a call may write to, so the probe targets a folder outside it.
    const projectFile = process.env.QA_OUTSIDE_TMP_FILE;
    report.sandbox = (await call('qaSandbox.backend.ts', 'runSandboxProbes', [projectFile])).body;
    report.projectFileExists = fs.existsSync(projectFile);
    fs.rmSync(projectFile, { force: true });
    const logCall = Date.now();
    const logged = await call('qaState.backend.ts', 'logSlowly', [1500]);
    const logLines = lines.filter(({ line }) => line.includes('qa first line') || line.includes('qa second line'));
    report.logs = {
        result: logged.body,
        lines: logLines.map(({ at, line }) => ({ msAfterCallStart: at - logCall, line: line.trim() })),
        callMs: logged.ms,
    };
    report.actionWithoutConnection = await call('qaActions.backend.ts', 'httpWithoutConnection');
    report.actionWithUnlistedConnection = await call('qaActions.backend.ts', 'httpWithUnlistedConnection');
} catch (error) {
    report.error = String(error);
} finally {
    report.serverLines = lines.filter(({ line }) => /datadog-apps-plugin|error|Executing/i.test(line)).slice(-15).map(({ line }) => line);
    vite.kill('SIGTERM');
}
console.log(JSON.stringify(report, null, 2));
export QA_OUTSIDE_TMP_FILE=<a path outside /tmp and the app>
DD_SITE=<staging site> node qa-run.mjs ./qa-app 5961 after > after.json
// dev-verify-run.mjs
// Starts a QA app's dev server in dev-verify mode and runs one backend function through it.
// Usage: DD_SITE=<staging site> DD_API_KEY=... DD_APP_KEY=... node dev-verify-run.mjs <appDir> <port>
import { spawn } from 'node:child_process';
import path from 'node:path';

const [appDir, port] = process.argv.slice(2);
const base = `http://localhost:${port}`;
const output = [];
const vite = spawn(path.join(appDir, 'node_modules/.bin/vite'), ['--port', port, '--strictPort', '--mode', 'dev-verify'], {
    cwd: appDir,
    env: process.env,
    stdio: ['ignore', 'pipe', 'pipe'],
});
vite.stdout.on('data', (chunk) => output.push(String(chunk)));
vite.stderr.on('data', (chunk) => output.push(String(chunk)));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
try {
    for (let i = 0; ; i++) {
        try {
            await fetch(base);
            break;
        } catch {
            if (i > 120) throw new Error('dev server never came up');
            await sleep(500);
        }
    }
    const proxy = await (await fetch(`${base}/src/getGreeting.backend.ts`)).text();
    const functionName = proxy.match(/executeBackendFunction\("([^"]+\.getGreeting)"/)[1];
    const started = Date.now();
    const response = await fetch(`${base}/__dd/executeAction`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ functionName, args: ['dev-verify'] }),
    });
    console.log(JSON.stringify({ status: response.status, ms: Date.now() - started, body: await response.json() }));
    const log = output.join('').replace(/\x1b\[[0-9;]*m/g, '');
    console.log(log.split('\n').filter((line) => /Executing action|preview-async|child process/.test(line)).join('\n'));
} finally {
    vite.kill('SIGTERM');
}
{"status":200,"ms":1997,"body":{"success":true,"result":{"data":"Hello, dev-verify! This greeting ran as a backend function."}}}
[Qa App|debug|vite|apps] Executing action via cloud: src/getGreeting/getGreeting with args
[Qa App|debug|vite|apps] Calling Datadog API: https://api.<staging site>/api/v2/app-builder/queries/preview-async

Blast Radius

  • Local development only: /__dd/executeAction; builds, deploys and dev:verify are unchanged.
  • No feature flag. Medium risk: a function relying on module state or the developer's environment now fails locally, as in production; each call adds about 100-150 ms.
  • A Yarn Plug'n'Play project can't run backend functions locally; the warning points to nodeLinker: node-modules or dev:verify.

Out of Scope / Follow-ups

10 items deferred
Item Status Next step
A Yarn Plug'n'Play project can't run backend functions locally: a call's process doesn't get Yarn's NODE_OPTIONS hooks. Deferred Forward Yarn's hooks to the child if Plug'n'Play users need it.
A call in a synchronous loop outlives a dev server that dies without exiting, such as by kill -9 or an unhandled signal. Deferred Add a watcher process if this shows up.
Output isn't bounded: lines wait for a newline, and a slow terminal queues output. Deferred Split long lines and handle backpressure.
The plugin still turns SSR import warmup off and warns about it, though each call now starts with an empty module cache. Deferred Drop the override and both warnings, or restate a reason that still holds.
index.test.ts justifies the ssr.noExternal entries by server.ssrLoadModule, which local execution doesn't call. Deferred Re-check the entries against the child's module runner and restate the test.
A call's process gets only its heap cap, and --inspect while debugging, not the dev server's NODE_OPTIONS or flags such as --enable-source-maps. Deferred Forward an allowlist of flags if one is needed.
vite/module-runner resolves from the project root, not the running Vite, so a framework-owned Vite under pnpm's strict layout gets none, and a second hoisted Vite gets a mismatched one. Deferred Resolve it from the running Vite's location.
Calls from every dev server in the process share one module-global queue. Independent #603 removes the queue.
network-guard.ts's header still describes guarding calls inside the dev server's process. Independent #563 deletes the guard.
Ending a dev server's calls on close patches server.close, which isn't plugin API. Deferred Move to a documented close hook if Vite adds one.

Documentation

🤖 Generated with Claude Code

@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Live Debugger Runtime Benchmark

SDK-loaded dormant-probe runtime overhead, measured against an uninstrumented bundle in the same browser session.

Browser Workload Quality Per-call overhead upper
chrome Hot clean <= 3.65 ns
chrome Tiny clean <= 0.01 ns
firefox Hot clean <= 7.63 ns
firefox Tiny clean <= 0.15 ns
safari Hot clean <= 4.10 ns
safari Tiny clean <= 0.75 ns

What do the Tiny and Hot workloads represent?

Browser Debugger SDK: 7.16.2 · built 2026-10-27 · S3 ETag 872de873

Full diagnostics
browser  workload  quality  per-call overhead upper  overhead upper          95% CI        A/A diag        block CI  acf(1)   baseline  instrumented                         samples
-------  --------  -------  -----------------------  --------------  --------------  --------------  --------------  ------  ---------  ------------  ------------------------------
chrome   Hot       clean                 <= 3.65 ns        <= 8.45%   3.63..3.65 ns  -0.01..0.01 ns   3.63..3.65 ns    0.02  51.075 ms     55.390 ms   102 (trim 20%, outliers 7.8%)
chrome   Tiny      clean                 <= 0.01 ns        <= 0.17%  -0.03..0.01 ns  -0.02..0.02 ns  -0.05..0.01 ns   -0.22  54.303 ms     54.275 ms  102 (trim 20%, outliers 13.7%)
firefox  Hot       clean                 <= 7.63 ns       <= 35.79%   7.61..7.63 ns  -0.00..0.00 ns   7.61..7.63 ns   -0.01  40.720 ms     55.280 ms   102 (trim 20%, outliers 5.9%)
firefox  Tiny      clean                 <= 0.15 ns        <= 1.22%   0.14..0.15 ns  -0.01..0.00 ns   0.14..0.15 ns   -0.11  55.060 ms     55.700 ms   102 (trim 20%, outliers 6.9%)
safari   Hot       clean                 <= 4.10 ns       <= 19.44%   4.09..4.10 ns  -0.00..0.01 ns   4.09..4.10 ns    0.18  46.300 ms     55.300 ms   102 (trim 20%, outliers 4.9%)
safari   Tiny      clean                 <= 0.75 ns       <= 23.04%   0.73..0.75 ns  -0.00..0.00 ns   0.72..0.75 ns    0.03  44.980 ms     55.240 ms   102 (trim 20%, outliers 2.0%)

Raw samples are in the live-debugger-runtime-bench-results artifact.

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from b0d7fe4 to 5bb05d3 Compare October 21, 2026 16:04
@tyffical
tyffical changed the base branch from tiffany.trinh/apps-2961-vite-version-matrix to tiffany.trinh/apps-2961-shared-execution-helpers October 21, 2026 16:04
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from 5bb05d3 to 4c9b050 Compare October 21, 2026 19:40
@tyffical
tyffical requested a balanced review from Copilot October 21, 2026 20:07
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@tyffical
tyffical marked this pull request as ready for review October 21, 2026 20:07
@tyffical
tyffical requested review from a team as code owners October 21, 2026 20:07
@tyffical
tyffical requested review from sdkennedy2 and removed request for a team October 21, 2026 20:07
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 21, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T00:24:42.052932Z e842184 Manual request
ℹ️ 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" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector

Copy link
Copy Markdown

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

Reviewed commit: 4c9b050140

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Shutdown cleanup can leave children running, and linked-package dependencies outside the app’s workspace can be incorrectly blocked.

2 open issues
What changed in this PR

Moves Vite 6+ local backend calls into fresh child processes, improving production parity while retaining Vite 5’s in-process fallback. Parallel execution remains a follow-up.

Changes:

  • Adds process isolation, permissions, timeouts, and streamed logs.
  • Routes module loading and authenticated actions through the dev server.
  • Adds integration coverage, fixtures, packaging, and documentation.
File Description
yarn.lock Locks new matching dependencies.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childVirtual.backend.ts Tests virtual module imports.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childTopLevelWriteHelper.ts Probes load-time writes.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childTopLevelWrite.backend.ts Exercises load-time guards.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childState.backend.ts Exercises isolation, limits, and logs.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childSlowLoad.backend.ts Exercises loading timeouts.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childSandboxProbes.ts Probes permissions and module requests.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childSandbox.backend.ts Exposes sandbox test functions.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childLoadTime.backend.ts Exercises runtime-context initialization.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childLaterImport.backend.ts Exercises missing imports.
packages/​tests/​src/​_jest/​fixtures/​apps_backend_project/​childActions.backend.ts Exercises action validation.
packages/​plugins/​apps/​src/​vite/​local-execution.ts Exports the shared execution queue.
packages/​plugins/​apps/​src/​vite/​local-execution-protocol.ts Defines parent-child messages.
packages/​plugins/​apps/​src/​vite/​local-execution-process.ts Implements child orchestration and checks.
packages/​plugins/​apps/​src/​vite/​local-execution-process.test.ts Covers child execution behavior.
packages/​plugins/​apps/​src/​vite/​local-execution-process.fixtures.ts Builds the test child entry.
packages/​plugins/​apps/​src/​vite/​local-execution-child.ts Executes functions and forwards actions.
packages/​plugins/​apps/​src/​vite/​local-execution-child-path.ts Locates the bundled child entry.
packages/​plugins/​apps/​src/​vite/​index.ts Selects and configures the executor.
packages/​plugins/​apps/​src/​vite/​index.test.ts Tests executor selection and fallback.
packages/​plugins/​apps/​src/​vite/​dev-server.ts Delegates local calls to the executor.
packages/​plugins/​apps/​src/​vite/​dev-server.test.ts Tests executor delegation.
packages/​plugins/​apps/​src/​vite/​dev-server.integration.test.ts Verifies fresh processes through middleware.
packages/​plugins/​apps/​src/​vite/​dev-server-module-graph.ts Makes stale-module uncaching configurable.
packages/​plugins/​apps/​src/​vite/​dev-server-module-graph.test.ts Tests child-mode cache handling.
packages/​plugins/​apps/​README.md Documents isolation, limits, and debugging.
packages/​plugins/​apps/​package.json Adds child packaging and matching dependencies.

Review effort: Balanced

Comment thread packages/plugins/apps/src/vite/local-execution-process.ts
Comment thread packages/plugins/apps/src/vite/local-execution-process.ts Outdated
@tyffical
tyffical requested a review from Scott-Meyer October 21, 2026 20:17
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch 2 times, most recently from aa5b6d7 to ba6b653 Compare October 22, 2026 02:01
@tyffical
tyffical requested a balanced review from Copilot October 22, 2026 06:08
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The sandbox and process lifecycle changes warrant human review, and unbounded output buffering remains an unresolved dev-server reliability risk.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread packages/plugins/apps/src/vite/local-execution-process.ts Outdated

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

ℹ️ 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 thread packages/plugins/apps/src/vite/local-execution-process.ts
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-shared-execution-helpers branch 2 times, most recently from 9a36cab to b6220fb Compare October 26, 2026 22:30
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from 4384bc9 to 6c426e6 Compare October 26, 2026 23:04
@datadog-prod-us1-3

datadog-prod-us1-3 Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Pipelines  Tests

🎉 All green!

🧪 All tests passed
❄️ No new flaky tests detected

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: 8796b07 | Docs | View more details | Give us feedback!

@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-shared-execution-helpers branch from b6220fb to 5721b26 Compare October 26, 2026 23:28
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from 6c426e6 to e842184 Compare October 26, 2026 23:57
@tyffical
tyffical requested a balanced review from Copilot October 27, 2026 00:20
@tyffical

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: e842184c53

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Callback-reported process messaging failures bypass the failure handler and can leave calls waiting indefinitely while debugging.

1 open issue
2 resolved since last review

Review effort: Balanced

Comment thread packages/plugins/apps/src/vite/local-execution-process.ts Outdated
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from e842184 to 44a23e7 Compare October 27, 2026 00:35
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-shared-execution-helpers branch from 5721b26 to 74f28bc Compare October 27, 2026 20:16
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from 44a23e7 to 9f367ef Compare October 27, 2026 20:16
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-shared-execution-helpers branch from 74f28bc to 11b2000 Compare October 28, 2026 05:39
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from 9f367ef to c78647b Compare October 28, 2026 05:39
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-shared-execution-helpers branch from 11b2000 to f08b751 Compare October 28, 2026 16:12
@tyffical
tyffical force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from c78647b to b750a1b Compare October 28, 2026 16:12
/__dd/executeAction forks a Node process per call, running the project's
Vite module runner, which loads modules from the dev server over IPC.
Each call starts with fresh module state. Calls still run one at a time.

Each process runs under the guards, which refuse writes outside its temp
dir and /tmp, subprocesses and worker threads. Its heap is capped at
256 MB and its environment holds no developer variables or keys.
$.Actions calls go back to the dev server, which keeps the credentials and
checks each call's connection. Output streams to the terminal line by line.
A call past its time budget is killed, even in a synchronous loop, and the
dev server kills its calls when it closes, restarts or exits.

The in-process executor and its fallbacks are gone. A Yarn Plug'n'Play
dev server, whose packages load through hooks a call's process doesn't
get, fails each call with a hint to use a node_modules install or
dev:verify.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot force-pushed the tiffany.trinh/apps-2961-child-process-execution branch from b750a1b to 8796b07 Compare October 28, 2026 16:34
@tyffical
tyffical removed this pull request from stack #623 October 28, 2026 16:38
Base automatically changed from tiffany.trinh/apps-2961-shared-execution-helpers to master October 28, 2026 16:57
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot merged commit f26a511 into master Oct 28, 2026
9 checks passed
@gh-worker-dd-mergequeue-cf854d
gh-worker-dd-mergequeue-cf854d Bot deleted the tiffany.trinh/apps-2961-child-process-execution branch October 28, 2026 16:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants