4a7324c0c8
Closes #22925 ## Problem `LocalChildProcessRunnerService.writeBootstrapRunner` generates a child-process runner that returns the function result to the parent over the Node IPC channel: ```js const out = await handlerFn(msg.payload); process.send && process.send({ ok: true, result: out }); process.exit(0); ``` `process.send()` is **asynchronous**. When the serialized payload is larger than the OS pipe buffer (~64 KB on Linux), it can't be written in a single synchronous step, and the `process.exit(0)` on the next line tears the child down before the message is flushed. On the parent side (`runChildWithEnv`), the lost message means the `'message'` handler never fires — only `'exit'` with `code === 0` does, which resolves: ```js resolve({ ok: true, stdout, stderr }); // no `result` ``` `LocalDriver.execute` then returns `data: result ?? null` → **`null`**, so the function's return value is silently discarded while the step is reported as a *success* with an empty result. ## Symptom Larger serverless / workflow **Code** step results intermittently come back empty (`{}` / `null`). It is size- and load-dependent, so it presents as flakiness. A common downstream failure is a workflow **Iterator** fed the now-missing array: ``` Iterator input items must be an array ``` Results smaller than the pipe buffer always flush synchronously and never reproduce it — which is why only larger payloads are affected. ## Root cause `process.exit()` runs before the asynchronous `process.send()` (and the stdout fallback `process.stdout.write()`) has flushed — the classic Node footgun of exiting before pending async writes drain. ## Fix Wait for the `send()` / `write()` flush callback before exiting, on every exit path (success, error, stdout fallback, outer catch): ```js if (process.send) { process.send({ ok: true, result: out }, () => process.exit(0)); } else { process.exit(0); } ``` Behavior-preserving: it never delivers *less* than before; it only closes the window where a large result is dropped. No change to the small-payload happy path. ## Deterministic reproduction Reproduces the dropped IPC message under back-pressure (parent stalls before draining), comparing the current pattern vs the fix: ```js const { spawn } = require('node:child_process'); const fs = require('fs'); const SIZE = 3_000_000; // beyond any pipe buffer const child = (mode) => ` process.on('message', () => { const out = 'z'.repeat(${SIZE}); ${mode === 'fixed' ? 'process.send({ ok: true, result: out }, () => process.exit(0));' : 'process.send({ ok: true, result: out }); process.exit(0);'} });`; const busy = (ms) => { const e = Date.now() + ms; while (Date.now() < e) {} }; function trial(mode) { return new Promise((resolve) => { const f = `/tmp/child_${mode}.cjs`; fs.writeFileSync(f, child(mode)); const c = spawn(process.execPath, [f], { stdio: ['pipe', 'pipe', 'pipe', 'ipc'] }); let got = false; c.on('message', (m) => { got = m?.result?.length === SIZE; }); c.on('exit', () => resolve(got)); c.send({ type: 'run' }); busy(30); // stall parent so it doesn't drain the IPC pipe promptly }); } (async () => { for (const mode of ['current', 'fixed']) { let ok = 0; const N = 25; for (let i = 0; i < N; i += 5) { ok += (await Promise.all([...Array(5)].map(() => trial(mode)))).filter(Boolean).length; } console.log(`${mode}: result delivered ${ok}/${N}`); } })(); ``` Output: ``` current: result delivered 0/25 fixed: result delivered 25/25 ``` <!-- This is an auto-generated description by cubic. --> <a href="https://cubic.dev/pr/twentyhq/twenty/pull/22920?utm_source=github" target="_blank" rel="noopener noreferrer" data-no-image-dialog="true"><picture><source media="(prefers-color-scheme: dark)" srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source media="(prefers-color-scheme: light)" srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img alt="Review in cubic" src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a> <!-- End of auto-generated description by cubic. -->