feat(server): ephemeral child process for test suite execution - #225
Open
07prajwal2000 wants to merge 1 commit into
Open
feat(server): ephemeral child process for test suite execution#22507prajwal2000 wants to merge 1 commit into
07prajwal2000 wants to merge 1 commit into
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
testRunner/types.tsTestBootstrap/TestResultmessagestestRunner/compile.tstestRunner/testExecutionProcess.tstestRunner/spawn.tsrunSuiteInChild+spawnCommand(rlimits, watchdog)compiler/service.tsloadGraphexportedThe child reuses
executeRouteInternalThe issue sketched the child rebuilding the execution context the way
initCompiledRuntimeandsetupContextVarsdo. It doesn't — it hydrates the loader caches from the bootstrap and then calls the existingexecuteRouteInternal. Schema validation, context vars, the db factory andctx.stopper.durationall come along, and there is no second copy of any of it to drift.Two consequences worth calling out:
resolveSuiteConfig(Test runner (2/6): per-suite config resolution with overrides #216) already applied them to the payload, so noRequestOverridesis passed andassertOverridesOwnedstays a parent-side concern.createHttpContext, sosetHeader/setCookiewrites made by the route are captured and returned asresult.headers.runner.ts:91hardcodedresHeaders = {}, which meant everyheaderassertion has been silently evaluating againstundefined.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.tscompiles the project's custom blocks alongside the route and the child callsregisterCompiledCustomBlockbefore instantiating. A block that fails to compile is logged and skipped rather than failing the run, matchingcompiledRuntime.addCustomBlock.Source comes from the database, not the artifact store
compileSuiteRoutecallsloadGraph+compileGraphper 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
spawnCommandwraps 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/$1rather than interpolated, so a path with a space or a quote can't change what runs. Windows skips the wrapper entirely (ulimitdoesn't exist there) and still spawns.The container-level
ulimits.nofile+mem_limit: 2gbackstop on theadminservice already landed in #221.Watchdog
setTimeout(timeoutMs + 2s)→child.kill(), reported astimedOutwith a duration and nothing else. No partial assertion detail is streamed out of a process about to die. The timer is cleared in afinally, so it can't hold the admin process open for the whole timeout. Unconditional —experimental.workerTimeoutsgoverns 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:compileGraphruns in the cold child;getConfig/getQueryParam/getHeaderall resolve from the bootstrap andsetHeadercomes back inresult.headersawait new Promise(() => {})→ killed,timedOut: true, parent unaffectedok: falsecarrying the messageOut of scope
Nothing is wired to
/test-suites/run—runSuiteAssertionsstill 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.tscompiles every custom block in the project rather than just the ones the route reaches (markedponytail:) — 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