Skip to content

supervisor: adopt only <id>, not swap dirs; a fresh crash record per install - #47

Merged
TeoSlayer merged 3 commits into
mainfrom
fix/supervisor-adopt-and-crash-record
Oct 8, 2026
Merged

TeoSlayer merged 3 commits into
mainfrom
fix/supervisor-adopt-and-crash-record

Conversation

@TeoSlayer

Copy link
Copy Markdown
Contributor

Problems

Both were confirmed by probe tests on main (v1.0.5).

  1. Two supervisors for one app.
    • scanInstalled adopted every subdirectory with a valid manifest, including pilotctl's <id>.staging (manifest write → swap) and <id>.previous (swap → retire).
    • On a same-version rebuild, a rescan in that window started a second supervisor. appCancel[id] was overwritten, and the two killed each other's processes (reapStale matches the binary and socket paths). Every kill counted as a crash, and the app ended up suspended. A probe saw 9 restarts and 4 suspensions in 12 s.
    • At daemon start, a leftover .previous ran beside the live app.
    • The hourly appstore upgrade --all produces same-version rebuilds.
  2. The crash record survived an upgrade or reinstall.
    • A v1 suspended for crash-looping left v2 suspended at its first exit. An uninstall followed by a reinstall inherited the suspension too.

Fixes

  • scanInstalled skips a directory whose name is not its manifest's ID. Each skipped name is logged once, not on every 30 s rescan.
  • rescanForNew clears the app's crash record when it (re)installs it, and rescanForGone clears it on removal.

Tests

Each test fails on main.

  • TestRescanIgnoresStagingAndPreviousDirs
  • TestScanInstalled_SkipsDirNotNamedForItsApp
  • TestRescanUpgradeClearsCrashRecord
  • TestRescanReinstallClearsCrashRecord

go test -race ./... passes, and the new tests passed 20 runs in a row.

Not changed: go.mod stays on go 1.25.10. Dependabot #45 and #46 would raise it to 1.26.0, so they stay unmerged until after this ships.

🤖 Generated with Claude Code

Teo Calin and others added 2 commits October 8, 2026 11:49
scanInstalled adopted every subdirectory of the install root that held a
valid manifest, without checking that the directory is named for the app.
pilotctl's install leaves <id>.staging (from the manifest write to the swap)
and <id>.previous (from the swap until it is retired) beside the live <id>,
both holding a valid manifest for the same app.

On a same-version rebuild the rescan swapped the new <id> in and then
"rebuilt" the app again from .previous (and .staging): startOne overwrote
appCancel[id], each supervisor's reapStale killed the other's process, and
the app was suspended (a probe saw 9 restarts and 4 suspends in 12 s). At
daemon start, Service.Start handed every scanned dir to run(), so a leftover
.previous ran alongside the live app.

A dir whose name is not its manifest's ID is now skipped, right after
Validate and before any signature work. Each skipped dir is logged once, not
on every 30 s rescan; a name that disappears is forgotten, so the next
install's staging dir is logged again.

Tests (zz8_install_dirs_test.go), both failing on ca6cce0:
- TestRescanIgnoresStagingAndPreviousDirs: in-memory app pinned to sha A; on
  disk <id> (sha B), <id>.previous (sha A), <id>.staging. rescanForNew must
  return only <id>, and the next tick nothing. Before: "rescanForNew started
  [io.test.app io.test.app.previous io.test.app.staging]".
- TestScanInstalled_SkipsDirNotNamedForItsApp: a .previous dir and an
  arbitrarily named copy are skipped and logged once over three scans.
  Before: "scanInstalled adopted [copy-of-test-app io.test.app
  io.test.app.previous]".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Crash records are keyed by app ID and recordCrash's suspended bit is sticky.
rescanForNew cleared s.crashes[id] only for a same-version rebuild: the
upgrade branch and the new-app tail kept it, and rescanForGone kept it when
an app was uninstalled. So a v1 suspended for crash-looping made v2 report
suspended and stop at its first exit, and an uninstall plus reinstall
inherited the suspension.

rescanForNew now deletes the record just before registering a new install
or version, and rescanForGone deletes it with the install. The .suspended
marker the old version left needs no change: superviseOne removes it when
the new version's supervise goroutine starts.

Tests (zz9_crash_record_test.go), both failing on ca6cce0:
- TestRescanUpgradeClearsCrashRecord: v1 crash-loops past its budget, rescan
  to v2. isSuspended and Apps().Suspended are false, one recordCrash does not
  suspend, and superviseOne (pre-canceled context) removes v1's .suspended
  marker. Fails without the rescanForNew delete.
- TestRescanReinstallClearsCrashRecord: suspend, remove the dir,
  rescanForGone, reinstall, rescanForNew. The record is gone after the
  uninstall and the reinstall is not suspended. Fails without the
  rescanForGone delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Oct 8, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

….previous

An install interrupted between renaming <id> to <id>.previous and
renaming <id>.staging to <id> leaves no <id>, so the supervisor adopts
nothing and the app stops. The skip line now says the app is not
installed and to run pilotctl appstore install <id>. Also fixes two
comments: a garbled sentence about the crash record, and the crash-loop
window, which an upgrade or reinstall now clears too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@TeoSlayer
TeoSlayer merged commit 804e369 into main Oct 8, 2026
5 checks passed
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.

1 participant