test-helpers.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499
  1. // Shared harness for runtime integration tests. Materializes a real
  2. // on-disk bundle, spawns a real Deno isolate via the same `bunChildSpawn`
  3. // adapter the production bootstrap uses, runs ONE invocation, returns the
  4. // `InvokeResult` plus a teardown callback.
  5. //
  6. // Tasks 17–22 all share this helper. Keep it test-agnostic: no test-name
  7. // branching, no hard-coded fixtures beyond the "@briven/cli/server" stub.
  8. import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
  9. import { tmpdir } from 'node:os';
  10. import { join, resolve } from 'node:path';
  11. import { spawn as bunSpawn } from 'bun';
  12. import { IsolatePoolImpl, type SpawnFn, type SpawnedChild } from '../../src/pool-manager.js';
  13. import type { Bundle, InvokeRequest, InvokeResult } from '../../src/types.js';
  14. // Resolve the Deno binary at fixture time (not module load) so each test
  15. // can override via env. Falls through to the default install path on the
  16. // dev machine, then to bare `deno` on PATH (CI).
  17. function resolveDenoPath(): string {
  18. return process.env.BRIVEN_RUNTIME_DENO_PATH ?? '/Users/flndrn/.deno/bin/deno';
  19. }
  20. export interface FixtureOpts {
  21. /** Source code for the customer function. Written to briven/functions/<fnName>.ts */
  22. fnSource: string;
  23. /** Function name (matches the export and the file name). */
  24. fnName: string;
  25. /** Deployment ID echoed in the ready handshake. */
  26. deploymentId: string;
  27. /** Optional: extra config overrides on the pool. */
  28. poolConfig?: Partial<{
  29. invocationTimeoutMs: number;
  30. idleKillMs: number;
  31. maxInvocationsPerIsolate: number;
  32. maxMemoryMb: number;
  33. maxIsolates: number;
  34. crashLoopThreshold: number;
  35. crashLoopWindowMs: number;
  36. }>;
  37. /** Optional: surface isolate stderr lines (for debugging a failing test). */
  38. onLog?: (line: unknown, projectId: string) => void;
  39. /** Optional: project env vars exposed via Deno.env (allow-env list). */
  40. projectEnv?: Record<string, string>;
  41. }
  42. export interface FixtureResult {
  43. result: InvokeResult;
  44. pool: IsolatePoolImpl;
  45. cleanup: () => Promise<void>;
  46. }
  47. /**
  48. * Materialize a fake bundle on disk, construct a pool against the real
  49. * Deno binary, run one invoke, return the result + the pool (for tests
  50. * that want to inspect state) + a cleanup callback.
  51. *
  52. * The caller MUST call `cleanup()` even on error (use try/finally).
  53. */
  54. export async function runIntegrationFixture(opts: FixtureOpts): Promise<FixtureResult> {
  55. const workDir = await mkdtemp(join(tmpdir(), 'briven-int-'));
  56. const bundleDir = join(workDir, 'bundle');
  57. const isolateBase = join(workDir, 'isolates');
  58. const runtimeStubDir = join(workDir, 'stub');
  59. // Vendor the real isolate-runtime stubs into the work dir. The
  60. // materializer will copy them again into the per-isolate tmp dir, but
  61. // it expects them to live under `runtimeStubDir`.
  62. const realStubDir = resolve(import.meta.dir, '..', '..', 'src', 'isolate-runtime');
  63. await mkdir(runtimeStubDir, { recursive: true });
  64. for (const f of ['loop.ts', 'server.ts', 'types.ts']) {
  65. await copyFile(join(realStubDir, f), join(runtimeStubDir, f));
  66. }
  67. // Customer function source.
  68. await mkdir(join(bundleDir, 'functions'), { recursive: true });
  69. await writeFile(join(bundleDir, 'functions', `${opts.fnName}.ts`), opts.fnSource);
  70. await mkdir(isolateBase, { recursive: true });
  71. const bundle: Bundle = {
  72. projectId: 'p-int',
  73. deploymentId: opts.deploymentId,
  74. functionNames: [opts.fnName],
  75. directory: bundleDir,
  76. };
  77. const request: InvokeRequest = {
  78. projectId: 'p-int',
  79. functionName: opts.fnName,
  80. args: {},
  81. deploymentId: opts.deploymentId,
  82. requestId: `req-${Date.now()}`,
  83. auth: null,
  84. };
  85. const denoPath = resolveDenoPath();
  86. const spawn = makeBunChildSpawn(denoPath);
  87. const pool = new IsolatePoolImpl({
  88. spawn,
  89. runtimeStubDir,
  90. isolateBaseDir: isolateBase,
  91. maxIsolates: opts.poolConfig?.maxIsolates ?? 50,
  92. maxMemoryMb: opts.poolConfig?.maxMemoryMb ?? 128,
  93. invocationTimeoutMs: opts.poolConfig?.invocationTimeoutMs ?? 30_000,
  94. idleKillMs: opts.poolConfig?.idleKillMs ?? 10 * 60_000,
  95. maxInvocationsPerIsolate: opts.poolConfig?.maxInvocationsPerIsolate ?? 1000,
  96. crashLoopThreshold: opts.poolConfig?.crashLoopThreshold ?? 3,
  97. crashLoopWindowMs: opts.poolConfig?.crashLoopWindowMs ?? 60_000,
  98. runQueryProxy: async () => [],
  99. onLog: (line, projectId) => {
  100. if (opts.onLog) opts.onLog(line, projectId);
  101. },
  102. loadProjectEnv: async () => opts.projectEnv ?? {},
  103. denoPath,
  104. });
  105. const result = await pool.invoke(bundle, request);
  106. const cleanup = async () => {
  107. await pool.shutdown();
  108. await rm(workDir, { recursive: true, force: true });
  109. };
  110. return { result, pool, cleanup };
  111. }
  112. /**
  113. * Adapter that maps Bun's `spawn` API onto the `SpawnFn` interface the
  114. * pool expects. Mirrors `apps/runtime/src/runtime-bootstrap.ts` —
  115. * intentionally duplicated for now (Phase 2 may extract a shared helper).
  116. */
  117. function makeBunChildSpawn(denoPath: string): SpawnFn {
  118. return async ({ args, env: childEnv, cwd }) => {
  119. const proc = bunSpawn({
  120. cmd: [denoPath, ...args],
  121. cwd,
  122. env: childEnv,
  123. stdin: 'pipe',
  124. stdout: 'pipe',
  125. stderr: 'pipe',
  126. });
  127. const stdoutLines = lineIterator(proc.stdout as ReadableStream<Uint8Array>);
  128. const stderrLines = lineIterator(proc.stderr as ReadableStream<Uint8Array>);
  129. const child: SpawnedChild = {
  130. pid: proc.pid,
  131. stdin: {
  132. write: async (line: string) => {
  133. const n = proc.stdin.write(line);
  134. await proc.stdin.flush();
  135. return typeof n === 'number' ? n > 0 : Boolean(n);
  136. },
  137. end: () => proc.stdin.end(),
  138. },
  139. stdout: {
  140. next: () => stdoutLines.next().then((r) => (r.done ? null : r.value)),
  141. },
  142. stderr: {
  143. next: () => stderrLines.next().then((r) => (r.done ? null : r.value)),
  144. },
  145. wait: async () => {
  146. const exitCode = await proc.exited;
  147. return { exitCode, signal: null };
  148. },
  149. kill: (signal: string) => proc.kill(signal as never),
  150. };
  151. return child;
  152. };
  153. }
  154. // ---------------------------------------------------------------------------
  155. // Multi-invocation helpers — used by Tasks 20–22.
  156. //
  157. // All three reuse the same per-test scratch dir layout `runIntegrationFixture`
  158. // builds (one workDir, one runtimeStubDir, one isolateBase). They diverge only
  159. // on how many bundles they materialize and how many invokes they fire against
  160. // the same pool.
  161. // ---------------------------------------------------------------------------
  162. /** Vendor stub files into runtimeStubDir. Mirrors `runIntegrationFixture`. */
  163. async function vendorRuntimeStubs(runtimeStubDir: string): Promise<void> {
  164. await mkdir(runtimeStubDir, { recursive: true });
  165. const realStubDir = resolve(import.meta.dir, '..', '..', 'src', 'isolate-runtime');
  166. for (const f of ['loop.ts', 'server.ts', 'types.ts']) {
  167. await copyFile(join(realStubDir, f), join(runtimeStubDir, f));
  168. }
  169. }
  170. /**
  171. * Wrap a SpawnFn so each spawn's `child.pid` is recorded into `sink`.
  172. * Used by `runTwoSequentialInvocations` and `runIdleKillFixture` to prove
  173. * a respawn happened (different PID).
  174. */
  175. function withPidRecorder(inner: SpawnFn, sink: number[]): SpawnFn {
  176. return async (opts) => {
  177. const child = await inner(opts);
  178. sink.push(child.pid);
  179. return child;
  180. };
  181. }
  182. export interface TwoInvocationsOpts {
  183. first: { fnSource: string; fnName: string; deploymentId: string };
  184. second: { fnSource: string; fnName: string; deploymentId: string };
  185. poolConfig?: FixtureOpts['poolConfig'];
  186. }
  187. export interface TwoInvocationsResult {
  188. first: InvokeResult;
  189. second: InvokeResult;
  190. /** PIDs observed across the two invocations. Should be 2 distinct values for deploy invalidation. */
  191. pidsObserved: number[];
  192. cleanup: () => Promise<void>;
  193. }
  194. /**
  195. * Two sequential invocations against the SAME pool/projectId, with two
  196. * different `deploymentId`s and two separately-materialized bundles. The
  197. * second invocation triggers deploy-invalidation: the first isolate is
  198. * retired and a fresh one cold-starts. PIDs are tracked via a wrapped
  199. * SpawnFn so the test can assert two distinct PIDs.
  200. */
  201. export async function runTwoSequentialInvocations(
  202. opts: TwoInvocationsOpts,
  203. ): Promise<TwoInvocationsResult> {
  204. const workDir = await mkdtemp(join(tmpdir(), 'briven-int-'));
  205. const bundleDirA = join(workDir, 'bundle-a');
  206. const bundleDirB = join(workDir, 'bundle-b');
  207. const isolateBase = join(workDir, 'isolates');
  208. const runtimeStubDir = join(workDir, 'stub');
  209. await vendorRuntimeStubs(runtimeStubDir);
  210. await mkdir(join(bundleDirA, 'functions'), { recursive: true });
  211. await writeFile(join(bundleDirA, 'functions', `${opts.first.fnName}.ts`), opts.first.fnSource);
  212. await mkdir(join(bundleDirB, 'functions'), { recursive: true });
  213. await writeFile(join(bundleDirB, 'functions', `${opts.second.fnName}.ts`), opts.second.fnSource);
  214. await mkdir(isolateBase, { recursive: true });
  215. const denoPath = resolveDenoPath();
  216. const pidsObserved: number[] = [];
  217. const spawn = withPidRecorder(makeBunChildSpawn(denoPath), pidsObserved);
  218. const pool = new IsolatePoolImpl({
  219. spawn,
  220. runtimeStubDir,
  221. isolateBaseDir: isolateBase,
  222. maxIsolates: opts.poolConfig?.maxIsolates ?? 50,
  223. maxMemoryMb: opts.poolConfig?.maxMemoryMb ?? 128,
  224. invocationTimeoutMs: opts.poolConfig?.invocationTimeoutMs ?? 30_000,
  225. idleKillMs: opts.poolConfig?.idleKillMs ?? 10 * 60_000,
  226. maxInvocationsPerIsolate: opts.poolConfig?.maxInvocationsPerIsolate ?? 1000,
  227. crashLoopThreshold: opts.poolConfig?.crashLoopThreshold ?? 3,
  228. crashLoopWindowMs: opts.poolConfig?.crashLoopWindowMs ?? 60_000,
  229. runQueryProxy: async () => [],
  230. onLog: () => {},
  231. loadProjectEnv: async () => ({}),
  232. denoPath,
  233. });
  234. const bundleA: Bundle = {
  235. projectId: 'p-int',
  236. deploymentId: opts.first.deploymentId,
  237. functionNames: [opts.first.fnName],
  238. directory: bundleDirA,
  239. };
  240. const requestA: InvokeRequest = {
  241. projectId: 'p-int',
  242. functionName: opts.first.fnName,
  243. args: {},
  244. deploymentId: opts.first.deploymentId,
  245. requestId: `req-${Date.now()}-a`,
  246. auth: null,
  247. };
  248. const first = await pool.invoke(bundleA, requestA);
  249. const bundleB: Bundle = {
  250. projectId: 'p-int',
  251. deploymentId: opts.second.deploymentId,
  252. functionNames: [opts.second.fnName],
  253. directory: bundleDirB,
  254. };
  255. const requestB: InvokeRequest = {
  256. projectId: 'p-int',
  257. functionName: opts.second.fnName,
  258. args: {},
  259. deploymentId: opts.second.deploymentId,
  260. requestId: `req-${Date.now()}-b`,
  261. auth: null,
  262. };
  263. const second = await pool.invoke(bundleB, requestB);
  264. const cleanup = async () => {
  265. await pool.shutdown();
  266. await rm(workDir, { recursive: true, force: true });
  267. };
  268. return { first, second, pidsObserved, cleanup };
  269. }
  270. export interface RepeatedFixtureOpts {
  271. fnName: string;
  272. fnSource: string;
  273. deploymentId: string;
  274. count: number;
  275. poolConfig?: FixtureOpts['poolConfig'];
  276. }
  277. export interface RepeatedFixtureResult {
  278. results: InvokeResult[];
  279. cleanup: () => Promise<void>;
  280. }
  281. /**
  282. * Run `count` sequential invocations against the same pool, projectId,
  283. * deploymentId, and bundle. Used by the crash-loop breaker test so the
  284. * breaker history accumulates across all calls.
  285. */
  286. export async function runFixtureRepeated(
  287. opts: RepeatedFixtureOpts,
  288. ): Promise<RepeatedFixtureResult> {
  289. const workDir = await mkdtemp(join(tmpdir(), 'briven-int-'));
  290. const bundleDir = join(workDir, 'bundle');
  291. const isolateBase = join(workDir, 'isolates');
  292. const runtimeStubDir = join(workDir, 'stub');
  293. await vendorRuntimeStubs(runtimeStubDir);
  294. await mkdir(join(bundleDir, 'functions'), { recursive: true });
  295. await writeFile(join(bundleDir, 'functions', `${opts.fnName}.ts`), opts.fnSource);
  296. await mkdir(isolateBase, { recursive: true });
  297. const denoPath = resolveDenoPath();
  298. const spawn = makeBunChildSpawn(denoPath);
  299. const pool = new IsolatePoolImpl({
  300. spawn,
  301. runtimeStubDir,
  302. isolateBaseDir: isolateBase,
  303. maxIsolates: opts.poolConfig?.maxIsolates ?? 50,
  304. maxMemoryMb: opts.poolConfig?.maxMemoryMb ?? 128,
  305. invocationTimeoutMs: opts.poolConfig?.invocationTimeoutMs ?? 30_000,
  306. idleKillMs: opts.poolConfig?.idleKillMs ?? 10 * 60_000,
  307. maxInvocationsPerIsolate: opts.poolConfig?.maxInvocationsPerIsolate ?? 1000,
  308. crashLoopThreshold: opts.poolConfig?.crashLoopThreshold ?? 3,
  309. crashLoopWindowMs: opts.poolConfig?.crashLoopWindowMs ?? 60_000,
  310. runQueryProxy: async () => [],
  311. onLog: () => {},
  312. loadProjectEnv: async () => ({}),
  313. denoPath,
  314. });
  315. const bundle: Bundle = {
  316. projectId: 'p-int',
  317. deploymentId: opts.deploymentId,
  318. functionNames: [opts.fnName],
  319. directory: bundleDir,
  320. };
  321. const results: InvokeResult[] = [];
  322. for (let i = 0; i < opts.count; i++) {
  323. const request: InvokeRequest = {
  324. projectId: 'p-int',
  325. functionName: opts.fnName,
  326. args: {},
  327. deploymentId: opts.deploymentId,
  328. requestId: `req-${Date.now()}-${i}`,
  329. auth: null,
  330. };
  331. results.push(await pool.invoke(bundle, request));
  332. }
  333. const cleanup = async () => {
  334. await pool.shutdown();
  335. await rm(workDir, { recursive: true, force: true });
  336. };
  337. return { results, cleanup };
  338. }
  339. export interface IdleKillFixtureOpts {
  340. fnSource: string;
  341. fnName: string;
  342. deploymentId: string;
  343. /** Idle threshold in ms; the helper waits 2x this between invokes before triggering the sweeper. */
  344. idleKillMs: number;
  345. }
  346. export interface IdleKillFixtureResult {
  347. firstPid: number;
  348. secondPid: number;
  349. cleanup: () => Promise<void>;
  350. }
  351. /**
  352. * Two sequential invocations against the same pool with the idle sweeper
  353. * triggered between them, proving the first isolate gets retired and the
  354. * second invocation cold-starts a fresh process.
  355. */
  356. export async function runIdleKillFixture(
  357. opts: IdleKillFixtureOpts,
  358. ): Promise<IdleKillFixtureResult> {
  359. const workDir = await mkdtemp(join(tmpdir(), 'briven-int-'));
  360. const bundleDir = join(workDir, 'bundle');
  361. const isolateBase = join(workDir, 'isolates');
  362. const runtimeStubDir = join(workDir, 'stub');
  363. await vendorRuntimeStubs(runtimeStubDir);
  364. await mkdir(join(bundleDir, 'functions'), { recursive: true });
  365. await writeFile(join(bundleDir, 'functions', `${opts.fnName}.ts`), opts.fnSource);
  366. await mkdir(isolateBase, { recursive: true });
  367. const denoPath = resolveDenoPath();
  368. const pidsObserved: number[] = [];
  369. const spawn = withPidRecorder(makeBunChildSpawn(denoPath), pidsObserved);
  370. const pool = new IsolatePoolImpl({
  371. spawn,
  372. runtimeStubDir,
  373. isolateBaseDir: isolateBase,
  374. maxIsolates: 50,
  375. maxMemoryMb: 128,
  376. invocationTimeoutMs: 30_000,
  377. idleKillMs: opts.idleKillMs,
  378. maxInvocationsPerIsolate: 1000,
  379. crashLoopThreshold: 3,
  380. crashLoopWindowMs: 60_000,
  381. runQueryProxy: async () => [],
  382. onLog: () => {},
  383. loadProjectEnv: async () => ({}),
  384. denoPath,
  385. });
  386. const bundle: Bundle = {
  387. projectId: 'p-int',
  388. deploymentId: opts.deploymentId,
  389. functionNames: [opts.fnName],
  390. directory: bundleDir,
  391. };
  392. const makeRequest = (suffix: string): InvokeRequest => ({
  393. projectId: 'p-int',
  394. functionName: opts.fnName,
  395. args: {},
  396. deploymentId: opts.deploymentId,
  397. requestId: `req-${Date.now()}-${suffix}`,
  398. auth: null,
  399. });
  400. const first = await pool.invoke(bundle, makeRequest('a'));
  401. if (!first.ok) {
  402. await pool.shutdown();
  403. await rm(workDir, { recursive: true, force: true });
  404. throw new Error(
  405. `idle-kill helper: first invoke failed with code=${first.code} message=${first.message}`,
  406. );
  407. }
  408. const firstPid = pidsObserved[0] ?? -1;
  409. // Wait long enough that the entry's lastActivityAt is older than idleKillMs.
  410. await new Promise((r) => setTimeout(r, Math.max(opts.idleKillMs * 2, 50)));
  411. await pool.triggerIdleCheck();
  412. const second = await pool.invoke(bundle, makeRequest('b'));
  413. if (!second.ok) {
  414. await pool.shutdown();
  415. await rm(workDir, { recursive: true, force: true });
  416. throw new Error(
  417. `idle-kill helper: second invoke failed with code=${second.code} message=${second.message}`,
  418. );
  419. }
  420. const secondPid = pidsObserved[1] ?? -1;
  421. const cleanup = async () => {
  422. await pool.shutdown();
  423. await rm(workDir, { recursive: true, force: true });
  424. };
  425. return { firstPid, secondPid, cleanup };
  426. }
  427. async function* lineIterator(
  428. stream: ReadableStream<Uint8Array>,
  429. ): AsyncGenerator<string, void, void> {
  430. const reader = stream.getReader();
  431. const decoder = new TextDecoder();
  432. let buf = '';
  433. while (true) {
  434. const { value, done } = await reader.read();
  435. if (done) {
  436. if (buf.length > 0) yield buf;
  437. return;
  438. }
  439. buf += decoder.decode(value, { stream: true });
  440. let nl: number;
  441. while ((nl = buf.indexOf('\n')) !== -1) {
  442. const line = buf.slice(0, nl);
  443. buf = buf.slice(nl + 1);
  444. if (line) yield line;
  445. }
  446. }
  447. }