fix(daemon): fall back to ephemeral port on conflict + harden teardown#386
Merged
Merged
Conversation
Fixes the packaged desktop app getting permanently stuck on "AO daemon is not ready" (#385) via two daemon-lifecycle fixes. 1. Port conflict no longer exits the daemon. When the configured port (default 127.0.0.1:3001) is held by a non-AO process, NewWithDeps now falls back to an OS-assigned ephemeral port instead of returning a bind error. A genuine peer AO daemon is already ruled out upstream (the running.json + /healthz check in daemon.Run), so a conflict here means a foreign holder. The bound port is logged ("daemon listening") and written to running.json, both of which the supervisor reads, so the fallback propagates to the renderer with no UI changes. 2. Detached daemon is torn down on more exit paths. before-quit already group-kills the daemon, but app.exit() and some shutdown routes skip it, orphaning the daemon so it keeps holding the port for the next launch. A synchronous process 'exit' handler now also signals the daemon's process group. A hard SIGKILL/crash still can't run JS, but fix #1 covers the orphan that leaves behind. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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.
Fixes #385.
The packaged desktop app could get permanently stuck on "AO daemon is not ready" because the bundled daemon never reached a ready state. Two related daemon-lifecycle problems caused this.
1. Daemon exits on port conflict instead of falling back
When the configured port (default
127.0.0.1:3001) was held by a non-AO process, the daemon returned a bind error and exited. The Electron supervisor saw the child exit, so the renderer's API base URL stayednulland every request returned{"message":"AO daemon is not ready."}.NewWithDeps(backend/internal/httpd/server.go) now falls back to an OS-assigned ephemeral port when the configured one is in use (EADDRINUSE). A genuine peer AO daemon is already ruled out upstream by therunning.json+/healthzcheck indaemon.Run, so a conflict at this point means a foreign holder. The daemon already logs the actual bound port (msg="daemon listening" addr=...) and writesrunning.json, both of which the supervisor reads (frontend/src/shared/daemon-discovery.ts), so the fallback port propagates to the renderer with no UI changes.Non-
EADDRINUSEbind errors still fail fast.2. Detached daemon orphaned on quit
The supervisor spawns the daemon
detached: true.before-quitalready group-kills it, butapp.exit()and some shutdown routes skip that event, so the detached daemon could survive and keep holding the port, wedging the next launch against problem #1.A synchronous Node
process.on("exit")handler now also group-kills the daemon, covering the pathsbefore-quitmisses. A hardSIGKILL/crash still can't run JS, but fix #1 covers the orphan that leaves behind.Testing
go build ./...andgo test -race ./...(1539 tests, 72 packages) pass.TestNewFailsOnPortConflictis replaced byTestNewFallsBackOnPortConflict, asserting the second bind takes a different ephemeral port instead of erroring.npm run typecheckpasses.🤖 Generated with Claude Code