Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gui/src/i18n/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,7 +267,7 @@ export const de: Record<TKey, string> = {
"dash.codexRestarting": "Wird gestoppt…",
"dash.codexRestartConfirm": "Codex-App-Server stoppen, damit sie die Modellliste neu laden? Ein laufender Codex-Vorgang wird unterbrochen, und Codex startet nicht von selbst neu — öffne es danach erneut.",
"dash.codexRestartDone": "{count} Codex-App-Server gestoppt. Öffne Codex erneut, um die aktuelle Modellliste zu laden.",
"dash.codexRestartNothing": "Es läuft kein Codex-App-Server. Der nächste Start liest die aktuelle Modellliste.",
"dash.codexRestartNothing": "Kein veralteter Codex-App-Server musste neu gestartet werden. Es wurde nichts beendet.",
"dash.codexRestartUnknown": "Prozesse konnten nicht aufgelistet werden, daher wurde nichts gestoppt.",
"dash.codexRestartPartial": "{count} App-Server wurden nicht beendet. Beende sie manuell, falls die Modellliste veraltet bleibt.",
"dash.codexRestartFailed": "Codex-Modelle konnten nicht neu geladen werden (HTTP {status}).",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export const en = {
"dash.codexRestarting": "Stopping…",
"dash.codexRestartConfirm": "Stop Codex app-servers so they reload the model list? Any Codex turn in progress is interrupted, and Codex does not relaunch on its own — reopen it afterwards.",
"dash.codexRestartDone": "Stopped {count} Codex app-server(s). Reopen Codex to load the current model list.",
"dash.codexRestartNothing": "No Codex app-server is running. The next launch reads the current model list.",
"dash.codexRestartNothing": "No stale Codex app-server required a restart. Nothing was stopped.",
"dash.codexRestartUnknown": "Could not list processes, so nothing was stopped.",
"dash.codexRestartPartial": "{count} app-server(s) did not exit. Stop them manually if the model list stays stale.",
"dash.codexRestartFailed": "Failed to reload Codex models (HTTP {status}).",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ export const fr: Record<TKey, string> = {
"dash.codexRestarting": "Arrêt…",
"dash.codexRestartConfirm": "Arrêter les serveurs d’application Codex afin qu’ils rechargent la liste des modèles ? Tout tour Codex en cours sera interrompu et Codex ne redémarrera pas automatiquement — rouvrez-le ensuite.",
"dash.codexRestartDone": "{count} serveur(s) d’application Codex arrêté(s). Rouvrez Codex pour charger la liste actuelle des modèles.",
"dash.codexRestartNothing": "Aucun serveur d’application Codex n’est en cours d’exécution. Le prochain lancement lira la liste actuelle des modèles.",
"dash.codexRestartNothing": "Aucun serveur d’application Codex obsolète ne nécessitait de redémarrage. Aucun processus n’a été arrêté.",
"dash.codexRestartUnknown": "Impossible de répertorier les processus ; aucun n’a donc été arrêté.",
"dash.codexRestartPartial": "{count} serveur(s) d’application ne se sont pas arrêtés. Arrêtez-les manuellement si la liste des modèles reste obsolète.",
"dash.codexRestartFailed": "Échec du rechargement des modèles Codex (HTTP {status}).",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export const ja: Record<TKey, string> = {
"dash.codexRestarting": "停止中…",
"dash.codexRestartConfirm": "Codex app-server を停止してモデル一覧を読み直させますか? 進行中の Codex の処理は中断され、Codex は自動では再起動しないので後で開き直してください。",
"dash.codexRestartDone": "Codex app-server を {count} 個停止しました。Codex を開き直すと最新のモデル一覧が読み込まれます。",
"dash.codexRestartNothing": "実行中の Codex app-server はありません。次回起動時に最新のモデル一覧を読み込みます。",
"dash.codexRestartNothing": "再起動が必要な古い Codex app-server はありません。何も停止していません。",
"dash.codexRestartUnknown": "プロセスを列挙できなかったため、何も停止しませんでした。",
"dash.codexRestartPartial": "app-server が {count} 個終了しませんでした。モデル一覧が古いままなら手動で停止してください。",
"dash.codexRestartFailed": "Codex のモデル一覧を再読み込みできませんでした (HTTP {status})。",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export const ko: Record<TKey, string> = {
"dash.codexRestarting": "종료하는 중…",
"dash.codexRestartConfirm": "Codex app-server를 종료해 모델 목록을 다시 읽게 할까요? 진행 중인 Codex 작업이 끊기고, Codex가 저절로 다시 뜨지는 않으니 끝나면 직접 열어야 합니다.",
"dash.codexRestartDone": "Codex app-server {count}개를 종료했습니다. Codex를 다시 열면 최신 모델 목록이 보입니다.",
"dash.codexRestartNothing": "실행 중인 Codex app-server가 없습니다. 다음 실행 때 최신 목록을 읽습니다.",
"dash.codexRestartNothing": "재시작이 필요한 오래된 Codex app-server가 없습니다. 아무 프로세스도 종료하지 않았습니다.",
"dash.codexRestartUnknown": "프로세스 목록을 읽지 못해 아무것도 종료하지 않았습니다.",
"dash.codexRestartPartial": "app-server {count}개가 종료되지 않았습니다. 모델 목록이 최신 상태로 바뀌지 않으면 직접 종료하세요.",
"dash.codexRestartFailed": "Codex 모델 목록을 새로고침하지 못했습니다 (HTTP {status}).",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -276,7 +276,7 @@ export const ru: Record<TKey, string> = {
"dash.codexRestarting": "Останавливается…",
"dash.codexRestartConfirm": "Остановить app-server'ы Codex, чтобы они перечитали список моделей? Текущий ход Codex будет прерван, и Codex не перезапустится сам — откройте его заново.",
"dash.codexRestartDone": "Остановлено app-server Codex: {count}. Откройте Codex заново, чтобы загрузить актуальный список моделей.",
"dash.codexRestartNothing": "Ни один app-server Codex не запущен. При следующем запуске будет прочитан актуальный список моделей.",
"dash.codexRestartNothing": "Устаревших app-server Codex, требующих перезапуска, нет. Ничего не было остановлено.",
"dash.codexRestartUnknown": "Не удалось получить список процессов, поэтому ничего не остановлено.",
"dash.codexRestartPartial": "app-server не завершились: {count}. Остановите их вручную, если список моделей остаётся устаревшим.",
"dash.codexRestartFailed": "Не удалось обновить список моделей Codex (HTTP {status}).",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,7 +277,7 @@ export const tr: Record<TKey, string> = {
"dash.codexRestarting": "Durduruluyor…",
"dash.codexRestartConfirm": "Model listesini yeniden okumaları için Codex app-server'ları durdurulsun mu? Süren bir Codex işlemi kesilir ve Codex kendiliğinden yeniden başlamaz — sonrasında yeniden açın.",
"dash.codexRestartDone": "{count} Codex app-server durduruldu. Güncel model listesi için Codex'i yeniden açın.",
"dash.codexRestartNothing": "Çalışan Codex app-server yok. Sonraki açılışta güncel model listesi okunur.",
"dash.codexRestartNothing": "Yeniden başlatılması gereken eski bir Codex app-server yok. Hiçbir işlem durdurulmadı.",
"dash.codexRestartUnknown": "Süreçler listelenemedi, bu yüzden hiçbir şey durdurulmadı.",
"dash.codexRestartPartial": "{count} app-server kapanmadı. Model listesi eski kalırsa bunları elle durdurun.",
"dash.codexRestartFailed": "Codex model listesi yenilenemedi (HTTP {status}).",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/zh-TW.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export const zhTW: Record<TKey, string> = {
"dash.codexRestarting": "正在停止…",
"dash.codexRestartConfirm": "停止 Codex app-server 以重新讀取模型清單?進行中的 Codex 工作會中斷,且 Codex 不會自動重啟,請稍後自行重新開啟。",
"dash.codexRestartDone": "已停止 {count} 個 Codex app-server。重新開啟 Codex 即可載入最新的模型清單。",
"dash.codexRestartNothing": "沒有執行中的 Codex app-server。下次啟動會讀取最新的模型清單。",
"dash.codexRestartNothing": "沒有需要重新啟動的舊版 Codex app-server。未停止任何程序。",
"dash.codexRestartUnknown": "無法列舉行程,因此沒有停止任何行程。",
"dash.codexRestartPartial": "有 {count} 個 app-server 未結束。若模型清單仍然過舊,請手動停止。",
"dash.codexRestartFailed": "無法重新載入 Codex 模型清單 (HTTP {status})。",
Expand Down
2 changes: 1 addition & 1 deletion gui/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export const zh: Record<TKey, string> = {
"dash.codexRestarting": "正在停止…",
"dash.codexRestartConfirm": "停止 Codex app-server 以便重新读取模型列表?进行中的 Codex 任务会被中断,且 Codex 不会自动重启,请稍后自行重新打开。",
"dash.codexRestartDone": "已停止 {count} 个 Codex app-server。重新打开 Codex 即可加载最新模型列表。",
"dash.codexRestartNothing": "没有正在运行的 Codex app-server。下次启动会读取最新模型列表。",
"dash.codexRestartNothing": "没有需要重启的旧 Codex app-server。未停止任何进程。",
"dash.codexRestartUnknown": "无法枚举进程,因此没有停止任何进程。",
"dash.codexRestartPartial": "有 {count} 个 app-server 未退出。若模型列表仍然过旧,请手动停止。",
"dash.codexRestartFailed": "无法重新加载 Codex 模型列表 (HTTP {status})。",
Expand Down
11 changes: 6 additions & 5 deletions gui/src/use-codex-restart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ export interface CodexRestartController {
/**
* Resolves to the response code, or null when the user declined the confirm or
* the call failed. Callers that track staleness must treat BOTH `stopped` and
* `nothing_running` as "no stale app-server remains" — the second is the race
* where the target exited on its own, and refreshing on only the first would
* leave a staleness banner up after a successful outcome.
* `nothing_running` as "no stale app-server remains" — that includes no
* process, a target that exited on its own, and app-servers already newer than
* the catalog. Refreshing on only `stopped` would leave a staleness banner up
* after any of those successful outcomes.
*/
restart: () => Promise<CodexRestartCode | null>;
}
Expand Down Expand Up @@ -84,7 +85,8 @@ export function useCodexRestart(
} else if (result.code === "enumeration_unavailable") {
alert(t("dash.codexRestartUnknown"));
} else {
alert(t("dash.codexRestartPartial", { count: String(result.surviving.length) }));
const unresolvedCount = new Set([...result.surviving, ...result.failed]).size;
alert(t("dash.codexRestartPartial", { count: String(unresolvedCount) }));
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// Only while mounted: a settled callback typically starts a refresh fetch,
Expand All @@ -95,4 +97,3 @@ export function useCodexRestart(

return { restarting, restart };
}

23 changes: 18 additions & 5 deletions gui/tests/codex-restart.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,10 +147,10 @@ describe("requestCodexRestart", () => {

test("passes each response code through with a self-consistent body", async () => {
const cases = [
{ code: "stopped", success: true, stopped: [4242], surviving: [], failed: [] },
{ code: "nothing_running", success: true, stopped: [], surviving: [], failed: [] },
{ code: "enumeration_unavailable", success: true, stopped: [], surviving: [], failed: [] },
{ code: "partially_stopped", success: false, stopped: [], surviving: [4242], failed: [] },
{ stateBefore: "stale", code: "stopped", success: true, requested: [4242], stopped: [4242], surviving: [], failed: [] },
{ stateBefore: "stale", code: "nothing_running", success: true, requested: [], stopped: [], surviving: [], failed: [] },
{ stateBefore: "unknown", code: "enumeration_unavailable", success: true, requested: [], stopped: [], surviving: [], failed: [] },
{ stateBefore: "stale", code: "partially_stopped", success: false, requested: [4242], stopped: [], surviving: [4242], failed: [] },
] as const;
for (const patch of cases) {
const outcome = await requestCodexRestart("", {
Expand Down Expand Up @@ -181,10 +181,24 @@ describe("requestCodexRestart", () => {
})) as typeof fetch,
...formatters,
});
const stoppedWithoutAccounting = await requestCodexRestart("", {
fetchFn: (async () => response({ ...STOPPED, stopped: [] })) as typeof fetch,
...formatters,
});
const duplicatePids = await requestCodexRestart("", {
fetchFn: (async () => response({
...STOPPED,
requested: [4242, 4242],
stopped: [4242, 4242],
})) as typeof fetch,
...formatters,
});

expect(successWithFailureCode).toEqual({ ok: false, message: "malformed" });
expect(cleanCodeWithSurvivors).toEqual({ ok: false, message: "malformed" });
expect(nothingRunningButStopped).toEqual({ ok: false, message: "malformed" });
expect(stoppedWithoutAccounting).toEqual({ ok: false, message: "malformed" });
expect(duplicatePids).toEqual({ ok: false, message: "malformed" });
});

test("a body that never finishes arriving is a timeout, not a malformed body", async () => {
Expand Down Expand Up @@ -219,4 +233,3 @@ describe("requestCodexRestart", () => {
expect(outcome).toEqual({ ok: false, message: "malformed" });
});
});

34 changes: 34 additions & 0 deletions gui/tests/codex-stale-banner-dom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,17 +162,48 @@ test("nothing_running also counts as settled", async () => {
expect(reloads).toBe(1);
});

test("a fresh no-op reports that no stale target was stopped", async () => {
let reloads = 0;
let message = "";
Object.defineProperty(globalThis, "alert", {
configurable: true,
value: (value: unknown) => { message = String(value); },
});
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: async () => new Response(JSON.stringify(restartBody({
stateBefore: "fresh",
code: "nothing_running",
requested: [],
stopped: [],
})), { status: 200, headers: { "content-type": "application/json" } }),
});

render(<Harness initialState="fresh" onReload={() => { reloads += 1; }} />);
const head = host.querySelector('[data-testid="head"]') as HTMLButtonElement;
await act(async () => { head.click(); });

expect(reloads).toBe(1);
expect(message).toBe("No stale Codex app-server required a restart. Nothing was stopped.");
});

test("an unresolved outcome does not clear the banner", async () => {
// partially_stopped means a target is still holding the old catalog, so the
// banner must stay and the state must not be re-read as if it were settled.
let reloads = 0;
let message = "";
Object.defineProperty(globalThis, "alert", {
configurable: true,
value: (value: unknown) => { message = String(value); },
});
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: async () => new Response(JSON.stringify(restartBody({
success: false,
code: "partially_stopped",
stopped: [],
surviving: [4242],
failed: [4242],
})), { status: 200, headers: { "content-type": "application/json" } }),
});

Expand All @@ -182,6 +213,9 @@ test("an unresolved outcome does not clear the banner", async () => {

expect(reloads).toBe(0);
expect(host.querySelector(".codex-stale-banner")).not.toBeNull();
expect(message).toBe(
"1 app-server(s) did not exit. Stop them manually if the model list stays stale.",
);
});

test("a declined confirm sends no request and does not refresh", async () => {
Expand Down
26 changes: 22 additions & 4 deletions src/codex/app-server-processes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ export interface CodexAppServerProcessIo {
getuid?: () => number | undefined;
listSnapshots?: () => ProcessSnapshot[];
isAlive?: (pid: number) => boolean;
/**
* Last-moment fail-closed identity guard. It runs after the helper's
* pid+command/executable match and immediately before the platform-specific
* termination path, so callers can refuse a signal without replacing the
* trusted Windows taskkill implementation.
*/
beforeSignal?: (pid: number, signal: NodeJS.Signals) => void;
kill?: (pid: number, signal: NodeJS.Signals) => void;
/** Windows termination seam: drives the taskkill branch without a real exec. */
execFile?: (file: string, args: readonly string[]) => void;
Expand Down Expand Up @@ -541,6 +548,7 @@ export function readProcessStartMs(pid: number, platform: NodeJS.Platform = proc
export function readProcessStartMsBatch(
pids: readonly number[],
platform: NodeJS.Platform = process.platform,
timeoutMs?: number,
): Map<number, number | null> {
const out = new Map<number, number | null>();
if (pids.length === 0) return out;
Expand All @@ -549,7 +557,7 @@ export function readProcessStartMsBatch(
const stdout = execFileSync("/bin/ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 3_000,
timeout: timeoutMs ?? 3_000,
});
const byPid = new Map<number, number>();
for (const raw of stdout.split(/\r?\n/)) {
Expand All @@ -573,7 +581,12 @@ export function readProcessStartMsBatch(
"-NoProfile", "-NoLogo", "-NonInteractive",
"-Command",
`Get-CimInstance Win32_Process -Filter "${filter}" | ForEach-Object { "$($_.ProcessId)\t$($_.CreationDate.ToUniversalTime().ToString("o"))" }`,
], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 5_000, windowsHide: true });
], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: timeoutMs ?? 5_000,
windowsHide: true,
});
const byPid = new Map<number, number>();
for (const line of stdout.split(/\r?\n/)) {
const tab = line.indexOf("\t");
Expand Down Expand Up @@ -760,8 +773,10 @@ function defaultKillCodexAppServer(
try {
exec(resolveTrustedWindowsTaskkillExe(), ["/PID", String(pid), "/T", "/F"]);
} catch {
// Fall back to the previous behavior rather than reporting a failure the old
// code would not have reported.
// taskkill may have raced with process exit and PID reuse before returning
// an error. Re-run the caller's identity gate immediately before the
// single-process fallback so a replacement process is never terminated.
io.beforeSignal?.(pid, signal);
signalProcess(pid, signal);
}
}
Expand All @@ -773,6 +788,7 @@ export function restartCodexAppServers(
io: CodexAppServerProcessIo = {},
): RestartCodexAppServersResult {
const isAlive = io.isAlive ?? isProcessAlive;
const beforeSignal = io.beforeSignal;
const kill = io.kill ?? ((pid, signal) => { defaultKillCodexAppServer(pid, signal, io); });
const wait = io.waitExit ?? waitForExit;
const now = io.now ?? Date.now;
Expand All @@ -794,9 +810,11 @@ export function restartCodexAppServers(
if (!live || codexAppServerProcessIdentity(live) !== codexAppServerProcessIdentity(proc)) {
// Original target exited (or identity changed); do not signal a replacement.
if (!isAlive(proc.pid)) stopped.push(proc.pid);
else surviving.push(proc.pid);
continue;
}
try {
beforeSignal?.(proc.pid, "SIGTERM");
kill(proc.pid, "SIGTERM");
signaled.push(proc);
} catch (error) {
Expand Down
Loading
Loading