修复 AGC 错误通知可重放展示

移除 Rust 错误队列 notified 状态与标记命令

将 error-report-updated 收敛为 generation 唤醒并补齐竞态重试

让 WebView 通过快照查询、重载恢复并新增回归测试

同步更新 AGC 错误报告技术方案
This commit is contained in:
2026-09-01 20:30:26 +08:00
parent 7f0d4605eb
commit de671b2e0d
11 changed files with 136 additions and 81 deletions
@@ -1,6 +1,6 @@
use tauri::command;
use super::queue::{ack, mark_notified, report_diagnostic_error, snapshot, ErrorReportEvent};
use super::queue::{ack, report_diagnostic_error, snapshot, ErrorReportEvent};
#[command]
pub fn report_client_error(
@@ -25,12 +25,6 @@ pub fn get_pending_error_reports() -> Vec<ErrorReportEvent> {
snapshot()
}
#[command]
pub fn mark_error_reports_notified(event_ids: Vec<String>) -> Result<(), String> {
mark_notified(&event_ids);
Ok(())
}
#[command]
pub fn ack_error_reports(event_ids: Vec<String>) -> Result<(), String> {
ack(&event_ids);
@@ -3,8 +3,6 @@ mod notifications;
mod queue;
mod sanitize;
pub use commands::{
ack_error_reports, get_pending_error_reports, mark_error_reports_notified, report_client_error,
};
pub use commands::{ack_error_reports, get_pending_error_reports, report_client_error};
pub use notifications::initialize_notifications;
pub use queue::report_diagnostic_error;
@@ -1,12 +1,16 @@
use std::sync::{Mutex, OnceLock};
use std::sync::{
atomic::{AtomicU64, Ordering},
Mutex, OnceLock,
};
use std::time::Duration;
use tauri::{AppHandle, Emitter};
use super::queue::{mark_notified, snapshot, unnotified_count};
use super::queue::{generation, snapshot};
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
static TIMER_ACTIVE: OnceLock<Mutex<bool>> = OnceLock::new();
static LAST_EMITTED_GENERATION: AtomicU64 = AtomicU64::new(0);
pub fn initialize_notifications(app: &AppHandle) {
let _ = APP_HANDLE.set(app.clone());
@@ -30,19 +34,13 @@ pub(crate) fn schedule_notification() {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(Duration::from_secs(5)).await;
let events = snapshot();
let ids: Vec<String> = events
.iter()
.filter(|event| !event.notified)
.map(|event| event.event_id.clone())
.collect();
if !ids.is_empty() {
mark_notified(&ids);
let current_generation = generation();
LAST_EMITTED_GENERATION.store(current_generation, Ordering::Release);
if !events.is_empty() {
let _ = handle.emit(
"error-report-updated",
serde_json::json!({
"generation": events.len(),
"newCount": ids.len(),
"eventIds": ids,
"generation": current_generation,
}),
);
}
@@ -51,7 +49,7 @@ pub(crate) fn schedule_notification() {
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = false;
}
if unnotified_count() > 0 {
if generation() > LAST_EMITTED_GENERATION.load(Ordering::Acquire) {
schedule_notification();
}
});
@@ -25,7 +25,6 @@ pub struct ErrorReportEvent {
pub occurred_at: String,
pub last_occurred_at: String,
pub count: u32,
pub notified: bool,
}
#[derive(Default)]
@@ -113,7 +112,6 @@ pub fn report_diagnostic_error(
occurred_at: timestamp.clone(),
last_occurred_at: timestamp,
count: 1,
notified: false,
};
state
.events
@@ -138,17 +136,6 @@ pub(crate) fn snapshot() -> Vec<ErrorReportEvent> {
.collect()
}
pub(crate) fn mark_notified(event_ids: &[String]) {
let mut state = queue()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
for event in state.events.values_mut() {
if event_ids.iter().any(|id| id == &event.event_id) {
event.notified = true;
}
}
}
pub(crate) fn ack(event_ids: &[String]) {
let mut state = queue()
.lock()
@@ -164,15 +151,11 @@ pub(crate) fn ack(event_ids: &[String]) {
}
}
pub(crate) fn unnotified_count() -> usize {
pub(crate) fn generation() -> u64 {
let state = queue()
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
state
.events
.values()
.filter(|event| !event.notified)
.count()
state.sequence
}
#[cfg(test)]
@@ -2362,7 +2362,6 @@ fn main() {
read_diagnostic_logs,
report_client_error,
get_pending_error_reports,
mark_error_reports_notified,
ack_error_reports
])
.build(tauri_context);
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import {
type ClientErrorEvent,
@@ -13,25 +13,54 @@ export function ErrorReportNotice() {
>([]);
const [reportEvents, setReportEvents] = useState<ClientErrorEvent[]>([]);
const loadEvents = useCallback(async (eventIds?: string[]) => {
try {
const events = await getPendingClientErrorEvents();
if (eventIds?.length) {
const ids = new Set(eventIds);
setNotificationEvents(events.filter((event) => ids.has(event.eventId)));
} else {
setNotificationEvents(
events.filter((event) => event.notified === false),
);
}
} catch {
// 通知失败不应打断 AGC 主流程。
const requestId = useRef(0);
const retryTimer = useRef<number | null>(null);
const loadEvents = useCallback(() => {
if (retryTimer.current !== null) {
window.clearTimeout(retryTimer.current);
retryTimer.current = null;
}
const currentRequest = ++requestId.current;
const retryDelays = [500, 1_000, 2_000];
const run = async (attempt: number): Promise<void> => {
try {
const events = await getPendingClientErrorEvents();
if (currentRequest !== requestId.current) return;
setNotificationEvents(events);
} catch {
if (
currentRequest !== requestId.current ||
attempt >= retryDelays.length
)
return;
retryTimer.current = window.setTimeout(() => {
retryTimer.current = null;
void run(attempt + 1);
}, retryDelays[attempt]);
}
};
void run(0);
}, []);
useEffect(() => {
void loadEvents();
return subscribeClientErrorEvents(loadEvents);
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible') loadEvents();
};
const handleFocus = () => loadEvents();
loadEvents();
document.addEventListener('visibilitychange', handleVisibilityChange);
window.addEventListener('focus', handleFocus);
const unsubscribe = subscribeClientErrorEvents(loadEvents);
return () => {
unsubscribe();
requestId.current += 1;
if (retryTimer.current !== null) {
window.clearTimeout(retryTimer.current);
retryTimer.current = null;
}
document.removeEventListener('visibilitychange', handleVisibilityChange);
window.removeEventListener('focus', handleFocus);
};
}, [loadEvents]);
const openReport = () => {
@@ -17,7 +17,6 @@ export type ClientErrorEvent = {
stack?: string;
occurredAt: string;
count: number;
notified?: boolean;
};
export type DiagnosticLogFile = { name: string; content: string };
@@ -131,13 +130,11 @@ export function getPendingClientErrorEvents() {
return getPendingErrorReports();
}
export function subscribeClientErrorEvents(
listener: (eventIds: string[]) => void,
) {
export function subscribeClientErrorEvents(listener: () => void) {
let disposed = false;
let unlisten: (() => void) | undefined;
void subscribeErrorReportUpdates((update) => {
if (!disposed) listener(update.eventIds ?? []);
void subscribeErrorReportUpdates(() => {
if (!disposed) listener();
}).then((stop) => {
if (disposed) stop();
else unlisten = stop;
@@ -182,9 +179,7 @@ export async function submitErrorReportBatch(
schemaVersion: 1,
submissionId:
globalThis.crypto?.randomUUID?.() ?? `submission-${Date.now()}`,
events: payload.events.map(
({ notified: _notified, ...event }) => event,
),
events: payload.events,
userDescription: payload.userDescription?.trim() || null,
logs: payload.logs,
}),
@@ -5,13 +5,10 @@ import type { ClientErrorEvent } from './errorReporting';
type RustErrorReportEvent = ClientErrorEvent & {
lastOccurredAt?: string;
notified?: boolean;
};
export type ErrorReportUpdate = {
generation: number;
newCount: number;
eventIds?: string[];
};
export function reportClientError(input: {
@@ -27,18 +24,15 @@ export function getPendingErrorReports() {
return invoke<RustErrorReportEvent[]>('get_pending_error_reports');
}
export function markErrorReportsNotified(eventIds: string[]) {
return invoke<void>('mark_error_reports_notified', { eventIds });
}
export function ackErrorReports(eventIds: string[]) {
return invoke<void>('ack_error_reports', { eventIds });
}
export function subscribeErrorReportUpdates(
listener: (update: ErrorReportUpdate) => void,
listener: () => void,
): Promise<UnlistenFn> {
return listen<ErrorReportUpdate>('error-report-updated', (event) => {
listener(event.payload);
void event.payload;
listener();
});
}
@@ -0,0 +1,67 @@
/** @vitest-environment jsdom */
import { cleanup, render, screen, waitFor } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
const mockState = vi.hoisted(() => ({
getPending: vi.fn(),
listeners: [] as Array<() => void>,
}));
vi.mock('../src/services/errorReporting', () => ({
getPendingClientErrorEvents: mockState.getPending,
subscribeClientErrorEvents: (listener: () => void) => {
mockState.listeners.push(listener);
return () => {
mockState.listeners = mockState.listeners.filter(
(item) => item !== listener,
);
};
},
}));
vi.mock('../src/components/error-report/ErrorReportDialog', () => ({
ErrorReportDialog: () => null,
}));
import { ErrorReportNotice } from '../src/components/error-report/ErrorReportNotice';
const event = {
eventId: 'rust-error-1',
fingerprint: 'fingerprint-1',
source: 'test',
message: '错过 emit 的错误',
occurredAt: '1',
count: 1,
};
describe('ErrorReportNotice', () => {
afterEach(() => {
cleanup();
mockState.getPending.mockReset();
mockState.listeners = [];
});
it('挂载时查询快照,即使错过唤醒事件也能显示通知', async () => {
mockState.getPending.mockResolvedValue([event]);
render(<ErrorReportNotice />);
expect(await screen.findByText('发现 1 个问题')).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(1);
});
it('收到唤醒后重新查询权威快照', async () => {
mockState.getPending
.mockResolvedValueOnce([])
.mockResolvedValueOnce([event]);
render(<ErrorReportNotice />);
await waitFor(() => expect(mockState.getPending).toHaveBeenCalledTimes(1));
mockState.listeners[0]?.();
expect(await screen.findByText('发现 1 个问题')).toBeTruthy();
expect(mockState.getPending).toHaveBeenCalledTimes(2);
});
});
@@ -10,7 +10,6 @@ type FakeErrorEvent = {
stack?: string;
occurredAt: string;
count: number;
notified: boolean;
};
const fakeRustQueue = vi.hoisted(() => {
@@ -49,7 +48,6 @@ vi.mock('@tauri-apps/api/core', () => ({
stack: typeof args?.stack === 'string' ? args.stack : undefined,
occurredAt: '1',
count: 1,
notified: false,
};
if (fakeRustQueue.events.size >= 100) {
const oldest = fakeRustQueue.events.keys().next().value;
@@ -12,8 +12,8 @@ AI Game Creator Shell 采用 IDEA 风格的当前进程错误报告:错误事
- 客户端 API 自动采集只覆盖网络错误、408 和 5xx;预期的 4xx 登录/鉴权失败不进入错误报告池。
- Rust 侧通过 `app_log!` 将普通文本日志同时输出到 stderr 和 AppData `diagnostics/application.log`,超出 256 KiB 滚动到 `application.previous.log`WebView 的 console 输出通过 `append_application_log` 镜像到同一 raw log,并在客户端桥接处再次脱敏;`read_diagnostic_logs` 只读取应用级日志。
- 报告面板只由自动诊断通知中的“查看并报告”打开,不提供聊天命令、崩溃页按钮或其他手动入口;默认选中最新事件,其他事件可勾选。允许填写最多 2,000 字中文描述并取消日志附件;本版本不支持截图或任意文件附件。
- Rust 是结构化错误队列的唯一真相源:`src-tauri/src/error_report/` 负责脱敏、调用点指纹、eventId、计数、100 条上限、5 秒聚合和 notified/ack 生命周期;WebView 仅通过 Tauri bridge 上报、读取快照并保存短暂 React 展示状态,不维护第二份事件 Map。Rust emit 只携带 generation、newCount 和 eventIds,不携带错误正文。
- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只显示一条非阻塞通知;通知支持“查看并报告”和“忽略”,同一 fingerprint 在本次运行中只提醒一次。通知不直接打开阻塞式报告面板。忽略只标记 notified,不删除事件;提交成功后由 bridge ack/delete 选中事件。
- Rust 是结构化错误队列的唯一真相源:`src-tauri/src/error_report/` 负责脱敏、调用点指纹、eventId、计数、100 条上限、5 秒聚合和 ack 生命周期;WebView 仅通过 Tauri bridge 上报、读取快照并保存短暂 React 展示状态,不维护第二份事件 Map。Rust emit 只作为无状态唤醒,携带单调递增的 generation,不携带错误正文或事件 ID
- 错误事件先在当前进程内存池按 fingerprint 合并,经过 5 秒聚合后只发出一次非阻塞存在性唤醒;WebView 挂载、收到唤醒、重新获得焦点或恢复可见时都查询完整未 ack 快照,并在桥接暂时失败时做有限退避重试。通知支持“查看并报告”和“忽略”,同一 fingerprint 仅在新增时唤醒一次。通知不直接打开阻塞式报告面板。忽略只关闭当前 UI,不删除事件;提交成功后由 bridge ack/delete 选中事件。
- 上传失败只在当前进程显示失败并允许用户再次提交,不跨重启恢复事件池,不后台自动重试。
## HTTP 与存储