An update that goes wrong is no longer the end of it
Two things the audit found, both about the archive path every shipped .app uses. **Settings could be wiped.** The update kept config/modules.json and config/layouts/ by name, but not config/proddash.json — which holds the admin PIN, the theme and the forge pointer. On an install whose data directory IS the app's own config/ (an unwritable Application Support, or PRODDASH_DATA_DIR aimed inside the app), an update wrote the repo's shipped defaults over it: the theme went, and admin silently unlocked. Nothing inside the data directory is written now, wherever it happens to sit, and the two legacy names stay listed for an install whose state never moved out. **A bad update was unrecoverable.** No rollback, and a fresh .app would not help: seeding only replaces an *older* copy, and a broken one carries a higher version — so the fix was deleting a folder nothing told you about. Three changes, in the order they catch things: - The download must parse before anything is swapped in. `node --check` against the new server.js, and a failure is reported with the syntax error and leaves the running copy untouched. - Past that, the copy being replaced is set aside as `app.previous`, and dropped once the new version has stayed up ninety seconds — so that folder existing means "the last update has not proven itself yet", and never anything older. - The launcher, which already gives up after four stops in two minutes, now looks for that folder before it does and puts the old copy back. Once: if the old one will not run either, the update was not the problem. The launcher half only reaches a machine with a new .app; the server half travels by the normal update. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5025e12b71
commit
12f6e22187
@@ -103,6 +103,45 @@ enum ProdDash {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
Go back to the copy ProdDash set aside before its last update.
|
||||
|
||||
A version that will not stay up is the one state this app cannot talk its
|
||||
way out of: the admin page went down with it, and installing a fresh
|
||||
`.app` does nothing, because seeding only replaces an *older* copy and a
|
||||
broken one carries a higher version. So ProdDash writes `app.previous`
|
||||
before it overwrites itself and drops it again once the new version has
|
||||
stayed up — meaning that folder exists only while an update is still
|
||||
unproven. This puts it back.
|
||||
|
||||
Returns the version restored, or nil when there is nothing to go back to.
|
||||
*/
|
||||
@discardableResult
|
||||
static func restorePrevious(root: URL, log: (String) -> Void) -> String? {
|
||||
let fm = FileManager.default
|
||||
let previous = URL(fileURLWithPath: root.path + ".previous")
|
||||
guard isRoot(previous) else { return nil }
|
||||
let version = shellVersion(root: previous)
|
||||
let named = version.isEmpty ? "(unknown version)" : version
|
||||
let broken = URL(fileURLWithPath: root.path + ".broken")
|
||||
do {
|
||||
try? fm.removeItem(at: broken)
|
||||
if fm.fileExists(atPath: root.path) { try fm.moveItem(at: root, to: broken) }
|
||||
try fm.moveItem(at: previous, to: root)
|
||||
makeWritable(root)
|
||||
try? fm.removeItem(at: broken)
|
||||
log("rolled ProdDash back to \(named) — the copy it replaced")
|
||||
return named
|
||||
} catch {
|
||||
log("could not roll back to the previous copy: \(error.localizedDescription)")
|
||||
// Leave something runnable behind rather than nothing at all.
|
||||
if !fm.fileExists(atPath: root.path), fm.fileExists(atPath: broken.path) {
|
||||
try? fm.moveItem(at: broken, to: root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Files copied out of a bundle can arrive read-only, and this copy is
|
||||
/// exactly where ProdDash writes its own updates.
|
||||
private static func makeWritable(_ root: URL) {
|
||||
|
||||
@@ -65,6 +65,8 @@ final class ServerController: ObservableObject {
|
||||
private var restartAfterExit = false
|
||||
private var startedAt: Date?
|
||||
private var crashTimes: [Date] = []
|
||||
/// One roll-back per bad update: a second would just swap copies forever.
|
||||
private var rolledBack = false
|
||||
private var pendingRestart: DispatchWorkItem?
|
||||
private var healthTimer: Timer?
|
||||
private var healthMisses = 0
|
||||
@@ -299,7 +301,7 @@ final class ServerController: ObservableObject {
|
||||
child = nil
|
||||
healthy = false
|
||||
let ranFor = startedAt.map { Date().timeIntervalSince($0) } ?? 0
|
||||
if ranFor > 60 { crashTimes.removeAll() } // a long healthy run clears the slate
|
||||
if ranFor > 60 { crashTimes.removeAll(); rolledBack = false } // a long healthy run clears the slate
|
||||
|
||||
if restartAfterExit {
|
||||
restartAfterExit = false
|
||||
@@ -328,6 +330,22 @@ final class ServerController: ObservableObject {
|
||||
crashTimes = crashTimes.filter { Date().timeIntervalSince($0) < 120 }
|
||||
|
||||
guard settings.restartOnCrash, crashTimes.count < 4 else {
|
||||
// Before giving up: if an update is still unproven, the copy it
|
||||
// replaced is sitting right there. Going back beats sitting dark,
|
||||
// and only once — if the old one will not run either, the problem
|
||||
// is not the update.
|
||||
if settings.restartOnCrash, !rolledBack, let root = rootURL,
|
||||
let restored = ProdDash.restorePrevious(root: root, log: { [log] in log.launcher($0) }) {
|
||||
rolledBack = true
|
||||
crashTimes.removeAll()
|
||||
state = .stopped
|
||||
message = "Rolled back to ProdDash \(restored) — starting…"
|
||||
Notifier.shared.post(title: "ProdDash rolled back",
|
||||
body: "The new version would not stay up. Back on \(restored).")
|
||||
refreshEnvironment()
|
||||
start()
|
||||
return
|
||||
}
|
||||
let why = settings.restartOnCrash
|
||||
? "ProdDash stopped \(crashTimes.count) times in two minutes — leaving it stopped."
|
||||
: "ProdDash \(how)."
|
||||
|
||||
Reference in New Issue
Block a user