Files
Genarrative/apps/ai-game-creator-shell/src/services/platformSession.ts
T
kdletters 7092689b36
Project CI / Repository checks (push) Successful in 3m11s
Project CI / Frontend tests (push) Successful in 4m4s
Project CI / Backend tests (push) Successful in 6m14s
Project CI / Native shell tests (push) Failing after 14m20s
收口 AGC 生命周期超时隔离与建项恢复
## 变更内容

在 master(0f829cd25)上用故障注入复现出四类"每走一步都会卡住"的生命周期残留,本轮按"超时后真正隔离并核对底层操作"收口,不再新增 operation 字段:

- 本地会话写入队列从"无限等上一次完成"改为带解围期限的闸门(60s)。Rust 侧 install/clear 本来就按 generation 单调拒绝更旧写入,所以渲染层只需保证新 generation 不被卡死的调用永久挡住;
- generation floor 读取不再把一次瞬时失败缓存成永久失败(原先 `??=` 缓存了已 reject 的 promise,导致同一渲染进程内后续登录/退出全部失败);
- 登录 UI 的 45 秒围栏只放弃等待、不放弃结果:本地运行时确实装好会话时界面跟随进入工作区,且迟到结果不会覆盖更新的登录尝试;
- 首页自动建项从 `deadlineMs: null` 改为 10 分钟兜底期限:到点解围并提示(底层创建继续在后台跑,迟到成功照常进项目),失败时保留 `scope.projectPath`、登记最近项目并新增「打开已创建的工作区」入口;底层创建未返回期间只挡"再建一个",不挡打开已有项目;
- 同步 `project.bootstrap` 权限文案断言、生命周期技术方案状态与 shared pitfalls。

## 验证

- `npm --prefix apps/ai-game-creator-shell run typecheck`(含 skill-pack、check-config)
- `vitest run apps/ai-game-creator-shell/tests/appSurface.test.ts`(428/428,含新增 5 个故障注入回归;`project.bootstrap` 文案断言在此前 master 上确定性失败)
- 定向 `clientHttp`、`clientApi`、`clientOperation`、`recentProjectsModel`、`clientRuntimeErrorBoundary`、`sessionPreview`、`start-dev-stack`、`dev-port`、`start-tauri-dev`(全部通过)
- `npm run check:doc-index`、`npm run check:encoding`、`git diff --check`

原生 Runner/IPC 与真实 Provider 下的同一批时序未执行,本轮结论来自 deterministic surface 与 mock 故障注入。

Co-authored-by: DotCraft <273930855+dotcraft-ai@users.noreply.github.com>
2026-09-14 14:49:45 +08:00

529 lines
16 KiB
TypeScript

import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri';
import {
getCurrentClientAuthUser,
getStoredAuthAccessToken,
refreshClientAuthAccessToken,
} from './clientAuth';
import { getClientServerBaseUrl } from './clientHttp';
import {
type ClientOperation,
createClientOperation,
transitionClientOperation,
} from './clientOperation';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
type CommittedPlatformSession = {
user: AuthUser;
accessToken: string;
apiBaseUrl: string;
generation: number;
};
export type PlatformSessionRefreshResult =
| { status: 'refreshed'; user: AuthUser; generation: number }
| { status: 'stale' }
| { status: 'failed'; error: unknown };
type PlatformSessionRefreshListener = (
result: PlatformSessionRefreshResult,
) => void;
type PlatformSessionGenerationListener = (generation: number) => void;
let platformAuthGeneration = 0;
let platformNativeGeneration = 0;
let platformNativeGenerationFloorPromise: Promise<number> | null = null;
let committedPlatformSession: CommittedPlatformSession | null = null;
let desiredPlatformSession: CommittedPlatformSession | null = null;
let platformSessionRefreshPromise: Promise<PlatformSessionRefreshResult> | null =
null;
let platformSessionOperation: ClientOperation<
'auth-transition',
{ userId: string | null }
> | null = null;
/**
* 本地会话写入的排队闸门。
*
* Rust 侧 `install_platform_session_in` / `clear_platform_session_in` 按 generation 单调校验:
* 更旧的 install 与更旧的 clear 都会被拒绝。因此渲染层必须保证的只有"新 generation 不被旧调用
* 无限挡住",而不需要让队列永远等下去。这里给闸门加一个上限:底层 invoke 迟迟不返回(例如
* Runner 卡住、IPC 不回调)时,后续登录/退出仍能继续推进,迟到的旧写入由 Rust 按代次拒绝。
*/
const PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS = 60_000;
let platformSessionNativeMutationGate: Promise<void> = Promise.resolve();
const platformSessionRefreshListeners =
new Set<PlatformSessionRefreshListener>();
const platformSessionGenerationListeners =
new Set<PlatformSessionGenerationListener>();
export function getPlatformSessionOperation() {
return platformSessionOperation;
}
function restoreCommittedAccessToken() {
if (committedPlatformSession?.accessToken) {
window.localStorage.setItem(
ACCESS_TOKEN_STORAGE_KEY,
committedPlatformSession.accessToken,
);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
function restoreCurrentRendererAccessToken() {
if (!desiredPlatformSession) {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
return;
}
restoreCommittedAccessToken();
}
function notifyPlatformSessionRefresh(result: PlatformSessionRefreshResult) {
for (const listener of platformSessionRefreshListeners) {
listener(result);
}
}
function notifyPlatformSessionGeneration() {
for (const listener of platformSessionGenerationListeners) {
listener(platformAuthGeneration);
}
}
async function installNativePlatformSession(
session: CommittedPlatformSession,
generation: number,
) {
const invoke = resolveTauriInvoke();
if (!invoke) return;
await invoke('install_platform_account_session', {
userId: session.user.id,
accessToken: session.accessToken,
apiBaseUrl: session.apiBaseUrl,
generation,
});
}
async function clearNativePlatformSession(generation: number) {
const invoke = resolveTauriInvoke();
if (!invoke) return;
await invoke('clear_platform_account_session', { generation });
}
function waitForNativeMutationAbandonment(
settled: Promise<void>,
timeoutMs: number,
) {
let timerId: number | undefined;
const abandoned = new Promise<void>((resolve) => {
timerId = window.setTimeout(resolve, timeoutMs);
});
return Promise.race([settled, abandoned]).finally(() => {
if (timerId !== undefined) {
window.clearTimeout(timerId);
}
});
}
function enqueuePlatformSessionNativeMutation<T>(
operation: () => Promise<T>,
abandonmentMs = PLATFORM_SESSION_NATIVE_MUTATION_ABANDONMENT_MS,
): Promise<T> {
const pending = platformSessionNativeMutationGate
.catch(() => undefined)
.then(operation);
platformSessionNativeMutationGate = waitForNativeMutationAbandonment(
pending.then(
() => undefined,
() => undefined,
),
abandonmentMs,
);
return pending;
}
async function readNativePlatformSessionGenerationFloor() {
const invoke = resolveTauriInvoke();
if (!invoke) return 0;
const floor = await invoke<number | null>(
'read_platform_account_session_generation',
);
// Browser/unit-test adapters commonly expose a no-op invoke that returns null
// for native-only read commands. They have no surviving Rust generation floor.
if (floor === null) return 0;
if (!Number.isSafeInteger(floor) || floor < 0) {
throw new Error('本地运行时登录态 generation 无效,请重启客户端后重试');
}
return floor;
}
async function reserveNativePlatformSessionGeneration() {
platformNativeGenerationFloorPromise ??=
readNativePlatformSessionGenerationFloor();
let nativeGenerationFloor: number;
try {
nativeGenerationFloor = await platformNativeGenerationFloorPromise;
} catch (error) {
// 一次瞬时失败(IPC 抖动、Runner 刚重启)不能被缓存成"永久失败":否则本次渲染进程
// 内的后续登录/退出都会在同一个已 reject 的 promise 上失败,用户重试也不会重新读取。
platformNativeGenerationFloorPromise = null;
throw error;
}
platformNativeGeneration = Math.max(
platformNativeGeneration + 1,
platformAuthGeneration,
nativeGenerationFloor + 1,
);
return platformNativeGeneration;
}
async function reconcileNativePlatformSessionToCurrentAuthority() {
for (;;) {
const authoritativeGeneration = platformAuthGeneration;
const authoritativeSession = desiredPlatformSession
? { ...desiredPlatformSession }
: null;
const reconciliationGeneration =
await reserveNativePlatformSessionGeneration();
restoreCurrentRendererAccessToken();
try {
if (authoritativeSession) {
await installNativePlatformSession(
authoritativeSession,
reconciliationGeneration,
);
} else {
await clearNativePlatformSession(reconciliationGeneration);
}
} catch (error) {
if (platformAuthGeneration === authoritativeGeneration) {
committedPlatformSession = null;
desiredPlatformSession = null;
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
}
throw error;
}
if (platformAuthGeneration !== authoritativeGeneration) {
continue;
}
committedPlatformSession = authoritativeSession
? {
...authoritativeSession,
generation: authoritativeGeneration,
}
: null;
desiredPlatformSession = committedPlatformSession
? { ...committedPlatformSession }
: null;
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
return;
}
}
function resolvePlatformApiBaseUrl() {
return getClientServerBaseUrl();
}
async function commitPlatformSession(
user: AuthUser,
accessToken: string,
apiBaseUrl: string,
expectedGeneration: number,
): Promise<CommittedPlatformSession | null> {
if (platformAuthGeneration !== expectedGeneration) {
restoreCurrentRendererAccessToken();
return null;
}
const candidate: CommittedPlatformSession = {
user,
accessToken,
apiBaseUrl,
generation: expectedGeneration + 1,
};
// Reserve a new generation so older refresh/login work becomes stale, but keep the previous
// committed identity authoritative until Rust and Runner have accepted the candidate.
platformAuthGeneration = candidate.generation;
desiredPlatformSession = { ...candidate };
notifyPlatformSessionGeneration();
restoreCommittedAccessToken();
const nativeGeneration = await reserveNativePlatformSessionGeneration();
try {
await installNativePlatformSession(candidate, nativeGeneration);
} catch (error) {
if (platformAuthGeneration === candidate.generation) {
desiredPlatformSession = committedPlatformSession
? { ...committedPlatformSession }
: null;
}
await reconcileNativePlatformSessionToCurrentAuthority();
if (platformAuthGeneration !== candidate.generation) return null;
throw error;
}
if (platformAuthGeneration !== candidate.generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
return null;
}
committedPlatformSession = candidate;
desiredPlatformSession = { ...candidate };
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
return candidate;
}
export function currentPlatformSessionGeneration() {
return platformAuthGeneration;
}
export function currentPlatformSessionApiBaseUrl() {
return committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl();
}
export function beginPlatformSessionTransition() {
platformAuthGeneration += 1;
desiredPlatformSession = committedPlatformSession
? { ...committedPlatformSession }
: null;
notifyPlatformSessionGeneration();
return platformAuthGeneration;
}
export function beginPlatformSessionClearTransition() {
platformAuthGeneration += 1;
desiredPlatformSession = null;
notifyPlatformSessionGeneration();
return platformAuthGeneration;
}
export async function commitAuthenticatedPlatformSession(
user: AuthUser,
expectedGeneration: number,
apiBaseUrl = resolvePlatformApiBaseUrl(),
) {
const accessToken = getStoredAuthAccessToken();
if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录');
}
const operation = createClientOperation(
'auth-transition',
{ userId: user.id },
{
scope: { apiBaseUrl, sessionGeneration: expectedGeneration },
deadlineMs: 45_000,
cancellable: false,
},
);
platformSessionOperation = transitionClientOperation(operation, 'runner');
return enqueuePlatformSessionNativeMutation(async () => {
try {
const session = await commitPlatformSession(
user,
accessToken,
apiBaseUrl,
expectedGeneration,
);
if (!session) {
platformSessionOperation = transitionClientOperation(
operation,
'unknown',
);
return null;
}
platformSessionOperation = transitionClientOperation(
operation,
'success',
{ scope: { sessionGeneration: session.generation } },
);
return session.generation;
} catch (error) {
platformSessionOperation = transitionClientOperation(
operation,
'retryable-failure',
);
throw error;
}
});
}
export async function refreshPlatformSessionForGeneration(
expectedGeneration: number,
apiBaseUrl = resolvePlatformApiBaseUrl(),
) {
const token = await refreshClientAuthAccessToken(apiBaseUrl);
if (platformAuthGeneration !== expectedGeneration) {
restoreCurrentRendererAccessToken();
return null;
}
return token;
}
export function requestPlatformSessionRefresh(expectedUserId?: string) {
if (platformSessionRefreshPromise) return platformSessionRefreshPromise;
const expectedGeneration = platformAuthGeneration;
const apiBaseUrl =
committedPlatformSession?.apiBaseUrl || resolvePlatformApiBaseUrl();
const expectedSessionUserId =
expectedUserId?.trim() || committedPlatformSession?.user.id || '';
platformSessionRefreshPromise =
(async (): Promise<PlatformSessionRefreshResult> => {
try {
const refreshed = await refreshPlatformSessionForGeneration(
expectedGeneration,
apiBaseUrl,
);
if (!refreshed) return { status: 'stale' };
const user = await getCurrentClientAuthUser(apiBaseUrl);
if (
platformAuthGeneration !== expectedGeneration ||
!user ||
(expectedSessionUserId && user.id !== expectedSessionUserId)
) {
restoreCurrentRendererAccessToken();
return { status: 'stale' };
}
const committedGeneration = await commitAuthenticatedPlatformSession(
user,
expectedGeneration,
apiBaseUrl,
);
if (committedGeneration === null) return { status: 'stale' };
return {
status: 'refreshed',
user,
generation: committedGeneration,
};
} catch (error) {
if (platformAuthGeneration !== expectedGeneration) {
restoreCurrentRendererAccessToken();
return { status: 'stale' };
}
let failure = error;
const currentOwnerUserId = committedPlatformSession?.user.id || '';
if (
currentOwnerUserId &&
currentOwnerUserId !== expectedSessionUserId
) {
restoreCurrentRendererAccessToken();
return { status: 'stale' };
}
if (
!currentOwnerUserId ||
currentOwnerUserId === expectedSessionUserId
) {
const clearGeneration = beginPlatformSessionClearTransition();
try {
await clearCommittedPlatformSession(clearGeneration);
} catch (clearError) {
failure = clearError;
}
}
return { status: 'failed', error: failure };
}
})().then((result) => {
notifyPlatformSessionRefresh(result);
return result;
});
platformSessionRefreshPromise.finally(() => {
platformSessionRefreshPromise = null;
});
return platformSessionRefreshPromise;
}
export function subscribePlatformSessionRefresh(
listener: PlatformSessionRefreshListener,
) {
platformSessionRefreshListeners.add(listener);
return () => {
platformSessionRefreshListeners.delete(listener);
};
}
export function subscribePlatformSessionGeneration(
listener: PlatformSessionGenerationListener,
) {
platformSessionGenerationListeners.add(listener);
return () => {
platformSessionGenerationListeners.delete(listener);
};
}
export async function clearCommittedPlatformSession(generation: number) {
const operation = createClientOperation(
'auth-transition',
{ userId: null },
{
scope: { sessionGeneration: generation },
deadlineMs: 45_000,
cancellable: false,
},
);
platformSessionOperation = transitionClientOperation(operation, 'runner');
return enqueuePlatformSessionNativeMutation(async () => {
try {
if (platformAuthGeneration !== generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
platformSessionOperation = transitionClientOperation(
operation,
'unknown',
);
return;
}
desiredPlatformSession = null;
const nativeGeneration = await reserveNativePlatformSessionGeneration();
try {
await clearNativePlatformSession(nativeGeneration);
} catch {
if (platformAuthGeneration === generation) {
desiredPlatformSession = null;
}
await reconcileNativePlatformSessionToCurrentAuthority();
platformSessionOperation = transitionClientOperation(
operation,
'retryable-failure',
);
return;
}
if (platformAuthGeneration !== generation) {
await reconcileNativePlatformSessionToCurrentAuthority();
platformSessionOperation = transitionClientOperation(
operation,
'unknown',
);
return;
}
committedPlatformSession = null;
desiredPlatformSession = null;
restoreCommittedAccessToken();
notifyPlatformSessionGeneration();
platformSessionOperation = transitionClientOperation(
operation,
'success',
);
} catch (error) {
platformSessionOperation = transitionClientOperation(
operation,
'retryable-failure',
);
throw error;
}
});
}
export function resetPlatformSessionStateForTests() {
platformAuthGeneration = 0;
platformNativeGeneration = 0;
platformNativeGenerationFloorPromise = null;
committedPlatformSession = null;
desiredPlatformSession = null;
platformSessionRefreshPromise = null;
platformSessionOperation = null;
platformSessionNativeMutationGate = Promise.resolve();
platformSessionRefreshListeners.clear();
platformSessionGenerationListeners.clear();
}