feat: add server liveness sweep and rule registry

This commit is contained in:
nav
2026-04-08 22:39:49 +00:00
parent ba007ebd59
commit 075fcb7974
3 changed files with 195 additions and 0 deletions

View File

@@ -8,8 +8,10 @@ import {
YONEXUS_PROTOCOL_VERSION,
buildAuthFailed,
buildAuthSuccess,
buildDisconnectNotice,
buildError,
buildHeartbeatAck,
buildStatusUpdate,
buildHelloAck,
buildPairFailed,
buildPairRequest,
@@ -47,6 +49,7 @@ export interface YonexusServerRuntimeOptions {
store: YonexusServerStore;
transport: ServerTransport;
now?: () => number;
sweepIntervalMs?: number;
}
export interface ServerLifecycleState {
@@ -60,6 +63,8 @@ export class YonexusServerRuntime {
private readonly registry: ServerRegistry;
private readonly pairingService: PairingService;
private readonly notificationService: DiscordNotificationService;
private readonly sweepIntervalMs: number;
private sweepTimer: NodeJS.Timeout | null = null;
private started = false;
constructor(options: YonexusServerRuntimeOptions) {
@@ -69,6 +74,7 @@ export class YonexusServerRuntime {
clients: new Map(),
sessions: new Map()
};
this.sweepIntervalMs = options.sweepIntervalMs ?? 30_000;
this.pairingService = createPairingService({ now: this.now });
this.notificationService = createDiscordNotificationService({
botToken: options.config.notifyBotToken,
@@ -100,6 +106,7 @@ export class YonexusServerRuntime {
}
await this.options.transport.start();
this.startSweepTimer();
this.started = true;
}
@@ -108,6 +115,7 @@ export class YonexusServerRuntime {
return;
}
this.stopSweepTimer();
await this.persist();
this.registry.sessions.clear();
await this.options.transport.stop();
@@ -669,6 +677,97 @@ export class YonexusServerRuntime {
await this.persist();
}
private startSweepTimer(): void {
this.stopSweepTimer();
this.sweepTimer = setInterval(() => {
void this.runLivenessSweep();
}, this.sweepIntervalMs);
}
private stopSweepTimer(): void {
if (!this.sweepTimer) {
return;
}
clearInterval(this.sweepTimer);
this.sweepTimer = null;
}
private async runLivenessSweep(): Promise<void> {
const now = this.now();
let hasChanges = false;
for (const record of this.registry.clients.values()) {
const nextStatus = this.getLivenessStatus(record, now);
if (!nextStatus || nextStatus === record.status) {
continue;
}
record.status = nextStatus;
record.updatedAt = now;
hasChanges = true;
if (nextStatus === "unstable") {
this.options.transport.send(
record.identifier,
encodeBuiltin(
buildStatusUpdate(
{
identifier: record.identifier,
status: "unstable",
reason: "heartbeat_timeout_7m"
},
{ timestamp: now }
)
)
);
continue;
}
if (nextStatus === "offline") {
this.options.transport.send(
record.identifier,
encodeBuiltin(
buildDisconnectNotice(
{
identifier: record.identifier,
reason: "heartbeat_timeout_11m"
},
{ timestamp: now }
)
)
);
this.options.transport.closeConnection(record.identifier, 1001, "Heartbeat timeout");
this.registry.sessions.delete(record.identifier);
}
}
if (hasChanges) {
await this.persist();
}
}
private getLivenessStatus(
record: ClientRecord,
now: number
): "online" | "unstable" | "offline" | null {
const session = this.registry.sessions.get(record.identifier);
if (!session || !session.isAuthenticated || !record.lastHeartbeatAt) {
return null;
}
const silenceSeconds = now - record.lastHeartbeatAt;
if (silenceSeconds >= 11 * 60) {
return "offline";
}
if (silenceSeconds >= 7 * 60) {
return "unstable";
}
return "online";
}
private async triggerRePairRequired(
connection: ClientConnection,
record: ClientRecord,