16 - Single-node hardening
On this page 7
Before anything scales out, the one box has to stop being killable by one large repository. This phase was written after reading Cursor's Git at any scale and then auditing this codebase for the failure modes that post takes for granted are already handled: unbounded buffers, missing backpressure, uncoordinated maintenance, and processes nobody counts. Phases 17 and 18 build the scale-out story; this phase makes the current architecture survive the load it already accepts.
The audit found the streaming diff path is the part that was built right: diffStream.ts sets
utf8 encoding, caps stderr at 64KB, applies real backpressure with for-await, and kills git when
the reader walks away. DiffManifestAction pulls one record per pull(). Everything in this phase
is spreading that discipline to the code that predates it.
Nothing here changes the storage layout, and nothing here touches wire-protocol argument handling, which is the historically dangerous area (see "Ask which one, not whether it worked" in the index). The milestones are ordered by severity times cheapness, and each one ships on its own.
M0 - Correctness bugs that look like scalability bugs
Four bugs that read as "it falls over under load" and are actually just wrong.
- Fix the mirror import clone.
app/Jobs/ImportRepositoryJob.tsroutesgit clone --mirrorthroughrunGit, which prepends--git-dir <parent>- exactly what the doc comment oncloneBare(app/Actions/Git/git.ts) says must never be done for a clone - and it inherits the default 30 second timeout, so any non-trivial import is SIGKILLed and the half-written directory removed. Add amirrorClone(from, to)helper next tocloneBarewith its own spawn shape, a long timeout (the mirror fetch path already uses 15 minutes), and stderr capped at 64KB. Keep the remove-on-failure cleanup.app/Commands/ImportGit.tshad the same bug and now uses the same helper. - Test the clone fix: mirror-clone a local fixture repository, assert refs and tags arrive,
assert a failed clone leaves no directory, and assert the argv never contains
--git-dir(tests/unit/mirror-clone.test.ts). - Drop
--prune=nowfrom repository maintenance.RepositoryMaintenanceJobrunsgit gc --prune=nowwith no coordination against in-flight pushes, and its comment claiming nothing can hold a reference to an unreachable object is wrong for a push sitting between quarantine merge and ref update. Plaingit gckeeps the two-week mtime grace period, which is the coordination mechanism. Rewrite the comment to say so, and extract the argument list into a pure function so a test can assert it without spawning git (packArgumentsinapp/Actions/Repo/retention.ts). - Stop running the push pipeline inline.
QUEUE_DRIVERdefaults tosync, soProcessPushJobruns inside the post-receive request unless someone sets it. DocumentQUEUE_DRIVER=databasein.env.exampleanddocs/self-hosting.mdas the deployment default. - Reconcile the stale compose file with reality.
compose.yamlships Meilisearch and sets aSEARCH_HOSTvariable nothing reads, whileconfig/search-engine.tsis deliberately Typesense - so a compose deployment has no working search at all. Brought to parity: Typesense with theTYPESENSE_*variables the config reads, and a header saying the canonical path is pantry (see M6) with this file as the kept-at-parity convenience.
M1 - Bounded runGit
runGit buffers stdout into a JavaScript string with no byte cap - the only bound is the timeout,
and a fast git fills memory long before a slow one hits it. Every unbounded caller is one large
repository away from taking the process down.
- Add a
maxBytesoption torunGit(default 10 MiB). Track accumulated length, SIGKILL the child on breach, resolve withtruncated: trueon the result. Early kill, never slice-after-allocation.okstays true on a breach: the caller asked for at most that much and got it, and a budget read as failure would make every bounded caller drop the bytes it budgeted for. - Set
utf8encoding on stdout and stderr inrunGit. Today each 64KB chunk is coerced to a string independently, so a multibyte character spanning a chunk boundary becomes replacement characters.diffStream.tsis the precedent. - Cap stderr at 64KB unconditionally, same rationale and same constant as
diffStream.ts. - Secret scanning (
app/Actions/Git/scan.ts): pass the existing 4 MiBSCAN_BYTE_LIMITasmaxBytesinstead of buffering the wholegit log --patchand slicing afterwards. This is on the push path, so it is the highest-value single cap. - Code search (
app/Actions/Browse/SearchCodeAction.tsandsearch.ts): add per-file--max-countto the grep arguments and a 2 MiBmaxBytes; todayMAX_RESULTStrims the string after git has printed everything it could in ten seconds. MeasureLanguagesJob: a finite budget on the fullls-tree -r --long(the comment already claims the output is bounded; make the claim true - therunGitdefault 10 MiB cap is now real, and the comment says what a cut means for a percentage breakdown).app/Actions/Browse/load.ts: budgets onlistTreeand on the compare path's--numstatand--name-statuscalls, with atruncatedflag surfaced so views can say a listing was cut rather than silently rendering a partial answer. A cut-zrecord is trimmed to the last complete one, because a clipped filename parses as a real entry by the wrong name.app/Actions/Pull/load.ts: budgets onchangedPathsForandcommitsOnBranch, with any partial trailing line dropped rather than returned clipped.- Tests: a fixture blob larger than a small
maxBytesoverride resolves promptly withtruncated: trueand no zombie child; a file of tens of thousands of multibyte characters round-trips throughrunGitwith no replacement characters (tests/unit/run-git-bounded.test.ts).
M2 - The SSR pull request page stops loading whole patches
The API diff path streams with an 8 MiB rendered-rows budget and pathspec-based refetch. The
server-rendered pull request page does not: it calls pullRequestDiff, which is runGit returning
the entire patch as one string, then parses all of it, then runs cross-file move detection over the
result. This is the main way a large diff kills the box, and the bounded infrastructure it should
use already exists.
- Rebuild
pullRequestDiffandcommitDiff(app/Actions/Pull/load.ts) onstreamMergeBaseDiff/streamCommitDiff, consuming the iterable under a byte budget matching the streamed path's 8 MiB, cancelling on breach, returning{ text, truncated }.diffStream.tslearnedignoreWhitespaceso the whitespace preference survives the move rather than being dropped as a casualty of it. - When truncated, the pull request page renders a banner naming the size and linking the reader to the virtualized diff view, which handles arbitrary sizes by design. Both the whole-diff and commit-by-commit modes have one.
- Tests: a diff larger than a small test budget returns truncated and well-formed partial
text; a normal diff is byte-identical to the old output
(
tests/unit/pull-diff-bounded.test.ts, asserting against the old argv run buffered).
M3 - A ceiling on concurrent git processes
There is no limit on how many git processes this app will spawn - the only backpressure is a
throttle on three wire-protocol routes, counted per credential. Clone storms are phase 15's normal
operating condition, and upload-pack is the most expensive thing this server runs.
- A counting semaphore (
app/Actions/Git/semaphore.ts) with three classes and env-tunable limits:interactive(default forrunGit, ~32),heavy(upload-pack, receive-pack, archive, 8),background(gc, languages, scans, imports, 4). FIFO, with an acquire timeout (GIT_SEMAPHORE_INTERACTIVE/_HEAVY/_BACKGROUND/_ACQUIRE_MS). runGitacquires its class before spawning; aspawnGitLimitedwrapper does the same for the streaming spawns. Wire-protocol saturation answers 503 withRetry-After, which git clients honor politely. The diff stream builders went async for it (seven call sites); the SSH transport holds aheavyslot too, refusing on the channel where HTTP answers 503.- Keep the classes structurally deadlock-free: a holder of one class must never acquire the
same class again while holding it. The audit found no nested
runGittoday; the rule keeps it that way, is written on the semaphore, andRawFileAction's two sequential spawns note why they are sequential. - Tests: limits honored per class, FIFO order, release on rejection, and a saturated
wire-protocol request answering 503 (
tests/unit/git-semaphore.test.ts, and the 503 through the real route intests/e2e/git-http.test.ts). - The spawns this milestone did not name. Written down because the list above was
wrong by two, and only a
grepfor everyspawnGitafterwards found them.routes/actions.tsservesupload-packfor the mirrored-actions cache - which a whole runner fleet fetches from at the start of every job, so it is the most likely endpoint on the instance to see a clone storm, and it had no slot, no pull-based stream and nodrainawait.app/Actions/Browse/blobWindow.tsspawnedcat-file bloboutside the ceiling too. Both are in now, with a 503 test on the actions route. The lesson for the next milestone that names call sites: enumerate them with a search, not from memory.
M4 - Backpressure on every stream
Three response streams enqueue every chunk the moment git produces it, so a slow client downloading
a multi-gigabyte archive buffers the whole difference in process memory. The stdin side has the
mirror image: child.stdin.write() return values are ignored, so a push arriving faster than git
indexes it buffers unboundedly.
- A shared pull-based
stdoutStream(child)helper - one chunk perpull(), kill oncancel()- replacing thestart()pluson('data')bodies in the wire-protocolstreamService(routes/git.ts),ArchiveAction, andRawFileAction. - The receive-pack pump awaits
drainwhenwrite()returns false. Verified real on this runtime: a 100MB write against a child that sleeps three seconds before reading was held to the child's pace,write()answering false throughout, RSS flat. - The SSH path (
app/Actions/Git/ssh.ts) does the same, pausing the channel if the library exposes it, else buffering under a hard watermark and terminating on breach. ts-ssh exposes no pause, so it is the watermark: 32 MiB onstdin.writableLength, terminate with a message. - Tests: a fake child proves one-chunk-per-pull and kill-on-cancel
(
tests/unit/git-stream.test.ts); existing smart HTTP and download suites confirm normal transfers unchanged. The manual memory check is the next box's story. - The download direction needs a Bun fix, and until it lands the memory-flat guarantee is
structural rather than actual. Measured on Bun 1.3.14 while closing the boxes above: the
runtime drains a spawned child's stdout into process memory eagerly no matter how slowly the
consumer reads - a 50MB writer finished in one second against a paused reader, the buffered
bytes invisible to
readableLength, and the same throughpause(), the async iterator, andBun.spawn's native stream. On Node the same code blocks the child at the pipe. The write direction is honest (that is why the two boxes above could tick). So every pull-based stream here bounds parsing and delivery but not the runtime's own buffer, anddiffStream.tshas carried the same latent gap since it was written. Adjacent, not identical, upstream reports: oven-sh/bun#18239 (stdin buffered whole), #14693, #5319. The fix is a Bun issue plus a regression test here when it lands; a FIFO-per-request detour was considered and rejected as exactly the workaround-that-hides-the-bug this codebase refuses.
M5 - More than one process on one host
Everything multi-process-unsafe today is config, not architecture: the cache driver is in-process memory, so pull request presence dies with the process; the websocket broadcast is single-node; the queue is fine (database driver) once M0 makes it the default. The framework already carries Redis support in all three configs, unused.
- Declare
redis.ioorvalkey.ioinconfig/deps.tsas an optional pantry-managed service (both are in pantry's package set; record the pick and why). Memory stays the zero-dependency default. The pick is valkey: Redis 7.4 moved to RSALv2/SSPL, neither OSI-approved, and the BSD-3 fork is protocol-identical - the reasoning is on the declaration. - Env-switchable
CACHE_DRIVERwith the Redis connection settings plumbed throughconfig/cache.ts. Presence inLiveStateActionalready rides the cache facade and already degrades when the cache is gone, so it becomes cross-process with no code change. The connection reads the sameREDIS_*variables the queue does, so it is configured once. - Document
BROADCAST_REDIS_ENABLEDas the requirement for running more than one app process, and a "running more than one process" section indocs/self-hosting.md: queue on database, cache on redis, broadcast on redis, all env-switched - plus the one thing that cannot move by env switch, the shared repository filesystem.
M6 - Pantry everywhere
All system dependencies come from pantry, and where pantry cannot do something this deployment
needs, pantry gets extended rather than worked around - the same fix-the-tool rule as stx and
bun-query-builder. The CI runner already lives this way: job toolchains via pantry env --install
and job services via pantry start (app/Actions/Runner/localExecutor.ts). The rest of the
infrastructure converges on that pattern, and the container path stops being the deployment story.
- Complete the declared inventory in
config/deps.ts: addopenssh(mirror pushes over ssh remotes need a client; today only the Dockerfile installs one, and bare-metal installs get whatever the OS has). Every binary this app spawns - git, gpg, and what git itself invokes - traces to a pantry declaration. Declared asopenssh.com; the note on it says why the forge's own ssh server (ts-ssh, TypeScript) needs nothing. - Fix
pantry install gnupg.orgupstream. On pantry 0.11.12 it reports 28 packages installed while installing nothing: no binary on PATH, nothing inpantry list. This is the documented blocker for the entire commit signature verification feature (app/Actions/Git/verify.tsnames it, andapp/Actions/Keys/gpg.tsspawns gpg directly for key imports). Check whether the 0.11.18 checkout already fixed it, upgrade the installed pantry, add a regression test upstream, then unblock the verify routes here. Fixed upstream (pantry builds the GnuPG dependency chain from source); gpg 2.4.8 is installed here, the verify route was already wired, andREVIEWOS_GPG_TESTS=1 bun test tests/e2e/git-signature.test.tsverifies a real signature against a real keyring - 8 passing. The comment inverify.tsnow records what was observed rather than what it was assumed to mean, which is what cost two wrong diagnoses. - Fix
pantry start --portupstream. The launchd agent it writes still runs the default port, documented as a known lie inconfig/deps.ts, and per-project Typesense depends on it. Two bugs, not one. The flag reaching the start command was fixed upstream in 0.11.20; what remained was that typesense-server binds twice and its peering port defaults to 8107 regardless of the API port - so this project came up on 8208 for HTTP and then fought another project for 8107 forever, logging "has started listening on port 8208" and never becoming healthy. Andpantry inspectrecomputed the definition from scratch, so it reported the default port, the wrong health check, and aCommand:line that was not the one running. Both fixed in pantry 0.11.31 with tests; two projects' Typesense instances now run side by side, and.env/.env.examplesay 8208 to match. - Extend pantry to manage project-level processes as services: the app server and the queue
worker as KeepAlive launchd/systemd agents, the same mechanism pantry already uses for
Postgres and Typesense. A production box becomes pantry plus a
.env, with no container runtime required. Built upstream asservices.definein deps.yaml (pantry 0.11.31), generated fromconfig/deps.tsby the Stacks setup command; verified by running this instance's queue worker as a managed launchd agent.commandis required,cwddefaults to the project root, and the worker deliberately declares no health check - its liveness is queue depth, and a check that only proved the process exists would call a wedged worker healthy. - A declaration that never reached
deps.yamlis not a declaration. Written down because three boxes above were ticked while being inert, and the review that asked "are you sure" is the only reason they were caught.config/deps.tsis the source, but pantry reads the generateddeps.yaml- and that file is only rewritten bybuddy setup, soopenssh.comandvalkey.iosat in the config, installed nothing, and looked done. Worse, the generator innode_modulespredated theservices.definesupport, so the next regeneration would have silently dropped the app and worker definitions. Closed by releasing the generator (@stacksjs/buddy0.72.31), regenerating, and installing: both binaries are now onpantry/.binand verified to run, and a git child with no hostsshresolves the declared one. Regenerating also exposed two more: a duplicatetypesense.orgkey left by a pantry bug already fixed upstream, andautoStart: truesilently droppingtypesensefrom the list, because the config type saidbooleanwhile every generator reading it accepted an array (widened in ts-pantry 0.11.32; the list is explicit here now). The generator is idempotent - two runs are byte-identical - which is the property that makes the file trustworthy. - Production provisioning follows the same line: ts-cloud provisions the box
(
config/cloud.tsalready targets Hetzner server mode), pantry installs every system dependency and runs every service. The Dockerfile'sapt-get install git ca-certificates openssh-clientduplication goes away when the compose path is demoted (M0). The division of labour is written onconfig/cloud.ts(the driver's job ends at a machine with an address and ssh; everything above it is pantry) and the Dockerfile says its list is a duplicate ofconfig/deps.tsrather than a second source of truth.