Files
dsh_shenxian/src/supervisor/crash-policy.ts
T

113 lines
4.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Crash-restart policy for a resident main DSH (档案 20 · 方案 A).
*
* Pure decision logic, deliberately separated from `LocalSpawner` so it can be
* unit-tested without spawning anything: given the recent restart timestamps
* and the current consecutive-attempt count, decide either "restart after
* `delayMs` (exponential backoff capped at `maxDelayMs`)" or "circuit open"
* (too many restarts inside the window — stop auto-restarting so a
* persistently failing instance cannot spin spawns forever).
*
* @module dshs/supervisor/crash-policy
*/
/** Tunables (see {@link ServerConfig} — all env-overridable). */
export interface CrashPolicyConfig {
/** Delay for the first restart (consecutive attempt 0). */
baseDelayMs: number
/** Upper bound for the exponential backoff. */
maxDelayMs: number
/** Rolling window over which restarts are counted. */
windowMs: number
/** Restarts allowed inside the window before the circuit opens. */
maxRestartsInWindow: number
/** A main that stays up this long is considered recovered: streak resets. */
stableResetMs: number
}
/** Outcome of one crash decision. */
export type CrashDecision =
| { action: 'restart'; delayMs: number; attempt: number; windowRestarts: number }
| { action: 'circuit-open'; windowRestarts: number }
/** Drop timestamps that fell out of the rolling window. */
export function pruneHistory(history: readonly number[], now: number, windowMs: number): number[] {
return history.filter((at) => now - at < windowMs)
}
/** Exponential backoff for a given consecutive attempt (0-based), capped. */
export function backoffDelayMs(consecutive: number, cfg: Pick<CrashPolicyConfig, 'baseDelayMs' | 'maxDelayMs'>): number {
const safe = Math.max(0, Math.min(consecutive, 20)) // guard against overflow
return Math.min(cfg.baseDelayMs * 2 ** safe, cfg.maxDelayMs)
}
/**
* Decide what to do after a main crashed.
* @param history - restart timestamps (ms epoch) already recorded for this user.
* @param consecutive - consecutive attempts since the last stable run.
* @param now - current time (ms epoch).
* @param cfg - policy tunables.
*/
export function decideCrashAction(
history: readonly number[],
consecutive: number,
now: number,
cfg: CrashPolicyConfig,
): CrashDecision {
const inWindow = pruneHistory(history, now, cfg.windowMs)
if (inWindow.length >= cfg.maxRestartsInWindow) {
return { action: 'circuit-open', windowRestarts: inWindow.length }
}
return {
action: 'restart',
delayMs: backoffDelayMs(consecutive, cfg),
attempt: consecutive + 1,
windowRestarts: inWindow.length + 1,
}
}
/* ─────────────────────────────────────────────────────────────────────────────
* 档案 78:熔断冷却(防「崩溃循环可无限重来」)
*
* 原始缺陷:`circuit-open` 分支里 `mains.delete` + `resetCrashState()` 会把窗口
* 历史一并清空,于是**下一次 enter/launch 又是满额预算** —— 只要有人(用户 F5、
* 注入脚本自愈、脚本直铺)不断重试,崩溃循环就能无限重复,且只留一行 stderr。
*
* 修法:每次熔断记一个**跨轮存活**的冷却窗(`opens` 递增 → 冷却指数加长),
* 冷却期内拒绝隐式/自动启动;冷却过后只给**一次**干净预算。纯函数,便于单测。
* ──────────────────────────────────────────────────────────────────────────── */
/** 熔断状态:`opens` = 该用户累计熔断次数(跨轮不清零)。 */
export interface BreakerState {
openedAt: number
opens: number
}
/** 冷却策略(见 ServerConfig,均可 env 覆盖)。 */
export interface BreakerPolicy {
baseCooldownMs: number
maxCooldownMs: number
}
/** 第 `opens` 次熔断的冷却时长:`base × 2^(opens-1)`,上限 `maxCooldownMs`。 */
export function breakerCooldownMs(opens: number, cfg: BreakerPolicy): number {
const safe = Math.max(1, Math.min(opens, 20))
return Math.min(cfg.baseCooldownMs * 2 ** (safe - 1), cfg.maxCooldownMs)
}
/** 冷却是否仍在生效(`now < openedAt + cooldown`)。 */
export function breakerActive(b: BreakerState | undefined, now: number, cfg: BreakerPolicy): boolean {
if (b === undefined) return false
return now < b.openedAt + breakerCooldownMs(b.opens, cfg)
}
/** 冷却结束时刻(无熔断时返回 0)。 */
export function breakerUntil(b: BreakerState | undefined, cfg: BreakerPolicy): number {
return b === undefined ? 0 : b.openedAt + breakerCooldownMs(b.opens, cfg)
}
/** 再次熔断:`opens` 递增,`openedAt` 取本次时刻。 */
export function openBreaker(prev: BreakerState | undefined, now: number): BreakerState {
return { openedAt: now, opens: (prev?.opens ?? 0) + 1 }
}