fix(container): reap ghost containers so an app can't be locked out of itself
Demo images / Build & push demo images (push) Successful in 3m24s
Demo images / Build & push demo images (push) Successful in 3m24s
A ghost is a container whose process tree is still running while podman
has no record of it: the exit-command's `cleanup --rm` deletes the record,
conmon and the payload survive. It keeps owning exactly what the app needs
— the published host port and the file locks in its data dir — so the
replacement container either fails to bind ("address already in use") or
starts and dies on the lock, and Restart=always loops it there forever.
Nothing in the stack could see it: every podman-level stop/rm/recreate
misses a container podman lost.
Seen twice now: 752 restarts on a fleet node (2026-08-10) and again on the
dev box today, where Gitea flapped until it fell out of My Apps. Both were
cleared by hand; container-doctor.sh has the same logic but is an
out-of-band script the daemon never calls.
- New container::ghost_reaper: finds conmon processes whose 64-hex
container id is absent from `podman ps -a --no-trunc -q`, then kills the
payload's children and conmon (TERM, 5s grace, then KILL — the Gitea
ghost ignored TERM). Id-based, never name-based: killing by name would
hit the live managed container. A failed `podman ps` reaps nothing
rather than treating every container as a ghost.
- Hooked at repair_before_package_start (covers package.start,
package.restart and the orchestrator start path) and in the boot
reconciler's 30s tick, so ghosts are cleared before an app is asked to
start and swept for every app continuously.
Restart feedback: the lifecycle RPCs return {"status":"restarting"} in
milliseconds and work in the background, so "Restarting..." flashed for a
few frames and the buttons went idle while the app was still down — the
click read as a no-op. The hero buttons now show a spinner and hold it off
the node's own state (starting/stopping/restarting/updating, plus running
+ health=starting), and the just-clicked action is held until the backend
confirms it picked the work up, with a 12s cap so an unresponsive node
still releases the controls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b113fafee4
commit
9ccc325a4d
@@ -312,10 +312,32 @@ function launchApp() {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Hold the just-clicked action until the node's own state confirms it picked
|
||||
* the work up (or we give up waiting).
|
||||
*
|
||||
* The lifecycle RPCs return in milliseconds and do the real work in the
|
||||
* background, so clearing `pendingAction` on the promise left the buttons idle
|
||||
* while the app was still down — the operator sees a flash and assumes the
|
||||
* click did nothing. The hero section keeps the spinner running off the
|
||||
* backend's transitional state; this only has to bridge the gap until that
|
||||
* first state push lands. The timeout means a node that never reports back
|
||||
* still releases the controls instead of wedging them.
|
||||
*/
|
||||
async function holdUntilBackendPicksUp(timeoutMs = 12000) {
|
||||
const started = Date.now()
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
const s = pkg.value?.state
|
||||
if (s === 'starting' || s === 'stopping' || s === 'restarting' || s === 'updating') return
|
||||
await new Promise((r) => setTimeout(r, 250))
|
||||
}
|
||||
}
|
||||
|
||||
async function startApp() {
|
||||
pendingAction.value = 'start'
|
||||
try {
|
||||
await store.startPackage(appId.value)
|
||||
await holdUntilBackendPicksUp()
|
||||
} catch (err) {
|
||||
showActionError(`Failed to start: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
@@ -330,6 +352,7 @@ async function stopApp() {
|
||||
// Stopping the app can take its admin credentials offline — invalidate
|
||||
// rather than show a stale "healthy" credentials card (T-02-12).
|
||||
credentialsResource.invalidate()
|
||||
await holdUntilBackendPicksUp()
|
||||
} catch (err) {
|
||||
showActionError(`Failed to stop: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
@@ -344,6 +367,7 @@ async function restartApp() {
|
||||
// A restart can rotate credentials/admin URLs — invalidate so the next
|
||||
// read is fresh rather than the pre-restart cache (T-02-12).
|
||||
credentialsResource.invalidate()
|
||||
await holdUntilBackendPicksUp()
|
||||
} catch (err) {
|
||||
showActionError(`Failed to restart: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||
} finally {
|
||||
|
||||
@@ -35,10 +35,14 @@
|
||||
:key="`top-${action.key}`"
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="['app-detail-action-btn px-4 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed', action.class]"
|
||||
:class="['app-detail-action-btn px-4 py-2.5 rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2', action.class]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
{{ action.label }}
|
||||
<svg v-if="action.busy" class="w-4 h-4 animate-spin shrink-0" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,13 +61,17 @@
|
||||
type="button"
|
||||
:disabled="controlsDisabled"
|
||||
:class="[
|
||||
'mobile-card-action rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
'mobile-card-action rounded-lg text-sm font-medium disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center justify-center gap-2',
|
||||
action.class,
|
||||
actionItems.length === 1 || action.full ? 'col-span-2' : '',
|
||||
]"
|
||||
@click="emitAction(action.emit)"
|
||||
>
|
||||
{{ action.label }}
|
||||
<svg v-if="action.busy" class="w-4 h-4 animate-spin shrink-0" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
<span>{{ action.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -90,7 +98,39 @@ const props = defineProps<{
|
||||
}>()
|
||||
|
||||
const icon = computed(() => resolveAppIcon(props.pkg.manifest?.id || props.appId, props.pkg))
|
||||
const controlsDisabled = computed(() => props.pendingAction !== null || props.pkg.state === 'updating')
|
||||
|
||||
// The lifecycle RPCs are fire-and-forget: package.restart returns
|
||||
// {"status":"restarting"} in milliseconds and does the real work in the
|
||||
// background. Keying the button purely off the RPC promise made
|
||||
// "Restarting..." flash and vanish while the app was still down, which reads
|
||||
// as "nothing happened" (or worse, "it's broken"). The backend's own state —
|
||||
// pushed over the WebSocket — is what actually says when the app is back, so
|
||||
// the busy state is driven from there, with `pendingAction` covering only the
|
||||
// gap before the first state push arrives.
|
||||
const backendBusy = computed(() => {
|
||||
const s = props.pkg.state
|
||||
if (s === 'starting' || s === 'stopping' || s === 'restarting' || s === 'updating') return true
|
||||
// Container is up but the app hasn't passed its health check yet — still
|
||||
// not usable, so keep the spinner rather than declaring victory early.
|
||||
return s === 'running' && props.pkg.health === 'starting'
|
||||
})
|
||||
|
||||
const isBusy = computed(() => props.pendingAction !== null || backendBusy.value)
|
||||
const controlsDisabled = computed(() => isBusy.value || props.pkg.state === 'updating')
|
||||
|
||||
// What the node is doing right now, for the button label. Prefers the
|
||||
// operator's just-clicked action so the label is instant, then falls back to
|
||||
// whatever the backend reports (covers a restart started from another device).
|
||||
const busyLabel = computed(() => {
|
||||
const s = props.pkg.state
|
||||
if (props.pendingAction === 'restart' || s === 'restarting') return 'Restarting…'
|
||||
if (props.pendingAction === 'start' || s === 'starting') return 'Starting…'
|
||||
if (props.pendingAction === 'stop' || s === 'stopping') return 'Stopping…'
|
||||
if (props.pendingAction === 'update' || s === 'updating') return 'Updating…'
|
||||
if (props.pendingAction === 'uninstall') return 'Uninstalling…'
|
||||
if (s === 'running' && props.pkg.health === 'starting') return 'Starting…'
|
||||
return ''
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
launch: []
|
||||
@@ -105,7 +145,14 @@ const emit = defineEmits<{
|
||||
type ActionEmit = 'launch' | 'start' | 'stop' | 'restart' | 'uninstall' | 'update' | 'channels'
|
||||
|
||||
const actionItems = computed(() => {
|
||||
const actions: Array<{ key: string; emit: ActionEmit; label: string; class: string; full?: boolean }> = []
|
||||
const actions: Array<{
|
||||
key: string
|
||||
emit: ActionEmit
|
||||
label: string
|
||||
class: string
|
||||
full?: boolean
|
||||
busy?: boolean
|
||||
}> = []
|
||||
|
||||
if (props.pkg['available-update'] && props.pkg.state !== 'updating') {
|
||||
actions.push({
|
||||
@@ -157,7 +204,11 @@ const actionItems = computed(() => {
|
||||
actions.push({
|
||||
key: 'restart',
|
||||
emit: 'restart',
|
||||
label: props.pendingAction === 'restart' ? 'Restarting...' : t('common.restart'),
|
||||
// While anything is in flight this button carries the status for the
|
||||
// whole card (it is the one action always present), so the operator
|
||||
// sees a spinner + "Restarting…" for as long as the node is working.
|
||||
label: isBusy.value ? busyLabel.value || t('common.restart') : t('common.restart'),
|
||||
busy: isBusy.value,
|
||||
class: 'glass-button',
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user