Skip to content

Commit 94a83bc

Browse files
authored
feat(dashboard): drain-and-restart from memory observability card (lidge-jun#563) (lidge-jun#580)
* feat(dashboard): drain-and-restart from memory observability card Expose a longer 60s informed recycle on the memory card (POST /api/system/restart) that reuses drainAndShutdown and respawns via ensure/service without Codex teardown (lidge-jun#563). * fix(dashboard): harden drain-and-restart respawn and exit cleanup Address Codex review: use failure-exit for supervised service children, spawn start with the live port (not ensure), suppress injection teardown on recycle, and clear the GUI draining state on pid change. * fix(dashboard): arm drain immediately and harden reconnect polling Set draining before the 200ms flush delay, bound reconnect /healthz polls, announce restart status to assistive tech, and clarify docs around active-turn drain timeout abort.
1 parent 3a2b2ea commit 94a83bc

17 files changed

Lines changed: 626 additions & 37 deletions

File tree

docs-site/src/content/docs/troubleshooting/windows-memory.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,13 @@ runtime the leak itself remains an upstream problem:
5050
whereas a flat `responseState` under rising observed memory points away from
5151
that store. The values are scalar-only — no request bodies, tokens, paths, or
5252
account identifiers — and the read is side-effect free (it never prunes or
53-
evicts). The dashboard's read-only **Memory observability** card renders the
54-
same fields.
53+
evicts). The dashboard's **Memory observability** card renders the
54+
same fields and offers a confirm-gated **Drain & restart** action: it shows
55+
the current active-turn count, waits up to 60s for active turns (reusing
56+
the existing 503 + `Retry-After` drain), then aborts any remaining turns and
57+
restarts the proxy via `ocx start` on the live port (or a failure-only
58+
service supervisor respawn) without tearing down Codex injection. That is a
59+
longer, informed recycle than the short drain on `POST /api/stop`.
5560
- **A gated alternative stream path** — a bounded single-reader relay that
5661
removes the unbounded buffering shape entirely. It becomes the default
5762
automatically once a bundled Bun release verifiably carries the #32111 fix;

gui/src/components/MemoryObservabilityCard.tsx

Lines changed: 162 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,10 @@ import { IconActivity } from "../icons";
44
import { useI18n, type Locale } from "../i18n/shared";
55

66
/**
7-
* Read-only Memory observability card. Polls GET /api/system/memory (the #314 WP3
8-
* service-process introspection surface) every 5s and renders scalar diagnostics
9-
* only: no sliders, no restart toggle, no PUT. Observed memory is the largest
10-
* of RSS, external, and ArrayBuffers so Windows working-set trimming does not
11-
* hide committed retention; a rising continuation-store total under rising
12-
* observed memory points at conversation retention.
7+
* Memory observability card. Polls GET /api/system/memory (#314 WP3) every 5s
8+
* and renders scalar diagnostics. Also hosts the confirm-gated Drain & restart
9+
* action (#563): longer 60s drain, then respawn via ensure/service — not the
10+
* short /api/stop teardown path.
1311
*/
1412

1513
interface MemorySample {
@@ -33,6 +31,7 @@ interface ResponseState {
3331
}
3432

3533
interface SystemMemory {
34+
pid?: number;
3635
rss: number;
3736
heapUsed: number;
3837
heapTotal: number;
@@ -43,9 +42,14 @@ interface SystemMemory {
4342
jscHeap: { heapSize: number; heapCapacity: number; objectCount: number } | null;
4443
/** Absent on older proxies whose /api/system/memory predates the continuation-store metrics. */
4544
responseState?: ResponseState;
45+
/** Absent on older proxies predating the drain-and-restart action (#563). */
46+
activeTurnCount?: number;
47+
isDraining?: boolean;
4648
watchdog: { warnThresholdBytes: number; lastWarnAt: number | null; observedBytes?: number; observedMetric?: MemoryMetric; samples: MemorySample[] } | null;
4749
}
4850

51+
type RestartPhase = "idle" | "draining" | "reconnecting" | "error";
52+
4953
/**
5054
* Render a byte count with a binary-scaled unit; non-finite/zero inputs render as "0 B".
5155
* The divisor is 1024, so the labels must be the binary ones (KiB/MiB/...). Labelling a
@@ -127,10 +131,34 @@ function Stat({ label, value }: { label: string; value: string }) {
127131
);
128132
}
129133

134+
const DRAIN_TIMEOUT_S = 60;
135+
const RECONNECT_POLL_MS = 1500;
136+
const RECONNECT_GIVE_UP_MS = 120_000;
137+
130138
export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }) {
131139
const { locale, t } = useI18n();
132140
const [data, setData] = useState<SystemMemory | null>(null);
133141
const [unavailable, setUnavailable] = useState(false);
142+
const [restartPhase, setRestartPhase] = useState<RestartPhase>("idle");
143+
const [restartError, setRestartError] = useState<string | null>(null);
144+
const [noSupervisor, setNoSupervisor] = useState(false);
145+
const [supportsRestart, setSupportsRestart] = useState(false);
146+
const [restartFromPid, setRestartFromPid] = useState<number | null>(null);
147+
148+
useEffect(() => {
149+
let cancelled = false;
150+
void (async () => {
151+
try {
152+
const res = await fetch(`${apiBase}/api/startup-health`);
153+
if (!res.ok || cancelled) return;
154+
const json = await res.json() as { protection?: string };
155+
if (!cancelled) setNoSupervisor(json.protection === "none");
156+
} catch {
157+
/* older proxies / offline — leave warning off */
158+
}
159+
})();
160+
return () => { cancelled = true; };
161+
}, [apiBase]);
134162

135163
useEffect(() => {
136164
let cancelled = false;
@@ -160,10 +188,31 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
160188
if (!cancelled) {
161189
setData(json);
162190
setUnavailable(false);
191+
setSupportsRestart(typeof json.activeTurnCount === "number");
192+
if (json.isDraining && restartPhase === "idle") setRestartPhase("draining");
193+
// Fast recycle can finish between polls with no observed outage — detect pid change.
194+
if (
195+
(restartPhase === "draining" || restartPhase === "reconnecting")
196+
&& restartFromPid != null
197+
&& typeof json.pid === "number"
198+
&& json.pid !== restartFromPid
199+
&& !json.isDraining
200+
) {
201+
setRestartPhase("idle");
202+
setRestartFromPid(null);
203+
setRestartError(null);
204+
}
163205
}
164206
} catch {
165207
// Old servers (pre-#314) 404 this route; degrade to a quiet unavailable note.
166-
if (!cancelled) setUnavailable(true);
208+
// During drain/restart the proxy goes away — switch to reconnect polling.
209+
if (!cancelled) {
210+
if (restartPhase === "draining" || restartPhase === "reconnecting") {
211+
setRestartPhase("reconnecting");
212+
} else {
213+
setUnavailable(true);
214+
}
215+
}
167216
} finally {
168217
if (timeoutId !== undefined) clearTimeout(timeoutId);
169218
if (activeController === controller) activeController = null;
@@ -177,9 +226,80 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
177226
activeController?.abort();
178227
clearInterval(interval);
179228
};
180-
}, [apiBase]);
229+
}, [apiBase, restartPhase, restartFromPid]);
181230

182-
if (unavailable && !data) {
231+
useEffect(() => {
232+
if (restartPhase !== "reconnecting") return;
233+
let cancelled = false;
234+
let inFlight = false;
235+
const started = Date.now();
236+
const tick = async () => {
237+
if (inFlight) return;
238+
inFlight = true;
239+
const controller = new AbortController();
240+
const timeoutId = setTimeout(() => controller.abort(), 5_000);
241+
try {
242+
const res = await fetch(`${apiBase}/healthz`, { cache: "no-store", signal: controller.signal });
243+
if (res.ok && !cancelled) {
244+
let replaced = restartFromPid == null;
245+
if (restartFromPid != null) {
246+
try {
247+
const health = await res.json() as { pid?: number };
248+
replaced = typeof health.pid === "number" && health.pid !== restartFromPid;
249+
} catch {
250+
replaced = true;
251+
}
252+
}
253+
if (replaced) {
254+
setRestartPhase("idle");
255+
setRestartFromPid(null);
256+
setRestartError(null);
257+
return;
258+
}
259+
}
260+
} catch {
261+
/* still down / aborted */
262+
} finally {
263+
clearTimeout(timeoutId);
264+
inFlight = false;
265+
}
266+
if (!cancelled && Date.now() - started >= RECONNECT_GIVE_UP_MS) {
267+
setRestartPhase("error");
268+
setRestartError(t("dash.mem.restartFailed"));
269+
}
270+
};
271+
void tick();
272+
const interval = setInterval(() => void tick(), RECONNECT_POLL_MS);
273+
return () => {
274+
cancelled = true;
275+
clearInterval(interval);
276+
};
277+
}, [apiBase, restartPhase, restartFromPid, t]);
278+
279+
const confirmRestart = () => {
280+
const count = data?.activeTurnCount ?? 0;
281+
const lines = [
282+
t("dash.mem.restartConfirm", { count, seconds: DRAIN_TIMEOUT_S }),
283+
];
284+
if (noSupervisor) lines.push(t("dash.mem.restartNoSupervisor"));
285+
if (!window.confirm(lines.join("\n\n"))) return;
286+
void (async () => {
287+
setRestartError(null);
288+
setRestartFromPid(typeof data?.pid === "number" ? data.pid : null);
289+
setRestartPhase("draining");
290+
try {
291+
const res = await fetch(`${apiBase}/api/system/restart`, { method: "POST" });
292+
if (!res.ok) throw new Error("restart_failed");
293+
// Proxy will drain then exit; memory poll will trip reconnecting or pid change.
294+
} catch {
295+
setRestartPhase("error");
296+
setRestartFromPid(null);
297+
setRestartError(t("dash.mem.restartFailed"));
298+
}
299+
})();
300+
};
301+
302+
if (unavailable && !data && restartPhase === "idle") {
183303
return (
184304
<div className="panel" style={{ marginBottom: 24 }}>
185305
<div className="font-semibold" style={{ display: "flex", alignItems: "center", gap: 8 }}>
@@ -196,6 +316,8 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
196316
const observedBy = data ? observedMetric(data) : null;
197317
// Optional on purpose: a 200 from an older proxy may lack the responseState field.
198318
const responseState = data?.responseState;
319+
const activeTurns = data?.activeTurnCount;
320+
const busy = restartPhase === "draining" || restartPhase === "reconnecting";
199321

200322
return (
201323
<div className="panel" style={{ marginBottom: 24 }}>
@@ -249,6 +371,37 @@ export default function MemoryObservabilityCard({ apiBase }: { apiBase: string }
249371
</div>
250372
)}
251373
</details>
374+
375+
{supportsRestart && (
376+
<div style={{ marginTop: 14, display: "flex", flexWrap: "wrap", alignItems: "center", gap: 12 }} aria-live="polite">
377+
<Stat
378+
label={t("dash.mem.inFlight")}
379+
value={typeof activeTurns === "number" ? plainNumberFormat(locale).format(activeTurns) : "—"}
380+
/>
381+
<button
382+
type="button"
383+
className="btn btn-ghost btn-sm"
384+
disabled={busy}
385+
onClick={confirmRestart}
386+
>
387+
{t("dash.mem.restart")}
388+
</button>
389+
{restartPhase === "draining" && (
390+
<span className="muted text-control">
391+
{t("dash.mem.draining", { count: typeof activeTurns === "number" ? activeTurns : 0 })}
392+
</span>
393+
)}
394+
{restartPhase === "reconnecting" && (
395+
<span className="muted text-control">{t("dash.mem.reconnecting")}</span>
396+
)}
397+
{restartPhase === "error" && restartError && (
398+
<span className="text-control" style={{ color: "var(--danger, #c44)" }}>{restartError}</span>
399+
)}
400+
{noSupervisor && restartPhase === "idle" && (
401+
<span className="muted text-control">{t("dash.mem.restartNoSupervisor")}</span>
402+
)}
403+
</div>
404+
)}
252405
</div>
253406
);
254407
}

gui/src/i18n/de.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,13 @@ export const de: Record<TKey, string> = {
136136
"dash.mem.never": "Nie",
137137
"dash.mem.details": "Details",
138138
"dash.mem.unavailable": "Speicherdiagnose nicht verfügbar (älterer Proxy).",
139+
"dash.mem.inFlight": "Laufende Anfragen",
140+
"dash.mem.restart": "Abwarten & neu starten",
141+
"dash.mem.restartConfirm": "Auf {count} laufende Anfrage(n) warten, dann neu starten (bis zu {seconds}s; Rest wird bei Timeout abgebrochen).",
142+
"dash.mem.draining": "{count} Anfrage(n) werden abgewartet… Neustart danach",
143+
"dash.mem.reconnecting": "Proxy wird neu gestartet… warte auf Verbindung",
144+
"dash.mem.restartFailed": "Abwarten & Neustart fehlgeschlagen. Prüfen Sie, ob der Proxy läuft.",
145+
"dash.mem.restartNoSupervisor": "Kein Neustartschutz erkannt. Der Proxy bleibt nach dem Neustart möglicherweise aus, bis Sie ihn erneut starten.",
139146
"dash.activeProviders": "Aktive Anbieter",
140147
"dash.noProviders": "Keine Anbieter konfiguriert. Führe {cmd} aus.",
141148
"dash.col.name": "Name",

gui/src/i18n/en.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,13 @@ export const en = {
146146
"dash.mem.never": "Never",
147147
"dash.mem.details": "Details",
148148
"dash.mem.unavailable": "Memory diagnostics unavailable (older proxy).",
149+
"dash.mem.inFlight": "In-flight requests",
150+
"dash.mem.restart": "Drain & restart",
151+
"dash.mem.restartConfirm": "Wait for {count} in-flight request(s), then restart (up to {seconds}s; remaining requests are cut on timeout).",
152+
"dash.mem.draining": "Draining {count} request(s)… restarting when complete",
153+
"dash.mem.reconnecting": "Proxy restarting… waiting to reconnect",
154+
"dash.mem.restartFailed": "Drain & restart failed. Check that the proxy is running.",
155+
"dash.mem.restartNoSupervisor": "No restart protection detected. The proxy may stay down after restart unless you start it again.",
149156
"dash.activeProviders": "Active providers",
150157
"dash.noProviders": "No providers configured. Run {cmd}.",
151158
"dash.col.name": "Name",

gui/src/i18n/ja.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ export const ja: Record<TKey, string> = {
145145
"dash.mem.never": "なし",
146146
"dash.mem.details": "詳細",
147147
"dash.mem.unavailable": "メモリ診断は利用できません(旧バージョンのプロキシ)。",
148+
"dash.mem.inFlight": "処理中のリクエスト",
149+
"dash.mem.restart": "完了後に再起動",
150+
"dash.mem.restartConfirm": "処理中のリクエスト {count} 件の完了を待ってから再起動します(最大 {seconds} 秒。タイムアウト時は残りを打ち切ります)。",
151+
"dash.mem.draining": "リクエスト {count} 件の完了を待機中… 完了後に再起動",
152+
"dash.mem.reconnecting": "プロキシを再起動中… 再接続を待機",
153+
"dash.mem.restartFailed": "完了後の再起動に失敗しました。プロキシが起動しているか確認してください。",
154+
"dash.mem.restartNoSupervisor": "再起動保護がありません。再起動後、プロキシが自動で戻らない可能性があります。",
148155
"dash.activeProviders": "アクティブなプロバイダー",
149156
"dash.noProviders": "プロバイダーが設定されていません。{cmd} を実行してください。",
150157
"dash.col.name": "名前",

gui/src/i18n/ko.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ export const ko: Record<TKey, string> = {
140140
"dash.mem.never": "없음",
141141
"dash.mem.details": "상세 정보",
142142
"dash.mem.unavailable": "메모리 진단을 사용할 수 없음 (구버전 프록시).",
143+
"dash.mem.inFlight": "진행 중 요청",
144+
"dash.mem.restart": "작업 완료 후 재시작",
145+
"dash.mem.restartConfirm": "진행 중 요청 {count}개가 끝날 때까지 기다린 뒤 재시작합니다(최대 {seconds}초; 시간이 지나면 남은 요청은 중단됩니다).",
146+
"dash.mem.draining": "요청 {count}개 완료 대기 중… 끝나면 재시작",
147+
"dash.mem.reconnecting": "프록시 재시작 중… 다시 연결하는 중",
148+
"dash.mem.restartFailed": "작업 완료 후 재시작에 실패했습니다. 프록시가 실행 중인지 확인하세요.",
149+
"dash.mem.restartNoSupervisor": "재시작 보호가 없습니다. 재시작 후 프록시가 자동으로 올라오지 않을 수 있습니다.",
143150
"dash.activeProviders": "활성 프로바이더",
144151
"dash.noProviders": "설정된 프로바이더가 없습니다. {cmd} 를 실행하세요.",
145152
"dash.col.name": "이름",

gui/src/i18n/ru.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,13 @@ export const ru: Record<TKey, string> = {
145145
"dash.mem.never": "Никогда",
146146
"dash.mem.details": "Подробности",
147147
"dash.mem.unavailable": "Диагностика памяти недоступна (старая версия прокси).",
148+
"dash.mem.inFlight": "Активные запросы",
149+
"dash.mem.restart": "Дождаться и перезапустить",
150+
"dash.mem.restartConfirm": "Дождаться завершения {count} активных запросов, затем перезапустить (до {seconds} с; оставшиеся при таймауте прервутся).",
151+
"dash.mem.draining": "Ожидание {count} запрос(ов)… перезапуск после завершения",
152+
"dash.mem.reconnecting": "Прокси перезапускается… ожидание подключения",
153+
"dash.mem.restartFailed": "Не удалось дождаться и перезапустить. Проверьте, что прокси запущен.",
154+
"dash.mem.restartNoSupervisor": "Защита перезапуска не обнаружена. После перезапуска прокси может остаться выключенным, пока вы не запустите его снова.",
148155
"dash.activeProviders": "Активные провайдеры",
149156
"dash.noProviders": "Провайдеры не настроены. Выполните {cmd}.",
150157
"dash.col.name": "Название",

gui/src/i18n/zh.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,13 @@ export const zh: Record<TKey, string> = {
140140
"dash.mem.never": "从不",
141141
"dash.mem.details": "详情",
142142
"dash.mem.unavailable": "内存诊断不可用(旧版代理)。",
143+
"dash.mem.inFlight": "进行中的请求",
144+
"dash.mem.restart": "排空并重启",
145+
"dash.mem.restartConfirm": "等待 {count} 个进行中的请求结束后再重启(最多 {seconds} 秒;超时将中断剩余请求)。",
146+
"dash.mem.draining": "正在等待 {count} 个请求完成… 完成后重启",
147+
"dash.mem.reconnecting": "代理正在重启… 等待重新连接",
148+
"dash.mem.restartFailed": "排空并重启失败。请确认代理正在运行。",
149+
"dash.mem.restartNoSupervisor": "未检测到重启保护。重启后代理可能不会自动恢复,需手动启动。",
143150
"dash.activeProviders": "活跃提供方",
144151
"dash.noProviders": "尚未配置提供方。请运行 {cmd}。",
145152
"dash.col.name": "名称",

0 commit comments

Comments
 (0)