fix(sdk): reclaim the process a failed service leaves behind - #17
Open
matej21 wants to merge 1 commit into
Open
Conversation
A service start that fails ends the wait, not the process. On a memory-starved sandbox a dev server that misses its 30s readiness window was marked `failed` while the process kept running and kept its ~535 MB, which made the next start miss its window too — memory pressure feeding the timeouts that caused it. Every path that gives up on a service now goes through one PID-reuse-guarded reap: the startup timeout, the spawn-error handler, a start that supersedes an entry still owning a process (the map and the pid registry are both keyed by (session, type), so overwriting them loses the pid for good), and stop(), which used to refuse a `failed` service and so left the platform's reaper no way to free its memory. The orphan sweep's guard is the same function now. The boot reconcile is driven off the recorded pid instead of a list of running statuses. A process we spawned and never saw exit is an orphan whatever the entry says, and the statuses that carry one are not only the running ones: `failed` kept its pid nowhere, and session_restarted — which fires immediately before the reconcile — dropped the pid of the services it marked stopped, so those dev servers survived every later boot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CeiG1iuVYt9LiffmSqXhjH
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.
Production evidence
A project's E2B sandbox (4 GB, no swap) hosts every session of that project, and each
session's
astro-dev(pletivo) dev server costs 535–613 MB. A live census found a devserver for session
019fefe1holding 535 MB, alive, whileservices.listreportedits
astro-devasfailed. That process was 3h42m older than the agent process, soit had already survived at least one agent restart.
It is a self-reinforcing loop: memory pressure → startup timeout →
failed→ ~535 MBleaked → more pressure → more timeouts.
On the same sandbox another session had
astro-devfailedwith no process alive —faileddoes not imply "leaked process", so nothing here kills onfailedalone.What was actually wrong
Services are configured with
startupTimeoutMs: 30000and areadyPattern. A startuptimeout ends the wait, not the process.
1. Abandoned spawns. The startup-timeout handler did already SIGKILL the process
group (verified against
origin/main— see "Verification"), but it was the only paththat did, it was unguarded against PID reuse, and the surrounding paths abandoned
processes freely:
ServiceExecutor.start()over an entry that isfailed,pausedorstoppingoverwrote
this.services[type]and the pid-registry record — both keyed by(session, type)— so the previous generation's pid was lost for good. The newprocess then tried to bind the very port the abandoned one was still holding, since a
restart deliberately reuses it.
stop()returnedErr("Service X is failed, cannot stop"). The platform-side reaperis the one caller that can free a leaked dev server's memory, and it had no way to.
child.on('error')handler marked the servicefailedwithout reclaiminganything.
2. The boot orphan sweep skipped it.
onSessionReadyreconciled onlyentry.status === 'starting' | 'ready' | 'paused', sofailedwas never reclaimed atthe next boot either.
3. (found while verifying) The pid was erased before the sweep could use it. Two
reducers deleted the only handle the sweep has:
failedbranch clearedpid/pidStartTime, so extending the status list in (2)alone would have reconciled nothing;
session_restarted— emitted byopenSessionimmediately beforeonSessionReady— marked running services
stoppedand cleared their pid, without killing anything.This is the likeliest path for the observed production artifact: a gen-1 dev server
becomes invisible to every later boot, keeps its port, and the
failedin theprojection belongs to a later, different spawn that then lost the port race.
Changes
For (1) — reclaim at the source. New
reapServiceProcessGroup(fs, logger, target, context)inservice.ts: one PID-reuse-guardedkill(-pid, SIGKILL). Every path thatgives up on a service now goes through it — startup timeout,
child.on('error'), astart that supersedes an entry still owning a process (reaped just before the spawn, so
the port is free), and
stop(), which now reclaims afailedservice and settles it asstoppedinstead of refusing.RunningServicecarries thepidStartTimecaptured atspawn plus an
exitedflag so an already-exited entry is never re-killed. The pidregistry's
killIfStillOursand the boot reconcile now share that same function insteadof keeping two copies of the guard.
Also fixed while in there: the
closehandler dropped the pid-registry record withoutchecking it still owned the type, so a superseded generation's exit could delete its
successor's record.
For (2) — sweep what earlier generations left. The
onSessionReadyreconcile isdriven off the recorded pid rather than a list of running statuses: a process we spawned
and never saw exit is an orphan whatever the entry says.
failedkeeps its status andits error through the reconcile — only its claim on a process is taken away — while
everything else settles as
stopped, as before.For (3). The
failedreducer branch now takes the pid from the event (the executorreports the pid it could not confirm gone, and nothing once the process is known to have
exited), and
session_restartedno longer clears it.Guarding throughout is the existing discipline:
pidStartTimefrom/proc/<pid>/statis compared before every kill, and a mismatch logs
PID reuse detected — refusing to killand does nothing.Verification
bun run ts:buildclean;bun run lintclean (1 pre-existing warning inplugins/uploads/preprocessors/markitdown-preprocessor.ts, untouched here); full suite1884 pass / 106 skip / 0 fail.
Six new integration tests in
services.integration.test.ts, following the existing"kills orphaned process group from previous server instance" model with real detached
processes. Each was run against unmodified
origin/mainsources with the new tests inplace — 4 of the 6 fail there and pass with this change:
origin/mainstarting over an entry that still owns a process reclaims the old onestopping a failed service settles it instead of refusingstop()returnsErrreclaims a failed entry a previous server instance left owning a processreclaims an orphan that session_restarted had already marked stoppedrefuses to reclaim a failed entry whose pid the kernel has recycleda startup timeout reclaims the process group it spawnedHonest note on the last row: the brief described the startup timeout as abandoning
its process outright. It does not on
origin/main— the SIGKILL has been there sincethe initial commit, and a probe confirmed the process dies. That test therefore locks in
existing behaviour (now routed through the guarded reap) rather than proving a fix. The
leak reaches production through the other paths above, above all the pid erasure in
(3). If the deployed agent bundle predates that kill, this change covers both.
Not fixed here:
ServiceExecutor.shutdown()still skipsfailedentries. Afterthis change they are reaped at the moment they fail, so a survivor there would have to
be one the PID-reuse guard refused — i.e. not ours. Left alone rather than adding a
defensive sweep.
The mirror-image blind spot in the worker-side reaper (it stops only
readyservices)is being fixed separately;
stop()accepting afailedservice is what makes that fixable to do anything.
🤖 Generated with Claude Code