fix(desktop): wait for first-run bootstrap before connection timeout

This commit is contained in:
2026-09-05 13:46:53 +03:00
parent 03634b1ca3
commit b9bb62ea82
6 changed files with 231 additions and 30 deletions
@@ -8,7 +8,12 @@ import { translateNow } from '@/i18n'
import { desktopDefaultCwd } from '@/lib/desktop-fs' import { desktopDefaultCwd } from '@/lib/desktop-fs'
import { decideLivenessForceClose, LIVENESS_REPROBE_DELAY_MS } from '@/lib/gateway-liveness-policy' import { decideLivenessForceClose, LIVENESS_REPROBE_DELAY_MS } from '@/lib/gateway-liveness-policy'
import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff'
import { BACKEND_BOOT_WAIT_TIMEOUT_MS, RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout' import {
BACKEND_BOOT_WAIT_TIMEOUT_MS,
RECONNECT_ATTEMPT_TIMEOUT_MS,
withBootstrapAwareTimeout,
withTimeout
} from '@/lib/with-timeout'
import { import {
$desktopBoot, $desktopBoot,
applyDesktopBootProgress, applyDesktopBootProgress,
@@ -988,10 +993,12 @@ export function useGatewayBoot({
// round-trip must not hang "Starting Hermes…" forever. Initial boot // round-trip must not hang "Starting Hermes…" forever. Initial boot
// rides out a full backend cold spawn, so it gets the shared 45s // rides out a full backend cold spawn, so it gets the shared 45s
// backend-boot budget, not the 20s reconnect budget. // backend-boot budget, not the 20s reconnect budget.
const conn = await withTimeout( const conn = await withBootstrapAwareTimeout(
desktop.getConnection(windowProfileOverride() ?? undefined), desktop.getConnection(windowProfileOverride() ?? undefined),
BACKEND_BOOT_WAIT_TIMEOUT_MS, BACKEND_BOOT_WAIT_TIMEOUT_MS,
'Timed out connecting to Hermes backend' 'Timed out connecting to Hermes backend',
desktop.getBootstrapState ? () => desktop.getBootstrapState!() : undefined,
() => cancelled
) )
if (cancelled) { if (cancelled) {
@@ -15,6 +15,7 @@ import type {
DesktopBootstrapState DesktopBootstrapState
} from '@/global' } from '@/global'
import { useI18n } from '@/i18n' import { useI18n } from '@/i18n'
import { aiturkInstallStage } from '@/i18n/aiturk'
import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons' import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons'
import { capitalize } from '@/lib/text' import { capitalize } from '@/lib/text'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -59,6 +60,10 @@ interface StageRowProps {
} }
function formatStageName(name: string): string { function formatStageName(name: string): string {
const translated = aiturkInstallStage(name)
if (translated) {
return translated
}
// 'system-packages' -> 'System packages'; 'uv' stays 'uv' // 'system-packages' -> 'System packages'; 'uv' stays 'uv'
if (name.length <= 3) { if (name.length <= 3) {
return name return name
@@ -270,6 +270,12 @@ export function DesktopOnboardingOverlay({
// do we know whether to dismiss (true) or surface the picker (false). // do we know whether to dismiss (true) or surface the picker (false).
// EXCEPTION: manual mode (user opened the selector from a working app to // EXCEPTION: manual mode (user opened the selector from a working app to
// add/switch a provider) shows the overlay regardless of configured state. // add/switch a provider) shows the overlay regardless of configured state.
// The local installer owns first-run choice and download progress. Provider
// onboarding becomes meaningful after that runtime is available.
if (boot.running && boot.phase === 'bootstrap.choice' && !onboarding.manual) {
return null
}
if (onboarding.configured === true && !onboarding.manual) { if (onboarding.configured === true && !onboarding.manual) {
return null return null
} }
@@ -446,7 +452,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) {
const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers])
const hasOauth = ordered.length > 0 const hasOauth = ordered.length > 0
const apiKeyOptions = useApiKeyCatalog() const apiKeyOptions = useApiKeyCatalog()
const aiturkProvider = <AiturkProvider onConnected={async () => { await refreshOnboarding(ctx) }} /> const aiturkProvider = (
<AiturkProvider
onConnected={async () => {
await refreshOnboarding(ctx)
}}
/>
)
// localEndpoint forces the key form regardless of `mode` (which a manual // localEndpoint forces the key form regardless of `mode` (which a manual
// provider refresh may flip back to 'oauth'); it preselects the local option // provider refresh may flip back to 'oauth'); it preselects the local option
@@ -674,7 +686,9 @@ export function ApiKeyForm({
type="button" type="button"
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">{o.id === 'local' ? t.settings.providers.localEndpoint.title : o.name}</span> <span className="text-sm font-medium">
{o.id === 'local' ? t.settings.providers.localEndpoint.title : o.name}
</span>
{isSet?.(o.envKey) ? <Check className="size-3.5 text-muted-foreground" /> : null} {isSet?.(o.envKey) ? <Check className="size-3.5 text-muted-foreground" /> : null}
</div> </div>
{(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? ( {(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? (
+103 -24
View File
@@ -1,45 +1,124 @@
import { useI18n } from './context' import { useI18n } from './context'
import { getRuntimeI18nLocale } from './runtime'
const trInstallStages: Record<string, string> = {
uv: 'uv',
git: 'Git',
node: 'Node.js',
'system-packages': 'Sistem araçları',
repository: 'Kaynak kodu',
python: 'Python',
venv: 'Python ortamı',
dependencies: 'Ajan bağımlılıkları',
'node-deps': 'Node.js bağımlılıkları',
path: 'Komut yolları',
'config-templates': 'Ayar şablonları',
'platform-sdks': 'Platform araçları',
'bootstrap-marker': 'Kurulum doğrulaması',
configure: 'Yapılandırma',
gateway: 'Ağ geçidi'
}
export function aiturkInstallStage(name: string): string | undefined {
return getRuntimeI18nLocale() === 'tr' ? trInstallStages[name] : undefined
}
const en = { const en = {
intro: 'Bring your code, question, or idea. Build with AITURK.', intro: 'Bring your code, question, or idea. Build with AITURK.',
pitch: 'Connect your models with your TurkServis account.', key: 'TurkServis API key', pitch: 'Connect your models with your TurkServis account.',
getKey: 'My account and API keys', noModels: 'No models are available for this key.', key: 'TurkServis API key',
getKey: 'My account and API keys',
noModels: 'No models are available for this key.',
failed: 'Could not connect to TurkServis.', failed: 'Could not connect to TurkServis.',
custom: { custom: {
title: 'Custom endpoints', active: 'Active', keySet: 'API key set', use: 'Use', delete: 'Delete endpoint', title: 'Custom endpoints',
empty: 'No custom endpoints', emptyDesc: 'Add an OpenAI-compatible endpoint below.', edit: 'Edit endpoint', add: 'Add endpoint', active: 'Active',
name: 'Name', providerId: 'Provider ID', url: 'Endpoint URL', model: 'Default model', context: 'Context', auto: 'Auto', keySet: 'API key set',
apiKey: 'API key', keepKey: 'Leave blank to keep current key', optional: 'Optional', newChats: 'Use for new chats', use: 'Use',
discover: 'Discover models', test: 'Test', newEndpoint: 'New endpoint', loadFailed: 'Could not load custom endpoints', delete: 'Delete endpoint',
saved: 'Custom endpoint saved.', saveFailed: 'Save failed', reachable: 'Endpoint is reachable.', validationFailed: 'Endpoint validation failed.', empty: 'No custom endpoints',
activationFailed: 'Activation failed', deleteFailed: 'Delete failed', preset: 'Use TurkServis settings', emptyDesc: 'Add an OpenAI-compatible endpoint below.',
found: (n: number) => `Endpoint is reachable. Found ${n} models.`, confirmDelete: (name: string) => `Delete ${name}?` edit: 'Edit endpoint',
add: 'Add endpoint',
name: 'Name',
providerId: 'Provider ID',
url: 'Endpoint URL',
model: 'Default model',
context: 'Context',
auto: 'Auto',
apiKey: 'API key',
keepKey: 'Leave blank to keep current key',
optional: 'Optional',
newChats: 'Use for new chats',
discover: 'Discover models',
test: 'Test',
newEndpoint: 'New endpoint',
loadFailed: 'Could not load custom endpoints',
saved: 'Custom endpoint saved.',
saveFailed: 'Save failed',
reachable: 'Endpoint is reachable.',
validationFailed: 'Endpoint validation failed.',
activationFailed: 'Activation failed',
deleteFailed: 'Delete failed',
preset: 'Use TurkServis settings',
found: (n: number) => `Endpoint is reachable. Found ${n} models.`,
confirmDelete: (name: string) => `Delete ${name}?`
}, },
about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.', about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.',
downloads: 'Official downloads and updates', source: 'Source code and license', downloads: 'Official downloads and updates',
source: 'Source code and license',
uninstallTitle: 'Uninstall AITURK IDE', uninstallTitle: 'Uninstall AITURK IDE',
uninstallDescription: 'Use your operating systems application manager to uninstall AITURK IDE. Your separate agent data folder is preserved.' uninstallDescription:
'Use your operating systems application manager to uninstall AITURK IDE. Your separate agent data folder is preserved.'
} }
const tr: typeof en = { const tr: typeof en = {
intro: 'Kodunuzu, sorunuzu veya fikrinizi paylaşın. AITURK ile birlikte geliştirin.', intro: 'Kodunuzu, sorunuzu veya fikrinizi paylaşın. AITURK ile birlikte geliştirin.',
pitch: 'TurkServis hesabınızla modellerinize bağlanın.', key: 'TurkServis API anahtarı', pitch: 'TurkServis hesabınızla modellerinize bağlanın.',
getKey: 'Hesabım ve API anahtarlarım', noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.', key: 'TurkServis API anahtarı',
getKey: 'Hesabım ve API anahtarlarım',
noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.',
failed: 'TurkServis bağlantısı kurulamadı.', failed: 'TurkServis bağlantısı kurulamadı.',
custom: { custom: {
title: 'Özel API bağlantıları', active: 'Etkin', keySet: 'API anahtarı ayarlandı', use: 'Kullan', delete: 'Bağlantıyı sil', title: 'Özel API bağlantıları',
empty: 'Özel bağlantı yok', emptyDesc: 'Aşağıdan OpenAI uyumlu bir API bağlantısı ekleyin.', edit: 'Bağlantıyı düzenle', add: 'Bağlantı ekle', active: 'Etkin',
name: 'Ad', providerId: 'Sağlayıcı kimliği', url: 'API adresi', model: 'Varsayılan model', context: 'Bağlam', auto: 'Otomatik', keySet: 'API anahtarı ayarlandı',
apiKey: 'API anahtarı', keepKey: 'Mevcut anahtarı korumak için boş bırakın', optional: 'İsteğe bağlı', newChats: 'Yeni sohbetlerde kullan', use: 'Kullan',
discover: 'Modelleri keşfet', test: 'Sına', newEndpoint: 'Yeni bağlantı', loadFailed: 'Özel bağlantılar yüklenemedi', delete: 'Bağlantıyı sil',
saved: 'Özel bağlantı kaydedildi.', saveFailed: 'Kaydedilemedi', reachable: 'API adresine erişiliyor.', validationFailed: 'Bağlantı doğrulanamadı.', empty: 'Özel bağlantı yok',
activationFailed: 'Etkinleştirilemedi', deleteFailed: 'Silinemedi', preset: 'TurkServis ayarlarını kullan', emptyDesc: 'Aşağıdan OpenAI uyumlu bir API bağlantısı ekleyin.',
found: n => `API adresine erişiliyor. ${n} model bulundu.`, confirmDelete: name => `${name} silinsin mi?` edit: 'Bağlantıyı düzenle',
add: 'Bağlantı ekle',
name: 'Ad',
providerId: 'Sağlayıcı kimliği',
url: 'API adresi',
model: 'Varsayılan model',
context: 'Bağlam',
auto: 'Otomatik',
apiKey: 'API anahtarı',
keepKey: 'Mevcut anahtarı korumak için boş bırakın',
optional: 'İsteğe bağlı',
newChats: 'Yeni sohbetlerde kullan',
discover: 'Modelleri keşfet',
test: 'Sına',
newEndpoint: 'Yeni bağlantı',
loadFailed: 'Özel bağlantılar yüklenemedi',
saved: 'Özel bağlantı kaydedildi.',
saveFailed: 'Kaydedilemedi',
reachable: 'API adresine erişiliyor.',
validationFailed: 'Bağlantı doğrulanamadı.',
activationFailed: 'Etkinleştirilemedi',
deleteFailed: 'Silinemedi',
preset: 'TurkServis ayarlarını kullan',
found: n => `API adresine erişiliyor. ${n} model bulundu.`,
confirmDelete: name => `${name} silinsin mi?`
}, },
about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.', about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.',
downloads: 'Resmî indirmeler ve güncellemeler', source: 'Kaynak kodu ve lisans', downloads: 'Resmî indirmeler ve güncellemeler',
source: 'Kaynak kodu ve lisans',
uninstallTitle: 'AITURK IDEyi kaldır', uninstallTitle: 'AITURK IDEyi kaldır',
uninstallDescription: 'AITURK IDEyi işletim sisteminin uygulama yöneticisinden kaldırın. Windowsta Ayarlar → Uygulamalar → Yüklü uygulamalar yolunu izleyin. Ayrı ajan veri klasörünüz korunur.' uninstallDescription:
'AITURK IDEyi işletim sisteminin uygulama yöneticisinden kaldırın. Windowsta Ayarlar → Uygulamalar → Yüklü uygulamalar yolunu izleyin. Ayrı ajan veri klasörünüz korunur.'
} }
export function useAiturkCopy() { export function useAiturkCopy() {
+60 -1
View File
@@ -1,8 +1,67 @@
import { describe, expect, it, vi } from 'vitest' import { describe, expect, it, vi } from 'vitest'
import { withTimeout } from './with-timeout' import { withBootstrapAwareTimeout, withTimeout } from './with-timeout'
describe('withTimeout', () => { describe('withTimeout', () => {
it.each([{ active: true }, { setupChoice: { active: true } }])(
'waits beyond cold boot during setup: %j',
async state => {
vi.useFakeTimers()
try {
let finish!: (value: string) => void
const connection = new Promise<string>(resolve => {
finish = resolve
})
const probe = vi.fn(async () => state)
const result = withBootstrapAwareTimeout(connection, 45_000, 'timeout', probe)
await vi.advanceTimersByTimeAsync(180_000)
expect(probe).toHaveBeenCalledTimes(4)
finish('connected')
await expect(result).resolves.toBe('connected')
} finally {
vi.useRealTimers()
}
}
)
it('keeps an ordinary stalled backend bounded', async () => {
vi.useFakeTimers()
try {
const result = withBootstrapAwareTimeout(new Promise<never>(() => {}), 45_000, 'timeout', async () => ({
active: false
}))
const rejection = expect(result).rejects.toThrow('timeout')
await vi.advanceTimersByTimeAsync(45_000)
await rejection
} finally {
vi.useRealTimers()
}
})
it('bounds a stalled bootstrap IPC probe', async () => {
vi.useFakeTimers()
try {
const result = withBootstrapAwareTimeout(
new Promise<never>(() => {}),
45_000,
'timeout',
() => new Promise(() => {})
)
const rejection = expect(result).rejects.toThrow('timeout')
await vi.advanceTimersByTimeAsync(50_000)
await rejection
} finally {
vi.useRealTimers()
}
})
it('propagates a real backend failure during setup', async () => {
const failure = new Error('runtime install failed')
await expect(
withBootstrapAwareTimeout(Promise.reject(failure), 45_000, 'timeout', async () => ({ active: true }))
).rejects.toBe(failure)
})
it('rejects with an onTimeout exception instead of letting it escape the timer callback', async () => { it('rejects with an onTimeout exception instead of letting it escape the timer callback', async () => {
vi.useFakeTimers() vi.useFakeTimers()
+37
View File
@@ -28,6 +28,43 @@ export function isTimeoutError(error: unknown): error is TimeoutError {
return error instanceof TimeoutError return error instanceof TimeoutError
} }
/** First-run downloads and the user's setup choice are outside the normal
* backend cold-start budget. Keep one connection request alive while main
* reports that work; a stalled IPC probe and ordinary boots remain bounded. */
export async function withBootstrapAwareTimeout<T>(
promise: Promise<T>,
ms: number,
message: string,
bootstrapState?: () => Promise<{ active?: boolean; setupChoice?: unknown } | null>,
cancelled: () => boolean = () => false
): Promise<T> {
let settled = false
const request = promise.then(
value => {
settled = true
return value
},
error => {
settled = true
throw error
}
)
for (;;) {
try {
return await withTimeout(request, ms, message)
} catch (error) {
if (settled || cancelled() || !isTimeoutError(error) || !bootstrapState) {
throw error
}
const state = await withTimeout(bootstrapState(), Math.min(ms, 5_000), message)
if (!state?.active && !state?.setupChoice) {
throw error
}
}
}
}
/** Settle with `promise`, or reject with a TimeoutError after `ms`. /** Settle with `promise`, or reject with a TimeoutError after `ms`.
* `onTimeout` runs synchronously before the rejection is published so callers * `onTimeout` runs synchronously before the rejection is published so callers
* can revoke ownership of work that would otherwise keep running unowned. If * can revoke ownership of work that would otherwise keep running unowned. If