fix(net): harden wire lifecycle races
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
APP,
|
||||
RECONNECT_INTERVAL_MS,
|
||||
RECV_TIMEOUT_MS,
|
||||
SERVER_ROTATE_EVERY,
|
||||
} from '../core/constants.ts';
|
||||
import type { InboundMessage, OutboundEnvelope } from '../core/types/envelope.ts';
|
||||
import { decodeFrame, encodeOutbound } from './envelope-codec.ts';
|
||||
import { decodeFrame } from './envelope-codec.ts';
|
||||
import { HeartbeatWatchdog, realClock, type Clock } from './heartbeat.ts';
|
||||
import { ReconnectPolicy } from './reconnect.ts';
|
||||
import type { Transport } from './transport.ts';
|
||||
@@ -36,6 +37,25 @@ function assertSwitchTarget(value: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
function serializeOutbound(envelope: OutboundEnvelope): string {
|
||||
if (typeof envelope !== 'object' || envelope === null || Array.isArray(envelope)) {
|
||||
throw new Error('invalid outbound envelope');
|
||||
}
|
||||
const value = envelope as unknown as Record<string, unknown>;
|
||||
const owns = (key: string) => Object.prototype.hasOwnProperty.call(value, key);
|
||||
if (
|
||||
!owns('app') || value.app !== APP
|
||||
|| !owns('route') || typeof value.route !== 'string' || value.route.length === 0
|
||||
|| !owns('rpc') || typeof value.rpc !== 'string' || value.rpc.length === 0
|
||||
|| !owns('data') || value.data === undefined
|
||||
) {
|
||||
throw new Error('invalid outbound envelope');
|
||||
}
|
||||
const frame = JSON.stringify(envelope);
|
||||
if (typeof frame !== 'string') throw new Error('invalid outbound envelope');
|
||||
return frame;
|
||||
}
|
||||
|
||||
/**
|
||||
* 只拥有 WebSocket 生命周期、帧编解码、收包看门狗和重连策略。
|
||||
* 登录、踢人、切服 RPC 等业务意图均作为 message 原样交给唯一上层编排。
|
||||
@@ -49,6 +69,7 @@ export class WireClient {
|
||||
|
||||
private transport: Transport | null = null;
|
||||
private reconnectTimer: unknown = null;
|
||||
private reconnectToken = 0;
|
||||
private generation = 0;
|
||||
private started = false;
|
||||
private stopped = false;
|
||||
@@ -84,7 +105,7 @@ export class WireClient {
|
||||
if (!this.open || this.stopped || this.transport === null) {
|
||||
throw new Error('connection is not open');
|
||||
}
|
||||
this.transport.send(encodeOutbound(envelope.route, envelope.rpc, envelope.data));
|
||||
this.transport.send(serializeOutbound(envelope));
|
||||
}
|
||||
|
||||
switchServer(url: string): void {
|
||||
@@ -119,16 +140,30 @@ export class WireClient {
|
||||
}
|
||||
|
||||
private emit(event: WireEvent): void {
|
||||
for (const listener of [...this.listeners]) listener(event);
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
console.error('[WireClient] listener error:', error, event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private connect(server: string): void {
|
||||
if (this.stopped) return;
|
||||
const generation = ++this.generation;
|
||||
const transport = this.transportFactory();
|
||||
this.transport = transport;
|
||||
this.transport = null;
|
||||
this.open = false;
|
||||
|
||||
let transport: Transport;
|
||||
try {
|
||||
transport = this.transportFactory();
|
||||
} catch {
|
||||
this.onConnectionFailure(generation, null, false);
|
||||
return;
|
||||
}
|
||||
this.transport = transport;
|
||||
|
||||
transport.onOpen(() => {
|
||||
if (!this.isCurrent(generation, transport)) return;
|
||||
this.open = true;
|
||||
@@ -140,9 +175,13 @@ export class WireClient {
|
||||
});
|
||||
transport.onClose(() => {
|
||||
if (!this.isCurrent(generation, transport)) return;
|
||||
this.onUnexpectedClose();
|
||||
this.onConnectionFailure(generation, transport, false);
|
||||
});
|
||||
transport.connect(server);
|
||||
try {
|
||||
transport.connect(server);
|
||||
} catch {
|
||||
this.onConnectionFailure(generation, transport, true);
|
||||
}
|
||||
}
|
||||
|
||||
private isCurrent(generation: number, transport: Transport): boolean {
|
||||
@@ -160,43 +199,72 @@ export class WireClient {
|
||||
private onReceiveTimeout(): void {
|
||||
if (!this.open || this.stopped) return;
|
||||
this.emit({ type: 'slow' });
|
||||
if (!this.open || this.stopped) return;
|
||||
this.disconnectAndSchedule(this.policy.onFailure());
|
||||
}
|
||||
|
||||
private onUnexpectedClose(): void {
|
||||
private onConnectionFailure(
|
||||
generation: number,
|
||||
transport: Transport | null,
|
||||
closeTransport: boolean,
|
||||
): void {
|
||||
if (this.stopped || generation !== this.generation || transport !== this.transport) return;
|
||||
this.transport = null;
|
||||
this.open = false;
|
||||
this.generation++;
|
||||
const failureGeneration = ++this.generation;
|
||||
this.watchdog.stop();
|
||||
if (closeTransport && transport) transport.close();
|
||||
this.emit({ type: 'close' });
|
||||
if (this.stopped || failureGeneration !== this.generation) return;
|
||||
this.scheduleReconnect(this.policy.onFailure());
|
||||
}
|
||||
|
||||
private disconnectAndSchedule(server: string): void {
|
||||
this.clearReconnectTimer();
|
||||
const transport = this.transport;
|
||||
const hadTransport = transport !== null;
|
||||
this.transport = null;
|
||||
this.open = false;
|
||||
this.generation++;
|
||||
const disconnectGeneration = ++this.generation;
|
||||
this.watchdog.stop();
|
||||
this.clearReconnectTimer();
|
||||
transport?.close();
|
||||
if (hadTransport) this.emit({ type: 'close' });
|
||||
if (
|
||||
this.stopped
|
||||
|| disconnectGeneration !== this.generation
|
||||
|| this.policy.current() !== server
|
||||
) return;
|
||||
this.scheduleReconnect(server);
|
||||
}
|
||||
|
||||
private scheduleReconnect(server: string): void {
|
||||
this.clearReconnectTimer();
|
||||
if (this.stopped || this.policy.current() !== server) return;
|
||||
const token = this.reconnectToken;
|
||||
this.emit({ type: 'reconnecting', server });
|
||||
this.reconnectTimer = this.clock.setTimeout(() => {
|
||||
if (
|
||||
this.stopped
|
||||
|| token !== this.reconnectToken
|
||||
|| this.policy.current() !== server
|
||||
) return;
|
||||
let handle: unknown = null;
|
||||
handle = this.clock.setTimeout(() => {
|
||||
if (
|
||||
this.stopped
|
||||
|| token !== this.reconnectToken
|
||||
|| this.policy.current() !== server
|
||||
|| this.reconnectTimer !== handle
|
||||
) return;
|
||||
this.reconnectTimer = null;
|
||||
this.reconnectToken++;
|
||||
this.connect(server);
|
||||
}, RECONNECT_INTERVAL_MS);
|
||||
this.reconnectTimer = handle;
|
||||
}
|
||||
|
||||
private clearReconnectTimer(): void {
|
||||
if (this.reconnectTimer === null) return;
|
||||
this.clock.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectToken++;
|
||||
if (this.reconnectTimer !== null) this.clock.clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user