fix: gate app launches on health readiness
Demo images / Build & push demo images (push) Successful in 3m47s

This commit is contained in:
archipelago
2026-09-12 09:35:25 -04:00
parent fbb3ada87d
commit caaa2e729e
3 changed files with 44 additions and 4 deletions
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { ref } from 'vue'
import { PackageState, type PackageDataEntry } from '@/types/api'
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
import { APP_CATEGORY_MAP, canLaunch, filterEntriesForTab, hasFrontendUi, isServiceContainer, isServicePackage, isWebsitePackage, isAppReadyForLaunch, launchBlockedReason, resolveAppIcon, useCategoriesWithApps, DEFAULT_APP_ICON } from '../appsConfig'
function makePkg(id: string, title: string, category: string): PackageDataEntry {
return {
@@ -141,6 +141,19 @@ describe('appsConfig service filtering', () => {
expect(canLaunch(confirmedUi)).toBe(true)
})
it('does not launch a health-checked app during the running-before-ready race', () => {
const pkg = makePkg('archipelago-source', 'GitWorkshop', 'development')
;(pkg.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
;(pkg.manifest as unknown as Record<string, unknown>).health_check = { path: '/healthz' }
pkg.installed = { 'interface-addresses': { main: { 'lan-address': 'http://localhost:8337' } }, status: 'running' } as unknown as PackageDataEntry['installed']
pkg.health = null
expect(isAppReadyForLaunch(pkg)).toBe(false)
expect(canLaunch(pkg)).toBe(false)
expect(launchBlockedReason(pkg.manifest.id, pkg)).toContain('Starting up')
pkg.health = 'healthy'
expect(canLaunch(pkg)).toBe(true)
})
it('never offers Launch for curated service containers even with a UI flag', () => {
const service = makePkg('indeedhub-api', 'IndeeHub API', 'media')
;(service.manifest as unknown as Record<string, unknown>).interfaces = { main: { ui: 'true' } }
+20 -2
View File
@@ -259,11 +259,26 @@ export function canLaunch(pkg: PackageDataEntry): boolean {
// the tile stays launchable while the backend is still 'starting' (ElectrumX
// indexes for 10m+ on first run). A genuinely 'unhealthy' backend still
// blocks. Apps that rely on a runtime interface-address keep the strict gate.
const blockedByHealth =
pkg.health === 'unhealthy' || (pkg.health === 'starting' && !hasKnownLaunchUrl)
const blockedByHealth = !isAppReadyForLaunch(pkg) ||
(pkg.health === 'starting' && !hasKnownLaunchUrl)
return !!hasUI && pkg.state === 'running' && !blockedByHealth
}
/**
* A published port is not the same thing as a usable app. During the short
* interval between the container entering `running` and its HTTP health check
* passing, nginx quite correctly returns 502 because the upstream has not
* bound its socket yet. Keep every app with a declared health check out of
* the launch path until the platform has observed readiness. Apps without a
* health check retain the legacy state/port behaviour.
*/
export function isAppReadyForLaunch(pkg: PackageDataEntry): boolean {
const manifest = pkg.manifest as unknown as Record<string, unknown>
const hasHealthCheck = Boolean(manifest.health_check || manifest['health-check'])
if (!hasHealthCheck) return pkg.health !== 'unhealthy'
return pkg.health === 'healthy'
}
export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null): string {
const appId = pkg?.manifest?.id || id
if (
@@ -272,6 +287,9 @@ export function launchBlockedReason(id: string, pkg?: PackageDataEntry | null):
) {
return 'Guardian opens a wait page until Bitcoin finishes initial sync.'
}
if (pkg && pkg.state === PackageState.Running && !isAppReadyForLaunch(pkg)) {
return 'Starting up — Launch will appear when the app is ready.'
}
return ''
}