fix(desktop): wait for first-run bootstrap before connection timeout
This commit is contained in:
@@ -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
@@ -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 system’s application manager to uninstall AITURK IDE. Your separate agent data folder is preserved.'
|
uninstallDescription:
|
||||||
|
'Use your operating system’s 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 IDE’yi kaldır',
|
uninstallTitle: 'AITURK IDE’yi kaldır',
|
||||||
uninstallDescription: 'AITURK IDE’yi işletim sisteminin uygulama yöneticisinden kaldırın. Windows’ta Ayarlar → Uygulamalar → Yüklü uygulamalar yolunu izleyin. Ayrı ajan veri klasörünüz korunur.'
|
uninstallDescription:
|
||||||
|
'AITURK IDE’yi işletim sisteminin uygulama yöneticisinden kaldırın. Windows’ta Ayarlar → Uygulamalar → Yüklü uygulamalar yolunu izleyin. Ayrı ajan veri klasörünüz korunur.'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAiturkCopy() {
|
export function useAiturkCopy() {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user