Repository navigation
Stop the judge server and model download when aegis exits - #4
Merged
Merged
Conversation
aegis starts two long-lived children: the L2 judge (llama-server, on the first judged scan) and curl for `install-models` (a ~1.8 GB download). Both outlived aegis on every exit path that skips destructors, reparented to init and still running. Measured on the published v0.1.3 natives (darwin/arm64, linux/arm64, linux/amd64) through the Pilot app: - A flagged scan ends with process::exit(1), which skipped IntentJudge::drop. The judge server stayed up with ppid=1, holding the model and its port, and survived the app's SIGTERM and SIGKILL. - A stop signal (the app's call timeout, a supervisor stop, Ctrl-C, a closed terminal) killed aegis with the default action. install-models left curl downloading with ppid=1; a retry started a second curl on the same file. - status, targets and scan ran `llama-server --version` on every call, even with no model installed: about 0.2 s per call, and once a hang of over 15 s whose llama-server was orphaned when the caller gave up. - The daemon's desktop notifications were never waited for, so it kept one zombie per notification. Changes: - children::spawn records the pid of every child aegis starts. An atexit hook and a SIGTERM/SIGINT/SIGHUP handler SIGTERM the recorded set, then aegis dies of the same signal as before. A signal that is already ignored (nohup, `aegis daemon &`) stays ignored. On Linux each child also gets PR_SET_PDEATHSIG(SIGKILL), which covers SIGKILL of aegis. Stop signals are blocked around fork+record, and unblocked again in the child before exec (macOS's posix_spawn path would otherwise pass the mask on, and the child could not be stopped with SIGTERM). - A judge server another aegis started (found through judge.state) is never recorded, so it keeps running for its owner, as before. - The llama-server check is a PATH lookup instead of an exec. With no model, aegis also skips judge.state and the health probe. - notify() reaps the notifier. tests/child_lifecycle.rs runs the real binary against stub llama-server and curl: a flagged scan, SIGTERM/SIGINT/SIGHUP during install-models, an ignored SIGHUP, SIGKILL on Linux, no llama-server process without a model, and a shared judge left running. On main 4 of 6 fail on macOS and 5 of 7 on Linux (the other two are the stub helper and the shared-judge guard); with this change all pass on darwin/arm64, linux/arm64 and linux/amd64. CI runs them without `|| true`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
So a v0.1.5 tag on this commit builds natives that report 0.1.5, and the Pilot app (io.pilot.aegis) can ship them as 0.1.5. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This was referenced Sep 24, 2026
Draft
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.
Problem
aegis starts two long-lived children: the L2 judge (
llama-server, on the first judged scan) andcurlforinstall-models(a ~1.8 GB download). Both outlived aegis on every exit path that skips destructors. They were reparented to init and kept running. Measured on the published v0.1.3 natives through the Pilot app (io.pilot.aegis) on darwin/arm64, linux/arm64 and linux/amd64:run_scanends withprocess::exit(1), which skipsIntentJudge::dropppid=1, still holding the model and its port. It also survived the app's SIGTERM and SIGKILL (darwin-arm64pid=18840 ppid=1, linux-arm64 pid 38, linux-amd64 pid 45).install-modelscurl, withppid=1(darwin-arm64pid=21800 ppid=1). A retry started a second curl on the same file.status,targets,scanwith no model installedllama-server --version: about 226 ms p50 perstatuscall. Once, 2 of 3 calls hung for more than 15 s, and the stop then leftllama-serverwithppid=1.aegis daemondesktop notificationsmain (v0.1.4) has the same code paths. The new tests below fail on it.
Fix
children::spawnrecords the pid of every child aegis starts. Anatexithook, which runs onprocess::exitand on a normal return frommain, sends SIGTERM to every recorded child. So does a SIGTERM/SIGINT/SIGHUP handler, and aegis then dies of the same signal, so callers see the same exit status as before.nohup aegis daemon,aegis daemon &from a script).PR_SET_PDEATHSIG(SIGKILL), which covers SIGKILL of aegis. There is agetppidcheck for aegis dying between fork andprctl.~/.aegis/judge.stateand never records it, so it keeps running for its owner, as before.llama-servercheck is now a PATH lookup, not an exec. With no model installed, aegis also skipsjudge.stateand the health probe.notify()reaps the notifier.Bump version to 0.1.5is a separate commit, so av0.1.5tag on the merge builds natives that report 0.1.5. Drop it if you'd rather version the release separately.Tests
tests/child_lifecycle.rsruns the real binary with HOME and PATH pointed at a scratch dir that holds a stubllama-serverand a stubcurl. The stub judge is the test binary re-executed as a tiny HTTP server that answers ATTACK, so the tests need only/bin/shandsleep. The tests cover:install-models;docker --initand without init)The tests that pass on main are the stub helper and the shared-judge guard. CI now runs
cargo test --release --test child_lifecyclewithout|| true.Verified through the Pilot app (all 4 platforms)
The natives were built from this branch:
cargo buildfor the two macOS targets,cargo zigbuildfor musl on Linux, with--remap-path-prefixso no local paths are embedded. They ran insideio.pilot.aegis0.1.5 adapter bundles built from app-template with the cli lifecycle fixes, pilot-protocol/app-template#111 plus the SIGTERM-first cancel. The adapter staged the natives over HTTP from a local mirror, and the lifecycle harness drove the app. darwin/amd64 ran under Rosetta; Linux ran indocker --init.install-modelspast the 60 s method timeoutinstall-modelsin flightaegis.status×200 p50aegis.version/aegis.exec scan-cmd×200With the published 0.1.3 native, the in-flight SIGTERM case still leaves the curl behind on darwin/arm64:
sleep 600withppid=1, 2 of 2 runs.Not in this PR
/healthanswers on the port recorded injudge.state(0600). v0.1.4 already fixed the fixed-port:8849issue. The remaining gap: a process that later binds that recorded port could still answer as the judge.integrations/pilot/) is a different adapter from the one in the catalogue. The catalogue ships app-template's generated adapter.🤖 Generated with Claude Code