From a081a97678838d23416fd8d7e202bd3a37d58923 Mon Sep 17 00:00:00 2001 From: iven Date: Thu, 9 Apr 2026 19:51:34 +0800 Subject: [PATCH] =?UTF-8?q?fix(relay):=20audit=20fixes=20=E2=80=94=20abort?= =?UTF-8?q?=20signal,=20model=20selector=20guard,=20SSE=20CRLF,=20SQL=20fo?= =?UTF-8?q?rmat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses findings from deep code audit: H-1: Pass abortController.signal to saasClient.chatCompletion() so user-cancelled streams actually abort the HTTP connection (was only stopping the read loop, leaving server-side SSE connection open). H-2: ModelSelector now shows only when (!isTauriRuntime() || isLoggedIn). Prevents decorative model list in Tauri local kernel mode where model selection has no effect (violates CLAUDE.md §5.2). M-1: Normalize CRLF to LF before SSE event boundary parsing (\n\n). Prevents buffer overflow when behind nginx/CDN with CRLF line endings. M-2: SQL window_minute comparison uses to_char(NOW()-interval, format) instead of (NOW()-interval)::TEXT, matching the stored format exactly. M-3: sort_candidates_by_quota uses same sliding 60s window as select_best_key. LOW: Fix misleading invalidate_cache doc comment. --- crates/zclaw-saas/src/relay/key_pool.rs | 6 +++--- crates/zclaw-saas/src/relay/service.rs | 2 +- desktop/src/components/ChatArea.tsx | 3 ++- desktop/src/lib/saas-relay-client.ts | 5 ++++- 4 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/zclaw-saas/src/relay/key_pool.rs b/crates/zclaw-saas/src/relay/key_pool.rs index b679fca..7942f69 100644 --- a/crates/zclaw-saas/src/relay/key_pool.rs +++ b/crates/zclaw-saas/src/relay/key_pool.rs @@ -28,7 +28,7 @@ fn get_cache() -> &'static DashMap { KEY_SELECTION_CACHE.get_or_init(DashMap::new) } -/// Invalidate cached selection for a provider (called on usage record and 429 marking) +/// Invalidate cached selection for a provider (called on 429 marking) fn invalidate_cache(provider_id: &str) { let cache = get_cache(); cache.remove(provider_id); @@ -86,7 +86,7 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) COALESCE(SUM(uw.token_count), 0) FROM provider_keys pk LEFT JOIN key_usage_window uw ON pk.id = uw.key_id - AND uw.window_minute >= (NOW() - INTERVAL '1 minute')::TEXT + AND uw.window_minute >= to_char(NOW() - INTERVAL '1 minute', 'YYYY-MM-DDTHH24:MI') WHERE pk.provider_id = $1 AND pk.is_active = TRUE AND (pk.cooldown_until IS NULL OR pk.cooldown_until::timestamptz <= $2) GROUP BY pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm @@ -200,7 +200,7 @@ pub async fn record_key_usage( // 3. 清理过期的滑动窗口行(保留最近 2 分钟即可) let _ = sqlx::query( - "DELETE FROM key_usage_window WHERE window_minute < (NOW() - INTERVAL '2 minutes')::TEXT" + "DELETE FROM key_usage_window WHERE window_minute < to_char(NOW() - INTERVAL '2 minutes', 'YYYY-MM-DDTHH24:MI')" ) .execute(db).await; // 忽略错误,非关键操作 diff --git a/crates/zclaw-saas/src/relay/service.rs b/crates/zclaw-saas/src/relay/service.rs index fcfa28e..bf0f92f 100644 --- a/crates/zclaw-saas/src/relay/service.rs +++ b/crates/zclaw-saas/src/relay/service.rs @@ -651,7 +651,7 @@ pub async fn sort_candidates_by_quota( SUM(COALESCE(pk.max_rpm, 999999) - COALESCE(uw.request_count, 0)) AS remaining_rpm FROM provider_keys pk LEFT JOIN key_usage_window uw ON pk.id = uw.key_id - AND uw.window_minute = to_char(date_trunc('minute', NOW()), 'YYYY-MM-DDTHH24:MI') + AND uw.window_minute >= to_char(NOW() - INTERVAL '1 minute', 'YYYY-MM-DDTHH24:MI') WHERE pk.provider_id = ANY($1) AND pk.is_active = TRUE AND (pk.cooldown_until IS NULL OR pk.cooldown_until::timestamptz <= NOW()) diff --git a/desktop/src/components/ChatArea.tsx b/desktop/src/components/ChatArea.tsx index a977c7c..a0cfc33 100644 --- a/desktop/src/components/ChatArea.tsx +++ b/desktop/src/components/ChatArea.tsx @@ -31,6 +31,7 @@ import { ReasoningBlock } from './ai/ReasoningBlock'; import { StreamingText } from './ai/StreamingText'; import { ChatMode } from './ai/ChatMode'; import { ModelSelector } from './ai/ModelSelector'; +import { isTauriRuntime } from '../lib/tauri-gateway'; import { SuggestionChips } from './ai/SuggestionChips'; import { PipelineResultPreview } from './pipeline/PipelineResultPreview'; import { PresentationContainer } from './presentation/PresentationContainer'; @@ -562,7 +563,7 @@ export function ChatArea({ compact, onOpenDetail }: { compact?: boolean; onOpenD }
- {models.length > 0 && ( + {models.length > 0 && (!isTauriRuntime() || isLoggedIn) && ( ({ id: m.id, name: m.name, provider: m.provider }))} currentModel={currentModel} diff --git a/desktop/src/lib/saas-relay-client.ts b/desktop/src/lib/saas-relay-client.ts index 3642714..a9360fc 100644 --- a/desktop/src/lib/saas-relay-client.ts +++ b/desktop/src/lib/saas-relay-client.ts @@ -134,7 +134,7 @@ export function createSaaSRelayGatewayClient( if (opts?.plan_mode) body['plan_mode'] = true; if (opts?.subagent_enabled) body['subagent_enabled'] = true; - const response = await saasClient.chatCompletion(body); + const response = await saasClient.chatCompletion(body, abortController.signal); if (!response.ok) { const errText = await response.text().catch(() => ''); @@ -160,6 +160,9 @@ export function createSaaSRelayGatewayClient( buffer += decoder.decode(value, { stream: true }); + // Normalize CRLF to LF for SSE spec compliance + buffer = buffer.replace(/\r\n/g, '\n'); + // Optimized SSE parsing: split by double-newline (event boundaries) let boundary: number; while ((boundary = buffer.indexOf('\n\n')) !== -1) {