Skip to content

appstore: install waits for the app's first start and reports it - #497

Merged
TeoSlayer merged 3 commits into
mainfrom
fix/appstore-install-health
Oct 7, 2026
Merged

TeoSlayer merged 3 commits into
mainfrom
fix/appstore-install-health

Conversation

@TeoSlayer

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

pilotctl appstore install returned success as soon as the files were in place. An app that then crashed at every start (seen with an image missing tar) was only suspended about 31 seconds later, and install had long since exited 0 with no hint.

Changes

With a daemon listening, install waits up to 20s after the swap and reads the app's supervisor.log, .suspended and socket:

  • started — a spawn since the install and the socket is present.
  • failed — suspended, or repeated exits with no socket. Install exits non-zero (app_start_failed) with the last supervisor log line and pointers to appstore audit, the daemon log, restart and uninstall.
  • starting — anything else at the deadline; exit 0 with a note, so a slow healthy app is never a failed install.

No wait with --no-wait, with no daemon reachable, for a --force reinstall of the same version and binary, or for a downgrade the supervisor will refuse. JSON gains start_state, start_exits, start_detail, start_waited_ms; existing fields are unchanged.

Also:

  • A replaced install needs three exits rather than two, because the instance being stopped logs one of its own.
  • appstore upgrade passes --no-wait: otherwise a failed start would be reported as "was not upgraded", which is false, and the hourly upgrade --all would wait on every app.

Behaviour change to note: scripts that treat install as file placement will see a new non-zero exit when the app crashes at start.

Test Plan

  • go build ./..., go vet ./..., unit suite with GOWORK=off
  • Unit tests for every state
  • Two tests run install against the real app-store supervisor with a crashing and a healthy app; they skip under -short, so CI's unit run does not execute them — run without -short, they pass
  • Not tested against a real daemon with the real sqlite or mysql adapters

Checklist

  • New code includes the SPDX license header
  • go.mod / go.sum unchanged
  • CHANGELOG updated

🤖 Generated with Claude Code

@TeoSlayer
TeoSlayer force-pushed the fix/appstore-install-health branch from a4fbf20 to 3c19862 Compare October 7, 2026 13:09
Comment thread cmd/pilotctl/appstore.go
fatalHint("invalid_argument", "usage: --wait <duration>, e.g. --wait 60s (0 is --no-wait)", "--wait needs a value")
return
}
d, err := time.ParseDuration(args[i+1])
Comment thread cmd/pilotctl/appstore.go
}
d, err := time.ParseDuration(args[i+1])
if err != nil || d < 0 {
fatalHint("invalid_argument", "usage: --wait <duration>, e.g. --wait 60s (0 is --no-wait)", "invalid --wait %q", args[i+1])
Teo Calin and others added 3 commits October 7, 2026 17:10
install only writes files; the daemon's supervisor starts the app on its
next rescan. So install exited 0 for an app that then exited at every
start (io.pilot.sqlite on an image without tar), and the app was suspended
about half a minute later with nothing in the install output.

With a daemon listening, install now watches the app dir (supervisor.log,
.suspended, app.sock) for up to 20s after the swap and reports:

- started: a spawn since the install and the socket is there;
- failed: the supervisor suspended the app, or it exited at least twice
  with no socket (three times when an install was replaced, since the
  instance the supervisor stops logs an exit too). install prints its
  report and then exits non-zero (app_start_failed) with the last
  supervisor log line and pointers to `appstore audit` and the daemon log;
- starting: neither by the deadline. Exit 0 with a note, so an app that
  takes longer than the wait to open its socket is not a failed install.

Waiting for .suspended alone would not do: the supervisor suspends after
more than 5 exits in a minute, 31s at the earliest.

No wait with --no-wait, when no daemon is listening, or for a reinstall
the supervisor does not act on (same version and binary, or an older
version). `appstore upgrade` passes --no-wait: it would otherwise report
a failed start as "was not upgraded", and the hourly `upgrade --all`
would spend the wait on every app.

The JSON report gains start_state, start_exits, start_detail and
start_waited_ms; existing fields are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fixes for the wait on the app's first start.

The replaced instance's events no longer count. supervisor.log is carried
into the new install and the supervisor keeps running the old instance
until its next rescan, so lines after the swap can still be the old
instance's: the end of its crash loop (exits, a suspend and its
.suspended marker) made a good upgrade report failed and exit 1, and the
exit it logs when the rescan stops it showed up as start_exits: 1 on a
clean upgrade. Counting now starts at the new instance's supervise-start
or spawn, recognised by the installed manifest's binary sha256 they
carry; an exit counts only when it ends one of the new instance's start
attempts, and a suspend only when it follows one of its exits. The
.suspended marker is no longer read: it names no instance, and the log
has the suspend first. A rotated supervisor.log.1 written during the wait
is read too.

Versions are ordered as the supervisor orders them (a release above its
prereleases), not with semverCompare, which ignores prereleases: --force
from 1.0.0 to 1.0.0-beta.1 waited 20s for a start the supervisor refuses.
A downgrade-refused line for the installed version is now a final
outcome: failed, with the supervisor's reason and how to get the version
running. A downgrade that is not waited for says the daemon starts it
only when it restarts, instead of "picked up within ~30s".

After a spawn-fail the failure reason is reported, not the exit -1 that
follows it. A wait that ends before the daemon picked the app up prints
one note instead of two that contradict each other. --wait <duration>
changes the 20s (0 is --no-wait). --no-wait and --wait are in the main
help, the JSON command catalogue and docs/cli-reference.md.

Tests: the three install tests in zz_appstore_cmds_test.go dialled the
daemon at /tmp/pilot.sock and waited 20s each when one was running; they
now use isolateAppStoreTest. The fake-supervisor helpers report with
t.Error, since they run on goroutines. New tests cover the upgrade log
sequences, the refusal and the prerelease order, with the fake and the
real supervisor; all of them fail on the previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review fix. With a binary identical across versions (a manifest-only bump)
the old instance's restarts after the swap log the new manifest's sha, so
they were counted, and two old crashes could report the new install as
failed. The counts now restart at the new instance's supervise-start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TeoSlayer
TeoSlayer force-pushed the fix/appstore-install-health branch from 60e2690 to 63c982a Compare October 7, 2026 14:11
@TeoSlayer
TeoSlayer merged commit 7cb5775 into main Oct 7, 2026
15 checks passed
@TeoSlayer
TeoSlayer deleted the fix/appstore-install-health branch October 7, 2026 14:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants