fix: runtime snapshot timeouts, config perms, stale lock recovery, summarization-v1 tier, loopback OAuth UX (#2690)

This commit is contained in:
Mega Mind
2026-05-26 16:42:16 +05:30
committed by GitHub
parent b616f48a04
commit c42e24960b
23 changed files with 132 additions and 29 deletions
@@ -263,6 +263,11 @@ const OAuthProviderButton = ({
})
.catch(err => {
warnLog('[%s] loopback callback failed', provider.id, err);
const isTimeout = err instanceof Error && err.message.includes('timed out');
if (isTimeout) {
setIsLoading(false);
setStartupError(t('oauth.button.loopbackTimeout'));
}
});
}
+2
View File
@@ -145,6 +145,8 @@ const ar4: TranslationMap = {
'notifications.center.filterAll': 'تصفية الكل',
'notifications.center.markAllRead': 'تحديد الكل كمقروء',
'notifications.center.title': 'الإشعارات',
'oauth.button.loopbackTimeout':
'انتهت مهلة تسجيل الدخول — لم يكتمل المتصفح إعادة توجيه OAuth. يرجى المحاولة مرة أخرى.',
'oauth.button.connecting': 'جارٍ الاتصال...',
'oauth.login.continueWith': 'المتابعة مع',
'onboarding.contextGathering.buildingDesc': 'وصف البناء',
+2
View File
@@ -146,6 +146,8 @@ const bn4: TranslationMap = {
'notifications.center.filterAll': 'সব ফিল্টার',
'notifications.center.markAllRead': 'সব পঠিত চিহ্নিত করুন',
'notifications.center.title': 'বিজ্ঞপ্তি',
'oauth.button.loopbackTimeout':
'সাইন-ইন টাইম আউট হয়েছে — ব্রাউজার OAuth পুনর্নির্দেশনা সম্পন্ন করেনি। অনুগ্রহ করে আবার চেষ্টা করুন।',
'oauth.button.connecting': 'সংযোগ হচ্ছে...',
'oauth.login.continueWith': 'দিয়ে চালিয়ে যান',
'onboarding.contextGathering.buildingDesc': 'বিল্ডিং বিবরণ',
+2
View File
@@ -146,6 +146,8 @@ const de4: TranslationMap = {
'notifications.center.filterAll': 'Alles filtern',
'notifications.center.markAllRead': 'Alles als gelesen markieren',
'notifications.center.title': 'Benachrichtigungen',
'oauth.button.loopbackTimeout':
'Anmeldung abgelaufen — der Browser hat die OAuth-Weiterleitung nicht abgeschlossen. Bitte versuche es erneut.',
'oauth.button.connecting': 'Verbinden...',
'oauth.login.continueWith': 'Weiter mit',
'onboarding.contextGathering.buildingDesc': 'Gebäudebeschreibung',
+2
View File
@@ -154,6 +154,8 @@ const en4: TranslationMap = {
'notifications.center.filterAll': 'Filter all',
'notifications.center.markAllRead': 'Mark all read',
'notifications.center.title': 'Notifications',
'oauth.button.loopbackTimeout':
'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.',
'oauth.button.connecting': 'Connecting...',
'oauth.login.continueWith': 'Continue with',
'onboarding.contextGathering.buildingDesc': 'Building desc',
+2
View File
@@ -146,6 +146,8 @@ const es4: TranslationMap = {
'notifications.center.filterAll': 'Filtrar todo',
'notifications.center.markAllRead': 'Marcar todo como leído',
'notifications.center.title': 'Notificaciones',
'oauth.button.loopbackTimeout':
'El inicio de sesión expiró — el navegador no completó la redirección OAuth. Por favor, inténtalo de nuevo.',
'oauth.button.connecting': 'Conectando...',
'oauth.login.continueWith': 'Continuar con',
'onboarding.contextGathering.buildingDesc': 'Descripción de construcción',
+2
View File
@@ -146,6 +146,8 @@ const fr4: TranslationMap = {
'notifications.center.filterAll': 'Tout filtrer',
'notifications.center.markAllRead': 'Tout marquer comme lu',
'notifications.center.title': 'Notifications',
'oauth.button.loopbackTimeout':
"La connexion a expiré — le navigateur n'a pas complété la redirection OAuth. Veuillez réessayer.",
'oauth.button.connecting': 'Connexion en cours…',
'oauth.login.continueWith': 'Continuer avec',
'onboarding.contextGathering.buildingDesc': 'Description de la construction',
+2
View File
@@ -146,6 +146,8 @@ const hi4: TranslationMap = {
'notifications.center.filterAll': 'सभी फिल्टर',
'notifications.center.markAllRead': 'सभी पढ़ा हुआ मार्क करें',
'notifications.center.title': 'नोटिफिकेशन',
'oauth.button.loopbackTimeout':
'साइन-इन का समय समाप्त हो गया — ब्राउज़र ने OAuth पुनर्निर्देशन पूरा नहीं किया। कृपया पुनः प्रयास करें।',
'oauth.button.connecting': 'कनेक्ट हो रहा है...',
'oauth.login.continueWith': 'के साथ जारी रखें',
'onboarding.contextGathering.buildingDesc': 'बिल्डिंग विवरण',
+2
View File
@@ -146,6 +146,8 @@ const id4: TranslationMap = {
'notifications.center.filterAll': 'Filter semua',
'notifications.center.markAllRead': 'Tandai semua sudah dibaca',
'notifications.center.title': 'Notifikasi',
'oauth.button.loopbackTimeout':
'Masuk habis waktu — browser tidak menyelesaikan pengalihan OAuth. Silakan coba lagi.',
'oauth.button.connecting': 'Menghubungkan...',
'oauth.login.continueWith': 'Lanjutkan dengan',
'onboarding.contextGathering.buildingDesc': 'Deskripsi pembangunan',
+2
View File
@@ -146,6 +146,8 @@ const it4: TranslationMap = {
'notifications.center.filterAll': 'Filtra tutte',
'notifications.center.markAllRead': 'Segna tutte come lette',
'notifications.center.title': 'Notifiche',
'oauth.button.loopbackTimeout':
'Accesso scaduto — il browser non ha completato il reindirizzamento OAuth. Riprova.',
'oauth.button.connecting': 'Connessione...',
'oauth.login.continueWith': 'Continua con',
'onboarding.contextGathering.buildingDesc': 'Descrizione costruzione',
+2
View File
@@ -137,6 +137,8 @@ const ko4: TranslationMap = {
'notifications.center.filterAll': '전체 필터',
'notifications.center.markAllRead': '모두 읽음으로 표시',
'notifications.center.title': '알림',
'oauth.button.loopbackTimeout':
'로그인 시간 초과 — 브라우저가 OAuth 리디렉션을 완료하지 못했습니다. 다시 시도해 주세요.',
'oauth.button.connecting': '연결 중...',
'oauth.login.continueWith': '다음으로 계속:',
'onboarding.contextGathering.buildingDesc': '작성 설명',
+2
View File
@@ -147,6 +147,8 @@ const pt4: TranslationMap = {
'notifications.center.filterAll': 'Filtrar todos',
'notifications.center.markAllRead': 'Marcar todos como lidos',
'notifications.center.title': 'Notificações',
'oauth.button.loopbackTimeout':
'Login expirou — o navegador não concluiu o redirecionamento OAuth. Por favor, tente novamente.',
'oauth.button.connecting': 'Conectando...',
'oauth.login.continueWith': 'Continuar com',
'onboarding.contextGathering.buildingDesc': 'Descrição de construção',
+2
View File
@@ -146,6 +146,8 @@ const ru4: TranslationMap = {
'notifications.center.filterAll': 'Все',
'notifications.center.markAllRead': 'Отметить всё прочитанным',
'notifications.center.title': 'Уведомления',
'oauth.button.loopbackTimeout':
'Время входа истекло — браузер не завершил перенаправление OAuth. Пожалуйста, попробуйте снова.',
'oauth.button.connecting': 'Подключение...',
'oauth.login.continueWith': 'Продолжить через',
'onboarding.contextGathering.buildingDesc': 'Сборка профиля',
+1
View File
@@ -143,6 +143,7 @@ const zhCN4: TranslationMap = {
'notifications.center.filterAll': '全部',
'notifications.center.markAllRead': '全部标记已读',
'notifications.center.title': '通知',
'oauth.button.loopbackTimeout': '登录超时 — 浏览器未完成 OAuth 跳转。请重试。',
'oauth.button.connecting': '连接中...',
'oauth.login.continueWith': '继续使用',
'onboarding.contextGathering.buildingDesc': '正在分析你的历史记录...',
+2
View File
@@ -2254,6 +2254,8 @@ const en: TranslationMap = {
'notifications.center.markAllRead': 'Mark all read',
'notifications.center.title': 'Notifications',
'oauth.button.connecting': 'Connecting...',
'oauth.button.loopbackTimeout':
'Sign-in timed out — the browser did not complete the OAuth redirect. Please try again.',
'oauth.login.continueWith': 'Continue with',
'onboarding.contextGathering.buildingDesc': 'Gathering context from your connected accounts…',
'onboarding.contextGathering.buildingProfile': 'Building your profile...',
+1 -1
View File
@@ -19,7 +19,7 @@ import { isTauri } from './tauriCommands/common';
*/
const DEFAULT_PORT = 53824;
const DEFAULT_TIMEOUT_SECS = 300;
const DEFAULT_TIMEOUT_SECS = 60;
const CALLBACK_EVENT = 'loopback-oauth-callback';
export interface LoopbackHandle {
+51 -13
View File
@@ -34,6 +34,7 @@ const CURRENT_USER_REFRESH_TTL: Duration = Duration::from_secs(5);
const RUNTIME_SNAPSHOT_TTL: Duration = Duration::from_secs(2);
const AUTH_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
const RUNTIME_SNAPSHOT_TIMEOUT: Duration = Duration::from_secs(10);
const SNAPSHOT_SUB_OP_TIMEOUT: Duration = Duration::from_secs(5);
static APP_STATE_FILE_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
static CURRENT_USER_CACHE: Lazy<Mutex<Option<CachedCurrentUser>>> = Lazy::new(|| Mutex::new(None));
static RUNTIME_SNAPSHOT_CACHE: Lazy<Mutex<Option<CachedRuntimeSnapshot>>> =
@@ -439,34 +440,71 @@ async fn build_runtime_snapshot(config: &Config, req_id: u64) -> RuntimeSnapshot
let (screen_intelligence, local_ai, autocomplete, service) = tokio::join!(
async {
let t = Instant::now();
let _ = crate::openhuman::screen_intelligence::global_engine()
.apply_config(si_config)
.await;
let status = crate::openhuman::screen_intelligence::global_engine()
.status()
.await;
let status = match tokio::time::timeout(SNAPSHOT_SUB_OP_TIMEOUT, async {
let _ = crate::openhuman::screen_intelligence::global_engine()
.apply_config(si_config)
.await;
crate::openhuman::screen_intelligence::global_engine()
.status()
.await
})
.await
{
Ok(s) => s,
Err(_) => {
warn!(
"{LOG_PREFIX} screen_intelligence timed out after {}s; using degraded sub-snapshot req_id={}",
SNAPSHOT_SUB_OP_TIMEOUT.as_secs(),
req_id,
);
degraded_runtime_snapshot(config).screen_intelligence
}
};
(status, t.elapsed().as_millis())
},
async {
let t = Instant::now();
let status = match crate::openhuman::inference::rpc::inference_status(
&config_for_local_ai,
let status = match tokio::time::timeout(
SNAPSHOT_SUB_OP_TIMEOUT,
crate::openhuman::inference::rpc::inference_status(&config_for_local_ai),
)
.await
{
Ok(outcome) => outcome.value,
Err(error) => {
Ok(Ok(outcome)) => outcome.value,
Ok(Err(error)) => {
warn!("{LOG_PREFIX} local_ai status failed during snapshot: {error}");
crate::openhuman::inference::LocalAiStatus::disabled(&config_for_local_ai)
}
Err(_) => {
warn!(
"{LOG_PREFIX} local_ai timed out after {}s; using degraded sub-snapshot req_id={}",
SNAPSHOT_SUB_OP_TIMEOUT.as_secs(),
req_id,
);
crate::openhuman::inference::LocalAiStatus::disabled(&config_for_local_ai)
}
};
(status, t.elapsed().as_millis())
},
async {
let t = Instant::now();
let status = crate::openhuman::autocomplete::global_engine()
.status_with_config(&config_for_autocomplete)
.await;
let status = match tokio::time::timeout(
SNAPSHOT_SUB_OP_TIMEOUT,
crate::openhuman::autocomplete::global_engine()
.status_with_config(&config_for_autocomplete),
)
.await
{
Ok(s) => s,
Err(_) => {
warn!(
"{LOG_PREFIX} autocomplete timed out after {}s; using degraded sub-snapshot req_id={}",
SNAPSHOT_SUB_OP_TIMEOUT.as_secs(),
req_id,
);
degraded_runtime_snapshot(config).autocomplete
}
};
(status, t.elapsed().as_millis())
},
async {
+2 -2
View File
@@ -40,8 +40,8 @@ pub use schema::{
StorageProviderSection, StreamMode, TeamModelConfig, TelegramConfig, UpdateConfig,
UpdateRestartStrategy, VoiceActivationMode, VoiceServerConfig, WebSearchConfig, WebhookConfig,
DEFAULT_CLOUD_LLM_MODEL, DEFAULT_MODEL, MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1,
MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, SEARCH_ENGINE_BRAVE, SEARCH_ENGINE_MANAGED,
SEARCH_ENGINE_PARALLEL,
MODEL_REASONING_QUICK_V1, MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1, SEARCH_ENGINE_BRAVE,
SEARCH_ENGINE_MANAGED, SEARCH_ENGINE_PARALLEL,
};
pub use schema::{
clear_active_user, default_projects_dir, default_root_openhuman_dir, pre_login_user_dir,
+28 -6
View File
@@ -1120,20 +1120,42 @@ impl Config {
if config_path.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
if let Ok(meta) = fs::metadata(&config_path).await {
if meta.permissions().mode() & 0o004 != 0 {
let warned = WARNED_WORLD_READABLE_CONFIGS
.get_or_init(|| Mutex::new(HashSet::new()));
let mut warned_guard = warned.lock().unwrap_or_else(|e| e.into_inner());
if warned_guard.insert(config_path.clone()) {
// Only attempt to fix paths not yet successfully chmod'd.
// Cache is advanced only on success so a persistent
// failure re-warns and re-attempts on every load.
let already_fixed = warned
.lock()
.unwrap_or_else(|e| e.into_inner())
.contains(&config_path);
if !already_fixed {
tracing::warn!(
"Config file {:?} is world-readable (mode {:o}). \
Consider restricting with: chmod 600 {:?}",
"[config] Config file {:?} is world-readable (mode {:o}); \
auto-fixing to 600",
config_path,
meta.permissions().mode() & 0o777,
config_path,
);
match fs::set_permissions(&config_path, Permissions::from_mode(0o600))
.await
{
Ok(()) => {
warned
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(config_path.clone());
}
Err(e) => {
tracing::warn!(
path = %config_path.display(),
error = %e,
"[config] failed to auto-fix config file permissions to 600",
);
}
}
}
}
}
+1
View File
@@ -24,6 +24,7 @@ pub const MODEL_CHAT_V1: &str = "chat-v1";
/// reasoning is needed.
pub const MODEL_REASONING_QUICK_V1: &str = "reasoning-quick-v1";
pub const MODEL_CODING_V1: &str = "coding-v1";
pub const MODEL_SUMMARIZATION_V1: &str = "summarization-v1";
/// Default model used when no explicit model is configured.
///
/// Set to `reasoning-quick-v1` (Kimi K2.6 Turbo on Fireworks — low-latency,
+4 -2
View File
@@ -1093,8 +1093,10 @@ impl AuthProfilesStore {
let too_old = age.map_or(false, |a| a >= Duration::from_millis(STALE_LOCK_AGE_MS));
// A pidless lock needs only a short grace: no healthy holder leaves the
// file without a `pid=` line for more than the microsecond gap between
// `create_new` and the write, so anything older is abandoned.
let malformed_too_old = age.map_or(false, |a| {
// `create_new` and the write, so anything older is abandoned. If mtime
// is unreadable (clock skew, platform limitation) default to stale —
// no legitimate in-flight writer would be undetectable for that long.
let malformed_too_old = age.map_or(true, |a| {
a >= Duration::from_millis(MALFORMED_LOCK_GRACE_MS)
});
+5 -2
View File
@@ -79,7 +79,7 @@ pub fn auth_key_for_slug(slug: &str) -> String {
pub(crate) fn is_known_openhuman_tier(model: &str) -> bool {
use crate::openhuman::config::{
MODEL_AGENTIC_V1, MODEL_CHAT_V1, MODEL_CODING_V1, MODEL_REASONING_QUICK_V1,
MODEL_REASONING_V1,
MODEL_REASONING_V1, MODEL_SUMMARIZATION_V1,
};
matches!(
model,
@@ -88,10 +88,12 @@ pub(crate) fn is_known_openhuman_tier(model: &str) -> bool {
| MODEL_AGENTIC_V1
| MODEL_CODING_V1
| MODEL_REASONING_QUICK_V1
| MODEL_SUMMARIZATION_V1
| "hint:reasoning"
| "hint:chat"
| "hint:agentic"
| "hint:coding"
| "hint:summarization"
)
}
@@ -416,6 +418,7 @@ fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box<dyn Provider>,
Some("chat") => crate::openhuman::config::MODEL_REASONING_QUICK_V1.to_string(),
Some("agentic") => crate::openhuman::config::MODEL_AGENTIC_V1.to_string(),
Some("coding") => crate::openhuman::config::MODEL_CODING_V1.to_string(),
Some("summarization") => crate::openhuman::config::MODEL_SUMMARIZATION_V1.to_string(),
Some(_) => {
// Unrecognised hint — forward verbatim; the backend decides validity.
model
@@ -427,7 +430,7 @@ fn make_openhuman_backend(config: &Config) -> anyhow::Result<(Box<dyn Provider>,
log::warn!(
"[providers][chat-factory] model '{}' is not a recognized OpenHuman \
backend tier (valid: reasoning-v1, chat-v1, agentic-v1, coding-v1, \
reasoning-quick-v1); falling back to '{}'",
reasoning-quick-v1, summarization-v1); falling back to '{}'",
model,
crate::openhuman::config::MODEL_REASONING_V1,
);
+8 -3
View File
@@ -294,7 +294,10 @@ async fn run_connection(
emit_state_change(shared);
// 7. Main event loop
let timeout_duration = Duration::from_millis(ping_interval + ping_timeout_ms + 5000);
// Deadline = pingInterval + pingTimeout + 5 s grace so minor server-side
// jitter doesn't cause a spurious reconnect on a healthy connection.
let timeout_ms = ping_interval + ping_timeout_ms + 5_000;
let timeout_duration = Duration::from_millis(timeout_ms);
let mut deadline = Instant::now() + timeout_duration;
loop {
@@ -336,8 +339,10 @@ async fn run_connection(
}
_ = tokio::time::sleep_until(deadline) => {
log::warn!(
"[socket] Ping timeout ({}ms)",
ping_interval + ping_timeout_ms + 5000
"[socket] No server ping received within {}ms (interval={}ms + timeout={}ms + 5s grace); reconnecting",
timeout_ms,
ping_interval,
ping_timeout_ms,
);
return ConnectionOutcome::Lost("Ping timeout".into());
}