7092689b36
## 变更内容 在 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>
529 lines
16 KiB
TypeScript
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();
|
|
}
|