修复 AGC 错误通知可重放展示
移除 Rust 错误队列 notified 状态与标记命令 将 error-report-updated 收敛为 generation 唤醒并补齐竞态重试 让 WebView 通过快照查询、重载恢复并新增回归测试 同步更新 AGC 错误报告技术方案
This commit is contained in:
@@ -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 与存储
|
||||
|
||||
Reference in New Issue
Block a user