feat(platform): expose lifecycle-safe UI observation

This commit is contained in:
2026-09-05 11:50:12 +08:00
parent 2d7655a5d7
commit bd45503d47
2 changed files with 419 additions and 3 deletions
@@ -119,6 +119,8 @@ export class PlatformRuntime {
private fatalError: Error | null = null;
private wireStopped = false;
private startupGeneration = 0;
private readonly stateObservers = new Set<() => void>();
private observerDispatchError: Error | null = null;
constructor(private readonly options: PlatformRuntimeOptions) {
this.clock = options.clock ?? realClock;
@@ -132,6 +134,54 @@ export class PlatformRuntime {
return this.gate.ready;
}
subscribeState(
listener: (state: PlatformState) => void,
onError: (error: unknown) => void,
): () => void {
if (
this.lifecycle === 'kicked'
|| this.lifecycle === 'fatal'
|| this.lifecycle === 'stopped'
) {
throw new Error(`PlatformRuntime cannot subscribe while ${this.lifecycle}`);
}
let active = true;
const unsubscribeStore = this.store.subscribe((state) => {
if (!active) return;
try {
listener(state);
} catch (error) {
off();
try {
onError(error);
} catch (callbackError) {
const failure = this.failureError(
'UI observer',
error,
[callbackError],
);
if (this.isStopped()) {
this.observerDispatchError = failure;
} else {
this.showFatalOnce(failure);
}
}
}
});
const off = (): void => {
if (!active) return;
active = false;
try {
unsubscribeStore();
} finally {
this.stateObservers.delete(off);
}
};
this.stateObservers.add(off);
return off;
}
async start(): Promise<void> {
if (this.lifecycle !== 'idle') {
throw new Error(`PlatformRuntime cannot start while ${this.lifecycle}`);
@@ -260,6 +310,9 @@ export class PlatformRuntime {
this.wireIsOpen = false;
const errors: unknown[] = [];
for (const off of [...this.stateObservers]) {
this.captureCleanupError(errors, off);
}
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
const unsubscribe = this.unsubscribeWire;
this.unsubscribeWire = null;
@@ -324,6 +377,7 @@ export class PlatformRuntime {
case 'open':
this.wireIsOpen = true;
this.store.setConnectionPhase('connected');
this.throwObserverDispatchError();
this.markReady('socket');
if (this.connectionIntent.type === 'switch') {
const loginEnvelope = this.loginEnvelope;
@@ -341,8 +395,10 @@ export class PlatformRuntime {
this.clearLoginGuard();
}
const stateBeforeDispatch = this.store.getState();
const dispatchResult = this.requireRouter().dispatch(event.message);
this.throwObserverDispatchError();
if (
this.requireRouter().dispatch(event.message) === 'handled-platform'
dispatchResult === 'handled-platform'
&& this.lifecycle === 'running'
&& this.store.getState() !== stateBeforeDispatch
) {
@@ -353,6 +409,8 @@ export class PlatformRuntime {
case 'reconnecting':
this.wireIsOpen = false;
this.store.setConnectionPhase(event.type);
this.throwObserverDispatchError();
if (this.isTerminal()) return;
this.options.scene.showReconnect();
break;
case 'close':
@@ -360,10 +418,11 @@ export class PlatformRuntime {
break;
}
} catch (error) {
const failure = this.withObserverDispatchError(error);
// A lifecycle callback may stop and then throw. Preserve that error without
// replacing the requested terminal stop with a fatal lifecycle transition.
if (this.isStopped()) throw error;
this.showFatalOnce(error);
if (this.isStopped()) throw failure;
this.showFatalOnce(failure);
}
}
@@ -371,6 +430,31 @@ export class PlatformRuntime {
return this.lifecycle === 'stopped';
}
private isTerminal(): boolean {
return this.lifecycle === 'fatal'
|| this.lifecycle === 'kicked'
|| this.lifecycle === 'stopped';
}
private throwObserverDispatchError(): void {
const failure = this.takeObserverDispatchError();
if (failure === null) return;
throw failure;
}
private takeObserverDispatchError(): Error | null {
const failure = this.observerDispatchError;
this.observerDispatchError = null;
return failure;
}
private withObserverDispatchError(error: unknown): unknown {
const observerFailure = this.takeObserverDispatchError();
return observerFailure === null
? error
: this.failureError('UI observer dispatch', observerFailure, [error]);
}
private sendLoginEnvelope(envelope: OutboundEnvelope): void {
const wire = this.requireOpenWire();
const session = this.requireSession();
@@ -441,6 +525,9 @@ export class PlatformRuntime {
this.connectionIntent = { type: 'none' };
this.wireIsOpen = false;
const errors: unknown[] = [];
for (const off of [...this.stateObservers]) {
this.captureCleanupError(errors, off);
}
this.captureCleanupError(errors, () => { this.store.setConnectionPhase('kicked'); });
this.captureCleanupError(errors, () => { this.clearLoginGuard(); });
this.captureCleanupError(errors, () => { this.options.scene.showKicked(data); });
@@ -603,6 +690,9 @@ export class PlatformRuntime {
this.wireIsOpen = false;
const cleanupErrors: unknown[] = [];
for (const off of [...this.stateObservers]) {
this.captureCleanupError(cleanupErrors, off);
}
this.captureCleanupError(cleanupErrors, () => { this.clearLoginGuard(); });
const unsubscribe = this.unsubscribeWire;