Skip to content

feat(server): ephemeral child process for test suite execution - #225

Open
07prajwal2000 wants to merge 1 commit into
Fluxify-rest:mainfrom
07prajwal2000:feat/test-suite-child-process
Open

feat(server): ephemeral child process for test suite execution#225
07prajwal2000 wants to merge 1 commit into
Fluxify-rest:mainfrom
07prajwal2000:feat/test-suite-child-process

Conversation

@07prajwal2000

@07prajwal2000 07prajwal2000 commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Part 3 of 6 — sandboxed test suite runner. Refs #217.

Why

Test suites execute in the admin API's own process today. A route with an infinite loop, a leaked global or a crash takes the admin server down with it, and module state bleeds from one suite into the next. This adds the machinery to run one suite in a process that is spawned, used once, and killed.

What

file role
testRunner/types.ts TestBootstrap / TestResult messages
testRunner/compile.ts route + project custom blocks → compiled source
testRunner/testExecutionProcess.ts the child entry point
testRunner/spawn.ts runSuiteInChild + spawnCommand (rlimits, watchdog)
compiler/service.ts loadGraph exported

The child reuses executeRouteInternal

The issue sketched the child rebuilding the execution context the way initCompiledRuntime and setupContextVars do. It doesn't — it hydrates the loader caches from the bootstrap and then calls the existing executeRouteInternal. Schema validation, context vars, the db factory and ctx.stopper.duration all come along, and there is no second copy of any of it to drift.

Two consequences worth calling out:

  • Overrides never cross the wire. resolveSuiteConfig (Test runner (2/6): per-suite config resolution with overrides #216) already applied them to the payload, so no RequestOverrides is passed and assertOverridesOwned stays a parent-side concern.
  • Response headers now exist. The child builds a real createHttpContext, so setHeader/setCookie writes made by the route are captured and returned as result.headers. runner.ts:91 hardcoded resHeaders = {}, which meant every header assertion has been silently evaluating against undefined.

Custom blocks

The issue's bootstrap carried only source. A route that invokes a custom block would have thrown in the child — the interpreted path supports them today, so shipping without would have been a regression. compile.ts compiles the project's custom blocks alongside the route and the child calls registerCompiledCustomBlock before instantiating. A block that fails to compile is logged and skipped rather than failing the run, matching compiledRuntime.addCustomBlock.

Source comes from the database, not the artifact store

compileSuiteRoute calls loadGraph + compileGraph per run. Reading the published artifact would have been cheaper but would test the last deployed graph — edit a route, press Run, and you'd be testing the old version.

Resource limits

spawnCommand wraps the child in /bin/sh -c "ulimit -n …; ulimit -v …; exec \"$0\" --smol \"$1\"". Defaults 256 fds / 1 GB address space, both env-overridable, and an override that isn't a positive integer is refused — the value lands in a shell script. The interpreter and entry path are passed as $0/$1 rather than interpolated, so a path with a space or a quote can't change what runs. Windows skips the wrapper entirely (ulimit doesn't exist there) and still spawns.

The container-level ulimits.nofile + mem_limit: 2g backstop on the admin service already landed in #221.

Watchdog

setTimeout(timeoutMs + 2s)child.kill(), reported as timedOut with a duration and nothing else. No partial assertion detail is streamed out of a process about to die. The timer is cleared in a finally, so it can't hold the admin process open for the whole timeout. Unconditional — experimental.workerTimeouts governs live traffic, not tests.

Tests

tests/spawn.spec.ts — argv shape: posix caps applied, env overrides honoured, a non-integer override ("0; rm -rf /") refused, win32 bypasses the shell.

tests/suiteExecution.integration.spec.ts — real spawns, no mocks:

  • a graph compiled with compileGraph runs in the cold child; getConfig/getQueryParam/getHeader all resolve from the bootstrap and setHeader comes back in result.headers
  • await new Promise(() => {}) → killed, timedOut: true, parent unaffected
  • a throwing route → ok: false carrying the message

Out of scope

Nothing is wired to /test-suites/runrunSuiteAssertions still runs in-process. Orchestration and persistence are #219, concurrency limiting #218, endpoints #220.

Notes for #219

Edge handles are "source", not "input"/"output". A wrong handle compiles to an unconnected graph that returns "NO RESULT" with no error.

compile.ts compiles every custom block in the project rather than just the ones the route reaches (marked ponytail:) — narrow it by walking the graph for invocations if a large project makes runs slow.

Verification

bun test apps/server/src/modules/testRunner — 13 pass / 0 fail. Pre-commit chain green: lint 9/9, 664 pass / 0 fail across 121 files.

🤖 Generated with Claude Code

https://claude.ai/code/session_018pjR2Xuyxu5UTUDFo9smSd

Test suites run in the admin API's own process today, so a route with an
infinite loop or a crash takes the admin server with it and state bleeds
between suites. This adds the pieces to run one suite in a process that is
spawned, used once, and killed.

- testExecutionProcess.ts: the child. Hydrates the loader caches from the
  bootstrap, registers the project's compiled custom blocks, installs the
  compiled graph as the blocks executor, then calls the existing
  executeRouteInternal. Reusing it means validation, context vars, the db
  factory and the in-band stopper are not reimplemented — and the response
  headers the route writes are captured through a real Hono-shaped context,
  so header assertions have something to read.
- spawn.ts: fd and address-space caps via a `sh -c` wrapper (POSIX only —
  Windows spawns bun directly), --smol, and an unconditional watchdog that
  kills the child and reports `timedOut`. The timer is always cleared.
- compile.ts: compiles the route and the project's custom blocks from the
  database per run, so a suite tests the saved canvas rather than the last
  published artifact. loadGraph is exported for it.
- types.ts: the bootstrap and result messages. No system credentials cross
  the boundary — no encryption key, no NATS, no admin database url.

The child returns the raw response only; assertions stay in the parent so
there is one implementation of them. Nothing is wired to /test-suites/run
yet — that is the orchestrator's job (Fluxify-rest#217 follow-up).

Refs Fluxify-rest#217

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018pjR2Xuyxu5UTUDFo9smSd
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant