Skip to content

Commit fec1ae3

Browse files
authored
Merge pull request #186 from FreeshardBase/fix/clayde/revive-exited-apps-on-access
Fix #185: revive exited apps on access via a real-state start primitive
2 parents 31596c6 + 233858d commit fec1ae3

10 files changed

Lines changed: 496 additions & 92 deletions

File tree

agents.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ shard_core/
2525
service/ → Business logic
2626
app_installation/ App install/uninstall/reinstall + background worker queue
2727
app_lifecycle.py Two-tier idle control: RUNNING -> PAUSED -> STOPPED, PSI-driven LRU demotion, wake-on-request
28-
app_tools.py Docker CLI wrapper functions (start/stop/pause/unpause/down)
28+
app_tools.py Docker CLI wrappers (pause/unpause/stop/down); start_app is the idempotent revive primitive that decides unpause vs up from real container state
2929
memory_pressure.py PSI parsing (/host/pressure/memory), cgroup v2 memory.reclaim page-out
3030
pause_metrics.py In-memory pause-tier telemetry accumulators (transitions, latencies, PSI snapshots)
3131
pairing.py Terminal pairing (JWT creation, code generation)

shard_core/data_model/backend/shard_model.py

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from enum import StrEnum, auto
55
from typing import List
66

7-
from pydantic import BaseModel
7+
from pydantic import BaseModel, field_validator
88

99
from .permission_model import PermissionHolder
1010
from .subscription_model import SubscriptionStatus
@@ -102,6 +102,8 @@ class ShardBase(BaseModel):
102102
price_cents: int | None = None
103103
pending_vm_size: VmSize | None = None
104104
pending_price_cents: int | None = None
105+
config_overrides_desired: dict[str, str] = {}
106+
config_overrides_applied: dict[str, str] = {}
105107

106108
@property
107109
def short_id(self) -> str:
@@ -226,6 +228,29 @@ class PairingCodeResponse(BaseModel):
226228
valid_until: datetime
227229

228230

231+
class ConfigOverrideKey(StrEnum):
232+
"""Allowlist of override keys an operator may set. Typing the request key as
233+
this enum is the single source of truth: it rejects unknown keys with 422 and
234+
surfaces the allowed set in the OpenAPI schema, so the frontend dropdown is
235+
generated from it. Adding an override key = one entry here. Each value is a
236+
valid env-var name by construction, so no separate format validation is
237+
needed."""
238+
239+
PAUSE_ENABLED = "PAUSE_ENABLED"
240+
241+
242+
class ConfigOverrideRequest(BaseModel):
243+
key: ConfigOverrideKey
244+
value: str
245+
246+
@field_validator("value")
247+
@classmethod
248+
def _value_has_no_newline(cls, v: str) -> str:
249+
if "\n" in v or "\r" in v:
250+
raise ValueError("value must not contain newlines")
251+
return v
252+
253+
229254
class CoreUpdateRequest(BaseModel):
230255
target_version: str
231256

shard_core/service/app_installation/worker.py

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from shard_core.database import installed_apps as db_installed_apps
1313
from shard_core.data_model.app_meta import Status
1414
from shard_core.service.app_tools import (
15+
app_op_lock,
1516
get_installed_apps_path,
1617
docker_create_app_containers,
1718
docker_stop_app,
@@ -123,31 +124,36 @@ async def _install_app_from_existing_zip(app_name: str):
123124

124125

125126
async def _uninstall_app(app_name: str):
126-
try:
127-
installed_app = await get_app_from_db(app_name)
128-
if installed_app.status == Status.PAUSED:
129-
# unfreeze while the status still says PAUSED — once it flips to
130-
# UNINSTALLING nothing knows the containers are frozen, and a
131-
# frozen stack can be neither stopped nor removed
132-
await docker_unpause_app(app_name)
133-
await update_app_status(app_name, Status.UNINSTALLING)
134-
except KeyError:
135-
log.warning(f"during uninstallation of {app_name}: app not found in database")
136-
137-
try:
138-
await docker_stop_app(app_name)
139-
await docker_shutdown_app(app_name)
140-
except Exception as e:
141-
log.error(f"Error while shutting down app {app_name}: {e!r}")
142-
143-
log.debug(f"deleting app data for {app_name}")
144-
shutil.rmtree(Path(get_installed_apps_path() / app_name), ignore_errors=True)
145-
log.debug(f"removing app {app_name} from database")
146-
async with db_conn() as conn:
147-
await db_installed_apps.remove(conn, app_name)
148-
await write_traefik_dyn_config()
149-
await signals.on_apps_update.send_async()
150-
log.info(f"uninstalled {app_name}")
127+
# Held across the whole teardown so a concurrent wake/control-tick revive
128+
# (start_app) can't recreate a container between our stop and row removal.
129+
async with app_op_lock(app_name):
130+
try:
131+
installed_app = await get_app_from_db(app_name)
132+
if installed_app.status == Status.PAUSED:
133+
# unfreeze while the status still says PAUSED — once it flips to
134+
# UNINSTALLING nothing knows the containers are frozen, and a
135+
# frozen stack can be neither stopped nor removed
136+
await docker_unpause_app(app_name)
137+
await update_app_status(app_name, Status.UNINSTALLING)
138+
except KeyError:
139+
log.warning(
140+
f"during uninstallation of {app_name}: app not found in database"
141+
)
142+
143+
try:
144+
await docker_stop_app(app_name)
145+
await docker_shutdown_app(app_name)
146+
except Exception as e:
147+
log.error(f"Error while shutting down app {app_name}: {e!r}")
148+
149+
log.debug(f"deleting app data for {app_name}")
150+
shutil.rmtree(Path(get_installed_apps_path() / app_name), ignore_errors=True)
151+
log.debug(f"removing app {app_name} from database")
152+
async with db_conn() as conn:
153+
await db_installed_apps.remove(conn, app_name)
154+
await write_traefik_dyn_config()
155+
await signals.on_apps_update.send_async()
156+
log.info(f"uninstalled {app_name}")
151157

152158

153159
async def _reinstall_app(app_name: str):

shard_core/service/app_lifecycle.py

Lines changed: 8 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88
from shard_core.data_model.app_meta import InstalledApp, Status
99
from shard_core.service import disk, memory_pressure, pause_metrics
1010
from shard_core.service.app_tools import (
11-
docker_start_app,
11+
start_app,
1212
docker_stop_app,
1313
docker_pause_app,
14-
docker_unpause_app,
1514
get_app_metadata,
1615
size_is_compatible,
1716
)
@@ -35,16 +34,12 @@ async def ensure_app_is_running(app: InstalledApp):
3534
if await size_is_compatible(app_meta.minimum_portal_size):
3635
global last_access_dict
3736
last_access_dict[app.name] = time.time()
38-
# PAUSED wakes via unpause (ms to ~2s), everything else via the legacy
39-
# start path. Calling docker_unpause_app directly also dodges the
40-
# global 5s throttle on docker_start_app, which would silently drop
41-
# the wake. This dispatch stays active even with pause_enabled=false,
42-
# so already-paused apps still wake after a backout.
43-
if app.status == Status.PAUSED:
44-
coro = docker_unpause_app(app.name)
45-
else:
46-
coro = docker_start_app(app.name)
47-
task = asyncio.create_task(coro, name=f"ensure {app.name} is running")
37+
# One idempotent revive primitive decides unpause vs up from the real
38+
# container state, so a paused stack unfreezes and an out-of-band exit
39+
# (crash, OOM, core-upgrade converge) still starts instead of 502-looping.
40+
task = asyncio.create_task(
41+
start_app(app.name), name=f"ensure {app.name} is running"
42+
)
4843
background_tasks.add(task)
4944
task.add_done_callback(background_tasks.discard)
5045

@@ -84,7 +79,7 @@ async def _control_app_time(app: InstalledApp, pause_enabled: bool):
8479
if app.status != Status.RUNNING and await size_is_compatible(
8580
app_meta.minimum_portal_size
8681
):
87-
await docker_start_app(app.name)
82+
await start_app(app.name)
8883
return
8984

9085
idle = time.time() - last_access_dict.get(app.name, 0.0)

shard_core/service/app_tools.py

Lines changed: 126 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import json
33
import logging
44
import time
5+
from collections import defaultdict
56
from pathlib import Path
7+
from typing import Literal
68

79
import shard_core.data_model.profile
810
from shard_core.database.connection import db_conn
@@ -31,41 +33,135 @@ async def docker_create_app_containers(name: str):
3133
await subprocess(*_app_compose(name), "up", "--no-start")
3234

3335

34-
@throttle(5)
35-
async def docker_start_app(name: str):
36-
async with db_conn() as conn:
37-
app = await db_installed_apps.get_by_name(conn, name)
38-
app_status = app["status"] if app else None
36+
ContainerState = Literal["running", "paused", "exited", "missing"]
3937

40-
if app_status == Status.PAUSED:
41-
# a paused app wakes by unfreezing, not by compose up
42-
await docker_unpause_app(name)
43-
return
38+
# Statuses a lifecycle revive may act on. Transitional/terminal ones
39+
# (INSTALLING, *_QUEUED, UNINSTALLING, REINSTALLING, ERROR) must never be
40+
# started, or the control tick recreates containers mid-uninstall/reinstall.
41+
_REVIVABLE_STATUS = (Status.STOPPED, Status.RUNNING, Status.DOWN, Status.PAUSED)
4442

45-
if app_status in [Status.STOPPED, Status.RUNNING, Status.DOWN]:
46-
log.debug(f"starting app {name=}")
47-
try:
43+
# Per-app op lock serializing revive against teardown: start_app and the
44+
# uninstall worker both take it, so a wake-on-access or control-tick revive can
45+
# never recreate a container the uninstall worker just removed (issue #185).
46+
_app_op_locks: dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
47+
48+
49+
def app_op_lock(name: str) -> asyncio.Lock:
50+
return _app_op_locks[name]
51+
52+
53+
async def get_app_container_state(name: str) -> ContainerState:
54+
"""Real docker state of an app's containers: running | paused | exited | missing.
55+
56+
Reads the daemon rather than the stored app status, so a caller can revive an
57+
app whose containers changed state out-of-band (crash, OOM, core-upgrade
58+
converge stop) while the database still says PAUSED or RUNNING.
59+
"""
60+
compose = _app_compose(name)
61+
try:
62+
ids_out = await subprocess(*compose, "ps", "-a", "-q")
63+
ids = [line.strip() for line in ids_out.splitlines() if line.strip()]
64+
if not ids:
65+
return "missing"
66+
states_out = await subprocess(
67+
"docker", "inspect", "--format", "{{.State.Status}}", *ids
68+
)
69+
except SubprocessError:
70+
return "missing"
71+
states = [line.strip() for line in states_out.splitlines() if line.strip()]
72+
if any(s == "paused" for s in states):
73+
return "paused"
74+
if states and all(s == "running" for s in states):
75+
return "running"
76+
return "exited"
77+
78+
79+
async def _compose_up(name: str):
80+
try:
81+
await subprocess(*_app_compose(name), "up", "-d")
82+
except SubprocessError as e:
83+
if "network" in str(e) and "not found" in str(e):
84+
log.warning(
85+
f"stale network reference for app {name=}, recreating containers"
86+
)
87+
await subprocess(*_app_compose(name), "down")
88+
await subprocess(*_app_compose(name), "up", "-d")
89+
elif "Conflict" in str(e) and "already in use" in str(e):
90+
log.warning(f"stale containers for app {name=}, removing and recreating")
91+
await subprocess(*_app_compose(name), "down")
4892
await subprocess(*_app_compose(name), "up", "-d")
49-
except SubprocessError as e:
50-
if "network" in str(e) and "not found" in str(e):
93+
else:
94+
raise
95+
96+
97+
async def _do_unpause(name: str):
98+
unpause_started = time.monotonic()
99+
await subprocess(*_app_compose(name), "unpause")
100+
pause_metrics.record_unpause_latency((time.monotonic() - unpause_started) * 1000)
101+
pause_metrics.record_app_transition(name, Status.PAUSED, Status.RUNNING)
102+
103+
104+
async def _mark_running(name: str):
105+
async with db_conn() as conn:
106+
await db_installed_apps.update_status(conn, name, Status.RUNNING)
107+
await signals.on_apps_update.send_async()
108+
109+
110+
@throttle(5)
111+
async def start_app(name: str) -> None:
112+
"""Bring a revivable app up, deciding from the real container state.
113+
114+
The single revive primitive for wake-on-access and the always-on control
115+
tick. It acts only on a revivable app (see _REVIVABLE_STATUS) and dispatches
116+
on the real container state, not the stored status, so an app that exited
117+
out-of-band while the database still says PAUSED still starts (via up) rather
118+
than erroring on unpause. Idempotent: an already-running stack is a no-op.
119+
120+
Holds the per-app op lock so a revive can never race the uninstall worker's
121+
teardown and recreate a container it just removed (issue #185).
122+
"""
123+
async with app_op_lock(name):
124+
async with db_conn() as conn:
125+
app = await db_installed_apps.get_by_name(conn, name)
126+
db_status = app["status"] if app else None
127+
if db_status not in _REVIVABLE_STATUS:
128+
log.debug(f"app {name=} has {db_status=}, skipping start")
129+
return
130+
131+
state = await get_app_container_state(name)
132+
133+
if state == "running":
134+
if db_status != Status.RUNNING:
51135
log.warning(
52-
f"stale network reference for app {name=}, recreating containers"
136+
f"app {name=} container is running but db says {db_status=}; reconciling"
53137
)
54-
await subprocess(*_app_compose(name), "down")
55-
await subprocess(*_app_compose(name), "up", "-d")
56-
elif "Conflict" in str(e) and "already in use" in str(e):
138+
await _mark_running(name)
139+
return
140+
141+
if state == "paused":
142+
if db_status != Status.PAUSED:
57143
log.warning(
58-
f"stale containers for app {name=}, removing and recreating"
144+
f"app {name=} container is paused but db says {db_status=}; unpausing"
59145
)
60-
await subprocess(*_app_compose(name), "down")
61-
await subprocess(*_app_compose(name), "up", "-d")
62-
else:
63-
raise
64-
async with db_conn() as conn:
65-
await db_installed_apps.update_status(conn, name, Status.RUNNING)
66-
await signals.on_apps_update.send_async()
67-
else:
68-
log.debug(f"app {name=} has status {app_status}, skipping start")
146+
log.debug(f"unpausing app {name=}")
147+
try:
148+
await _do_unpause(name)
149+
except SubprocessError:
150+
# a partially-paused stack (some containers already exited) can't
151+
# be revived by unpause — fall back to a plain start
152+
log.warning(f"unpause failed for {name=}, starting instead")
153+
await _compose_up(name)
154+
await _mark_running(name)
155+
return
156+
157+
# exited / created / missing
158+
if db_status in (Status.RUNNING, Status.PAUSED):
159+
log.warning(
160+
f"app {name=} container is {state} but db says {db_status=}; starting it"
161+
)
162+
log.debug(f"starting app {name=}")
163+
await _compose_up(name)
164+
await _mark_running(name)
69165

70166

71167
async def docker_pause_app(name: str):
@@ -93,15 +189,8 @@ async def docker_unpause_app(name: str):
93189
app_status = app["status"] if app else None
94190
if app_status == Status.PAUSED:
95191
log.debug(f"unpausing app {name=}")
96-
unpause_started = time.monotonic()
97-
await subprocess(*_app_compose(name), "unpause")
98-
pause_metrics.record_unpause_latency(
99-
(time.monotonic() - unpause_started) * 1000
100-
)
101-
pause_metrics.record_app_transition(name, Status.PAUSED, Status.RUNNING)
102-
async with db_conn() as conn:
103-
await db_installed_apps.update_status(conn, name, Status.RUNNING)
104-
await signals.on_apps_update.send_async()
192+
await _do_unpause(name)
193+
await _mark_running(name)
105194
else:
106195
log.debug(f"app {name=} has {app_status=}, skipping unpause")
107196

0 commit comments

Comments
 (0)