AITURK IDE 1.0.0-beta.17: telefon kodu yayini yeniden denenir
Uzun sure acik kalan IDE'de "Telefon ekle" ilk istegi olu bir keep-alive baglantisinda kaybedebiliyordu; bulusma kaydi yayinlanamayinca kod yolu bes dakika boyunca "Kodla baglanma su an kullanilamiyor" kaliyordu (QR calisiyordu). - publishPairOffer: en cok 4 deneme (0,5 / 1,5 / 3 sn); ag hatasi, 5xx ve 429 yeniden denenir, 422 kalicidir; yaniti kaybolan denemeden sonraki 409 kaydin yerinde oldugu anlamina gelir (iptal jetonu yoktur, kayit kendiliginden biter). - Pencerede "Yeniden dene": ayni kod ve QR ile kaydi yeniden yayinlar. - Role oturumu kaydi ve "Giris yap" baslangici icin tek ek deneme (zaman asimi yeniden denenmez). - Gunluge yalniz "pair offer published / failed after N tries" duser; kod, lookup ve jeton yazilmaz. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
1 parent
2ed9a6b2de
commit
58dbb4523b
13 files changed
+729
-76
No files matched your search
@@ -11,6 +11,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
AITURK_CLI_KEY_HOLD_MS,
|
||||
AITURK_LOGIN_START_RETRY_DELAY_MS,
|
||||
type AiturkCliExit,
|
||||
AiturkLoginError,
|
||||
createAiturkCliRunner,
|
||||
@@ -38,6 +39,8 @@ const approved = { state: 'approved', key: IDE_KEY, cliKey: CLI_KEY, account: 'a
|
||||
function harness(
|
||||
options: {
|
||||
start?: { status: number; body?: unknown }
|
||||
/** Thrown by successive `device-auth/start` calls before any answer is given. */
|
||||
startErrors?: Error[]
|
||||
polls?: ({ status: number; body?: unknown } | Error)[]
|
||||
cliExits?: AiturkCliExit[]
|
||||
validate?: unknown
|
||||
@@ -47,6 +50,7 @@ function harness(
|
||||
) {
|
||||
const calls: { url: string; init: any }[] = []
|
||||
const polls = [...(options.polls ?? [])]
|
||||
const startErrors = [...(options.startErrors ?? [])]
|
||||
const cliExits = [...(options.cliExits ?? [0])]
|
||||
const clock = { now: 1_000_000 }
|
||||
const timers: { fn: () => void; ms: number; cleared: boolean }[] = []
|
||||
@@ -55,6 +59,12 @@ function harness(
|
||||
calls.push({ url, init })
|
||||
|
||||
if (url.endsWith('/start')) {
|
||||
const failure = startErrors.shift()
|
||||
|
||||
if (failure) {
|
||||
throw failure
|
||||
}
|
||||
|
||||
const r = options.start ?? { status: 201, body: startBody() }
|
||||
|
||||
return { status: r.status, json: async () => r.body }
|
||||
@@ -154,6 +164,47 @@ describe('aiturk login: start', () => {
|
||||
await expect(harness({ start: { status: 500 } }).login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
})
|
||||
|
||||
it('tries start once more after a thrown network error (first request of a long-idle process)', async () => {
|
||||
const h = harness({ startErrors: [new TypeError('fetch failed')] })
|
||||
const pending = h.login.start()
|
||||
|
||||
await vi.waitFor(() => expect(h.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||
expect(AITURK_LOGIN_START_RETRY_DELAY_MS).toBe(500)
|
||||
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(h.openExternal).not.toHaveBeenCalled()
|
||||
h.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||
|
||||
expect(await pending).toEqual({ userCode: 'ABCD2345', verificationUri: URI, expiresAt: 1_000_000 + 600_000 })
|
||||
expect(h.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||
// One code only: the approval page is opened once.
|
||||
expect(h.openExternal).toHaveBeenCalledExactlyOnceWith(URI)
|
||||
})
|
||||
|
||||
it('gives up after the second network error, and never retries an HTTP answer or a timeout', async () => {
|
||||
const twice = harness({ startErrors: [new TypeError('fetch failed'), new TypeError('fetch failed')] })
|
||||
const failing = twice.login.start().catch(e => e)
|
||||
|
||||
await vi.waitFor(() => expect(twice.timers.some(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toBe(true))
|
||||
twice.timers.find(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)!.fn()
|
||||
expect((await failing).message).toBe('aiturk-login:unavailable')
|
||||
expect(twice.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(2)
|
||||
expect(twice.timers.filter(timer => timer.ms === AITURK_LOGIN_START_RETRY_DELAY_MS)).toHaveLength(1)
|
||||
|
||||
for (const status of [500, 503, 429, 422]) {
|
||||
const answered = harness({ start: { status } })
|
||||
|
||||
await expect(answered.login.start()).rejects.toThrow(/^aiturk-login:(?:unavailable|rate-limited)$/)
|
||||
expect(answered.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(answered.timers).toHaveLength(0)
|
||||
}
|
||||
|
||||
const timedOut = harness({ startErrors: [new DOMException('The operation was aborted due to timeout', 'TimeoutError')] })
|
||||
|
||||
await expect(timedOut.login.start()).rejects.toThrow('aiturk-login:unavailable')
|
||||
expect(timedOut.calls.filter(call => call.url.endsWith('/start'))).toHaveLength(1)
|
||||
expect(timedOut.timers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('rejects malformed codes', async () => {
|
||||
await expect(
|
||||
harness({ start: { status: 201, body: startBody({ userCode: 'abcd2345' }) } }).login.start()
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
export const AITURK_LOGIN_ORIGIN = 'https://turkservis.online'
|
||||
export const AITURK_LOGIN_FETCH_TIMEOUT_MS = 20_000
|
||||
/** Wait before the single second try of `device-auth/start` after a thrown network error. */
|
||||
export const AITURK_LOGIN_START_RETRY_DELAY_MS = 500
|
||||
export const AITURK_CLI_TIMEOUT_MS = 60_000
|
||||
export const AITURK_CLI_KEY_HOLD_MS = 5 * 60_000
|
||||
|
||||
@@ -237,16 +239,29 @@ export function createAiturkLogin(deps: AiturkLoginDeps) {
|
||||
await cancelOnServer(previous.deviceCode)
|
||||
}
|
||||
|
||||
let response: AiturkFetchResponse
|
||||
let response: AiturkFetchResponse | undefined
|
||||
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/start', null, {
|
||||
client: 'ide',
|
||||
device_name: deviceName(deps.hostname()),
|
||||
also_cli: true
|
||||
})
|
||||
} catch {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
// A long-idle process can fail its first request on a dead pooled
|
||||
// connection. Only a THROWN network error (no HTTP answer, so no code was
|
||||
// handed to us) is tried once more; an HTTP answer is never retried, so
|
||||
// one start cannot knowingly leave two codes behind. A timeout already
|
||||
// used its whole budget and is not retried.
|
||||
for (let attempt = 1; response === undefined; attempt++) {
|
||||
try {
|
||||
response = await post('/v1/aiturk/device-auth/start', null, {
|
||||
client: 'ide',
|
||||
device_name: deviceName(deps.hostname()),
|
||||
also_cli: true
|
||||
})
|
||||
} catch (error) {
|
||||
const name = (error as null | { name?: unknown })?.name
|
||||
|
||||
if (attempt >= 2 || name === 'TimeoutError' || name === 'AbortError') {
|
||||
throw new AiturkLoginError('unavailable')
|
||||
}
|
||||
|
||||
await new Promise<void>(resolve => void setTimer(resolve, AITURK_LOGIN_START_RETRY_DELAY_MS))
|
||||
}
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
|
||||
@@ -17893,7 +17893,7 @@ function getMobileLink(): MobileLink {
|
||||
// API traffic is not logged; pairing and refusals are, for diagnosis. Relay
|
||||
// lines are status changes only ("relay connected", "relay failed:<code>").
|
||||
log: line => {
|
||||
if (/^relay /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||
if (/^(?:relay|pair) /.test(line) || / \/aiturk\//.test(line) || / [45]\d\d$/.test(line)) {
|
||||
rememberLog(`[mobile-lan] ${line}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ const TEXT = {
|
||||
scan: 'Telefonda AITURK Mobil → “Bilgisayara bağlan” → bu QR kodunu okutun.',
|
||||
orCode: 'ya da telefonda şu kodu yazın:',
|
||||
codePending: 'Kodla bağlanma hazırlanıyor…', codeReady: 'Kodla bağlanma hazır.',
|
||||
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.',
|
||||
codeUnavailable: 'Kodla bağlanma şu an kullanılamıyor; QR kodunu okutun.', codeRetry: 'Yeniden dene',
|
||||
securityCode: code => `Güvenlik kodu: ${code} (telefonda da aynı kod görünmeli)`,
|
||||
anyNetwork: 'Telefon ve bilgisayar aynı ağda olmak zorunda değil.', sameWifi: 'Telefon ve bilgisayar aynı Wi-Fi ağında olmalı.',
|
||||
countdown: (m, s) => `Kod ${m}:${s} sonra geçersiz olur`, expired: 'Kodun süresi doldu. Yeniden “Telefon ekle” seçin.',
|
||||
@@ -56,7 +56,7 @@ const TEXT = {
|
||||
scan: 'On the phone: AITURK Mobile → “Connect to computer” → scan this QR code.',
|
||||
orCode: 'or type this code on the phone:',
|
||||
codePending: 'Preparing code sign-in…', codeReady: 'Connecting with the code is ready.',
|
||||
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.',
|
||||
codeUnavailable: 'Connecting with the code is unavailable right now; scan the QR code.', codeRetry: 'Try again',
|
||||
securityCode: code => `Security code: ${code} (the phone must show the same code)`,
|
||||
anyNetwork: 'The phone and the computer do not have to be on the same network.', sameWifi: 'The phone and the computer must be on the same Wi-Fi network.',
|
||||
countdown: (m, s) => `The code expires in ${m}:${s}`, expired: 'The code expired. Choose “Add a phone” again.',
|
||||
@@ -84,7 +84,7 @@ document.documentElement.lang = lang; document.title = `AITURK · ${t.title}`
|
||||
for (const [id, key] of [['t-eyebrow', 'eyebrow'], ['t-title', 'title'], ['t-intro', 'intro'], ['t-remote', 'remote'], ['t-scan', 'scan'],
|
||||
['t-or-code', 'orCode'], ['t-devices', 'devices'], ['devices-empty', 'noDevices'], ['t-enabled', 'toggle'], ['t-relay-label', 'relayLabel'],
|
||||
['t-lan-label', 'lanLabel'], ['t-reissued', 'reissued'], ['lan-disallow', 'lanDisallow'], ['relay-renew', 'renew'],
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
['pair-begin', 'addPhone'], ['pair-cancel', 'cancel'], ['pair-republish', 'codeRetry'], ['firewall-remove', 'removeRule']]) $(id).textContent = t[key]
|
||||
|
||||
let current = null, drawnQr = '', busy = false
|
||||
function notice(text) { $('notice').textContent = text || ''; $('notice').hidden = !text }
|
||||
@@ -121,6 +121,8 @@ function render(state) {
|
||||
$('pair-qr').setAttribute('aria-label', t.scan)
|
||||
$('pair-code').textContent = `${state.pairing.code.slice(0, 4)}-${state.pairing.code.slice(4)}`
|
||||
$('pair-code-path').textContent = { pending: t.codePending, ready: t.codeReady, unavailable: t.codeUnavailable }[state.pairing.codePath]
|
||||
// Offered only once every background try is over; while they run the line above says "hazırlanıyor…".
|
||||
$('pair-republish').hidden = state.pairing.codePath !== 'unavailable'
|
||||
$('pair-security').textContent = state.pairing.securityCode ? t.securityCode(state.pairing.securityCode) : ''
|
||||
$('t-same-wifi').textContent = /[?&]r=/.test(state.pairing.qr) ? t.anyNetwork : t.sameWifi
|
||||
tick()
|
||||
@@ -155,6 +157,7 @@ async function refresh() { try { render(await host.call('state')) } catch (error
|
||||
$('enabled').onchange = () => { const on = $('enabled').checked; void run(async () => { notice(''); render(await host.call(on ? 'enable' : 'disable')) }) }
|
||||
$('pair-begin').onclick = () => run(async () => { notice(''); render(await host.call('pair.begin')) })
|
||||
$('pair-cancel').onclick = () => run(async () => { render(await host.call('pair.cancel')) })
|
||||
$('pair-republish').onclick = () => run(async () => { render(await host.call('pair.republish')) })
|
||||
$('lan-allow').onclick = () => run(async () => { notice(''); render(await host.call('lan.allow')) })
|
||||
$('lan-disallow').onclick = () => run(async () => { notice(''); render(await host.call('lan.disallow')) })
|
||||
$('relay-renew').onclick = () => { if (window.confirm(t.renewConfirm)) void run(async () => { render(await host.call('relay.renew')); notice(t.renewed) }) }
|
||||
|
||||
@@ -24,3 +24,4 @@ button.link { background:none; color:var(--accent); min-height:0; padding:0; mar
|
||||
.security { font-family:ui-monospace,Consolas,monospace; color:var(--text); overflow-wrap:anywhere }
|
||||
footer { display:flex; flex-wrap:wrap; gap:8px 20px }
|
||||
footer button.link { min-height:44px }
|
||||
#pair-republish { display:block; min-height:44px; margin:0 auto; padding:0 12px }
|
||||
@@ -20,6 +20,7 @@
|
||||
<p id="t-or-code"></p>
|
||||
<p id="pair-code" class="code" aria-live="off"></p>
|
||||
<p id="pair-code-path"></p>
|
||||
<button id="pair-republish" class="link" hidden></button>
|
||||
<p id="pair-countdown" class="countdown"></p>
|
||||
<p id="pair-security" class="security"></p>
|
||||
<p id="t-same-wifi" class="hint"></p>
|
||||
|
||||
@@ -20,6 +20,8 @@ export function createMobileWindow({ assets, link, show = true }) {
|
||||
disable: () => link.disable(),
|
||||
'pair.begin': () => link.pairBegin(),
|
||||
'pair.cancel': () => link.pairCancel(),
|
||||
// Publishes the running pairing's rendezvous record again; the code and the QR stay as they are.
|
||||
'pair.republish': () => link.pairRepublish(),
|
||||
devices: () => link.devices(),
|
||||
'device.revoke': params => link.deviceRevoke(params?.deviceId),
|
||||
// The only actions that may raise a UAC prompt; both are explicit button presses.
|
||||
|
||||
@@ -19,6 +19,7 @@ import { WebSocket, WebSocketServer } from 'ws'
|
||||
import { createSelfSignedCertificate } from './mobile-lan-cert'
|
||||
import {
|
||||
buildPairQrPayload,
|
||||
cancelPairOffer,
|
||||
createLanProxy,
|
||||
createMobileLink,
|
||||
createWindowsFirewall,
|
||||
@@ -34,6 +35,8 @@ import {
|
||||
type MobileLinkEvent,
|
||||
normalizePairCode,
|
||||
PAIR_CODE_ALPHABET,
|
||||
PAIR_OFFER_MAX_TRIES,
|
||||
PAIR_OFFER_RETRY_DELAYS_MS,
|
||||
pairLookup,
|
||||
parseFirewallRuleOutput,
|
||||
parseMobileLinkStored,
|
||||
@@ -745,10 +748,19 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
const events: MobileLinkEvent[] = []
|
||||
const logs: string[] = []
|
||||
const hostSockets: FakeHostSocket[] = []
|
||||
/** Outcomes of successive `POST /offers` calls (status or 'throw'); afterwards `state.offerStatus` applies. */
|
||||
const offerPlan: (number | 'throw')[] = [...(over.offerPlan ?? [])]
|
||||
/** Waits the controller asked for between tries (real values); they are shortened to 1 ms here. */
|
||||
const retrySleeps: number[] = []
|
||||
/** With `holdRetries` a wait ends only when the test calls the function. */
|
||||
const heldRetries: (() => void)[] = []
|
||||
const RETRY_WAITS = new Set<number>([...PAIR_OFFER_RETRY_DELAYS_MS])
|
||||
|
||||
const state = {
|
||||
remote: false,
|
||||
offerStatus: over.offerStatus ?? 201,
|
||||
/** While set, `POST /offers` answers only after it resolves. */
|
||||
offerGate: null as null | Promise<void>,
|
||||
/** false = `POST /sessions` fails (no internet). */
|
||||
sessions: over.sessions ?? true,
|
||||
sessionCount: 0,
|
||||
@@ -809,13 +821,20 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
return { status: 201, json: async () => ({ relayId: RELAY_IDS[index], hostToken: HOST_TOKENS[index] }) }
|
||||
}
|
||||
|
||||
if (state.offerStatus === 'throw') {
|
||||
const isOffer = url.endsWith('/offers')
|
||||
const offerOutcome = isOffer && offerPlan.length ? offerPlan.shift() : state.offerStatus
|
||||
|
||||
if (isOffer && state.offerGate) {
|
||||
await state.offerGate
|
||||
}
|
||||
|
||||
if (offerOutcome === 'throw') {
|
||||
throw new Error('network down')
|
||||
}
|
||||
|
||||
if (url.endsWith('/offers')) {
|
||||
if (isOffer) {
|
||||
return {
|
||||
status: state.offerStatus,
|
||||
status: offerOutcome,
|
||||
json: async () => ({ offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}`, expiresIn: 300 })
|
||||
}
|
||||
}
|
||||
@@ -835,6 +854,23 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
platform: 'win32',
|
||||
listenHost: LAN_HOST,
|
||||
ports: [0],
|
||||
// Real timers, except the waits between tries of a request: those are recorded and cut to 1 ms.
|
||||
setTimer: (fn: () => void, ms: number) => {
|
||||
if (!RETRY_WAITS.has(ms)) {
|
||||
return setTimeout(fn, ms)
|
||||
}
|
||||
|
||||
retrySleeps.push(ms)
|
||||
|
||||
if (over.holdRetries) {
|
||||
heldRetries.push(fn)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
return setTimeout(fn, 1)
|
||||
},
|
||||
clearTimer: (handle: unknown) => clearTimeout(handle as NodeJS.Timeout),
|
||||
log: (line: string) => logs.push(line)
|
||||
}
|
||||
|
||||
@@ -850,7 +886,7 @@ function linkHarness(over: Record<string, any> = {}) {
|
||||
const firewallWrites = () => firewallCalls.filter(entry => entry !== 'query')
|
||||
const offers = () => fetchCalls.filter(entry => entry.url.endsWith('/offers'))
|
||||
|
||||
return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets }
|
||||
return { link: make(), make, vault, store, firewallCalls, firewallWrites, fetchCalls, offers, events, logs, state, hostSockets, retrySleeps, heldRetries }
|
||||
}
|
||||
|
||||
describe('mobile link controller', () => {
|
||||
@@ -1278,6 +1314,306 @@ describe('mobile link controller', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('mobile link controller: rendezvous record retries', () => {
|
||||
const lookupOf = (h: ReturnType<typeof linkHarness>, index: number) => JSON.parse(h.offers()[index].init.body).lookup
|
||||
const cancels = (h: ReturnType<typeof linkHarness>) => h.fetchCalls.filter(entry => entry.url.endsWith('/cancel'))
|
||||
|
||||
const noSecretInLogs = (h: ReturnType<typeof linkHarness>, code: string) => {
|
||||
const text = h.logs.join('\n')
|
||||
|
||||
expect(text).not.toContain(code)
|
||||
expect(text).not.toContain(pairLookup(code))
|
||||
expect(text).not.toContain('tsmp_')
|
||||
}
|
||||
|
||||
it('first request rejects, the second answers 201: the code path becomes ready (one lost request does not close it)', async () => {
|
||||
const h = linkHarness({ offerPlan: ['throw', 201] })
|
||||
|
||||
await h.link.enable()
|
||||
|
||||
const state = await h.link.pairBegin()
|
||||
|
||||
expect(state.pairing!.codePath).toBe('pending')
|
||||
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
||||
expect(h.offers()).toHaveLength(2)
|
||||
expect(h.retrySleeps).toEqual([500])
|
||||
// Same record both times, and each try has its own timeout signal.
|
||||
expect(lookupOf(h, 1)).toBe(lookupOf(h, 0))
|
||||
expect(h.offers()[1].init.signal).not.toBe(h.offers()[0].init.signal)
|
||||
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
|
||||
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer published'])
|
||||
noSecretInLogs(h, state.pairing!.code)
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('two 503 answers, then 201: ready on the third try', async () => {
|
||||
const h = linkHarness({ offerPlan: [503, 503, 201] })
|
||||
|
||||
await h.link.enable()
|
||||
await h.link.pairBegin()
|
||||
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
||||
expect(h.offers()).toHaveLength(3)
|
||||
expect(h.retrySleeps).toEqual([500, 1500])
|
||||
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('a lost answer followed by 409 means the first request landed: ready, and no cancel is sent without a token', async () => {
|
||||
const h = linkHarness({ offerPlan: ['throw', 409] })
|
||||
|
||||
await h.link.enable()
|
||||
await h.link.pairBegin()
|
||||
await until(() => h.events.some(event => event.state.pairing?.codePath === 'ready'))
|
||||
expect(h.offers()).toHaveLength(2)
|
||||
expect((await h.link.pairCancel()).pairing).toBeNull()
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(cancels(h)).toEqual([])
|
||||
await h.link.stop()
|
||||
expect(cancels(h)).toEqual([])
|
||||
})
|
||||
|
||||
it('422 is final: one try, unavailable', async () => {
|
||||
const h = linkHarness({ offerPlan: [422, 201] })
|
||||
|
||||
await h.link.enable()
|
||||
|
||||
const state = await h.link.pairBegin()
|
||||
|
||||
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(h.offers()).toHaveLength(1)
|
||||
expect(h.retrySleeps).toEqual([])
|
||||
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 1 try'])
|
||||
noSecretInLogs(h, state.pairing!.code)
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('four failing tries: unavailable only after the fourth, never earlier', async () => {
|
||||
const h = linkHarness({ offerPlan: ['throw', 503, 429, 'throw', 201], holdRetries: true })
|
||||
|
||||
await h.link.enable()
|
||||
|
||||
const state = await h.link.pairBegin()
|
||||
|
||||
for (let tries = 1; tries <= 3; tries++) {
|
||||
await until(() => h.offers().length === tries && h.heldRetries.length === 1)
|
||||
// Still being tried: the window keeps saying "hazırlanıyor…".
|
||||
expect((await h.link.state()).pairing!.codePath).toBe('pending')
|
||||
expect(h.events.some(event => event.state.pairing?.codePath === 'unavailable')).toBe(false)
|
||||
h.heldRetries.shift()!()
|
||||
}
|
||||
|
||||
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||
expect(h.offers()).toHaveLength(4)
|
||||
expect(h.retrySleeps).toEqual([500, 1500, 3000])
|
||||
expect(h.heldRetries).toEqual([])
|
||||
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual(['pair offer failed after 4 tries'])
|
||||
noSecretInLogs(h, state.pairing!.code)
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('cancelling the pairing stops the tries; a record that lands after the cancel is cancelled on the gateway', async () => {
|
||||
const h = linkHarness({ offerPlan: ['throw', 201], holdRetries: true })
|
||||
|
||||
await h.link.enable()
|
||||
await h.link.pairBegin()
|
||||
await until(() => h.offers().length === 1 && h.heldRetries.length === 1)
|
||||
expect((await h.link.pairCancel()).pairing).toBeNull()
|
||||
// The wait ends after the pairing is gone: no further request.
|
||||
h.heldRetries.shift()!()
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(h.offers()).toHaveLength(1)
|
||||
expect(cancels(h)).toEqual([])
|
||||
expect(h.logs.filter(line => line.startsWith('pair '))).toEqual([])
|
||||
|
||||
// A NEW pairing is not confused with the old one, and a request in flight when pairing ends is cleaned up.
|
||||
let release: () => void = () => undefined
|
||||
|
||||
h.state.offerGate = new Promise<void>(resolve => {
|
||||
release = resolve
|
||||
})
|
||||
await h.link.pairBegin()
|
||||
await until(() => h.offers().length === 2)
|
||||
await h.link.pairCancel()
|
||||
release()
|
||||
await until(() => cancels(h).length === 1)
|
||||
expect(cancels(h)[0].init.headers.Authorization).toBe(`Bearer tsmp_${'c'.repeat(43)}`)
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('pair.republish publishes the running pairing again: new request, same code, same QR, same expiry', async () => {
|
||||
const h = linkHarness({ offerPlan: [422] })
|
||||
|
||||
await h.link.enable()
|
||||
|
||||
const begun = (await h.link.pairBegin()).pairing!
|
||||
|
||||
await until(() => h.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||
expect(h.offers()).toHaveLength(1)
|
||||
|
||||
const again = (await h.link.pairRepublish()).pairing!
|
||||
|
||||
expect(again).toMatchObject({ code: begun.code, qr: begun.qr, expiresAt: begun.expiresAt, securityCode: begun.securityCode, codePath: 'pending' })
|
||||
await until(() => h.events.at(-1)?.state.pairing?.codePath === 'ready')
|
||||
expect(h.offers()).toHaveLength(2)
|
||||
expect(lookupOf(h, 1)).toBe(lookupOf(h, 0))
|
||||
expect(JSON.parse(h.offers()[1].init.body)).toEqual(JSON.parse(h.offers()[0].init.body))
|
||||
|
||||
// Nothing to do while ready (or pending), and nothing without a pairing.
|
||||
expect((await h.link.pairRepublish()).pairing).toMatchObject({ code: begun.code, codePath: 'ready' })
|
||||
expect(h.offers()).toHaveLength(2)
|
||||
|
||||
// The code on screen still pairs the phone.
|
||||
const claim = await call((await h.link.state()).port as number, 'POST', '/aiturk/pair/claim', {
|
||||
ca: h.vault.value.lanTls.cert,
|
||||
body: { code: begun.code, device_name: 'Pixel' }
|
||||
})
|
||||
|
||||
expect(claim.status).toBe(200)
|
||||
await until(() => h.events.some(event => event.type === 'paired'))
|
||||
expect((await h.link.pairRepublish()).pairing).toBeNull()
|
||||
expect(h.offers()).toHaveLength(2)
|
||||
await h.link.stop()
|
||||
})
|
||||
|
||||
it('pair.republish after four lost requests: a 409 on its first try is our own record (ready); after a 422 it is not', async () => {
|
||||
const lost = linkHarness({ offerPlan: ['throw', 'throw', 'throw', 'throw', 409] })
|
||||
|
||||
await lost.link.enable()
|
||||
await lost.link.pairBegin()
|
||||
await until(() => lost.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||
expect(lost.offers()).toHaveLength(4)
|
||||
await lost.link.pairRepublish()
|
||||
await until(() => lost.events.at(-1)?.state.pairing?.codePath === 'ready')
|
||||
expect(lost.offers()).toHaveLength(5)
|
||||
await lost.link.stop()
|
||||
expect(cancels(lost)).toEqual([])
|
||||
|
||||
const refused = linkHarness({ offerPlan: [422, 409] })
|
||||
|
||||
await refused.link.enable()
|
||||
await refused.link.pairBegin()
|
||||
await until(() => refused.events.some(event => event.state.pairing?.codePath === 'unavailable'))
|
||||
await refused.link.pairRepublish()
|
||||
await until(() => refused.offers().length === 2 && refused.events.at(-1)?.state.pairing?.codePath === 'unavailable')
|
||||
await refused.link.stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('publishPairOffer / cancelPairOffer', () => {
|
||||
const input = { code: 'ABCDEFGH', addresses: [] as string[], port: 47321, fingerprint: 'f'.repeat(43), relay: RELAY_IDS[0] }
|
||||
const good = { offerId: '0f8fad5b-d9cb-469f-a165-70867728950e', cancelToken: `tsmp_${'c'.repeat(43)}` }
|
||||
|
||||
function plan(outcomes: (number | 'throw')[]) {
|
||||
const sleeps: number[] = []
|
||||
const logs: string[] = []
|
||||
let calls = 0
|
||||
|
||||
const fetcher = async () => {
|
||||
const outcome = outcomes[calls++] ?? 201
|
||||
|
||||
if (outcome === 'throw') {
|
||||
throw new Error('fetch failed')
|
||||
}
|
||||
|
||||
return { status: outcome, json: async () => good }
|
||||
}
|
||||
|
||||
const options = {
|
||||
sleep: async (ms: number) => {
|
||||
sleeps.push(ms)
|
||||
},
|
||||
log: (line: string) => logs.push(line)
|
||||
}
|
||||
|
||||
return { fetcher, options, sleeps, logs, calls: () => calls }
|
||||
}
|
||||
|
||||
it('waits 0.5 s, 1.5 s and 3 s between at most four tries', async () => {
|
||||
const p = plan(['throw', 500, 429, 'throw', 201])
|
||||
|
||||
expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull()
|
||||
expect(p.calls()).toBe(PAIR_OFFER_MAX_TRIES)
|
||||
expect(p.calls()).toBe(4)
|
||||
expect(p.sleeps).toEqual([500, 1500, 3000])
|
||||
expect(p.sleeps).toEqual([...PAIR_OFFER_RETRY_DELAYS_MS])
|
||||
expect(p.logs).toEqual(['offer failed after 4 tries'])
|
||||
})
|
||||
|
||||
it('retries a thrown error, 5xx and 429; 201 ends it', async () => {
|
||||
for (const first of ['throw', 500, 502, 503, 504, 429] as const) {
|
||||
const p = plan([first, 201])
|
||||
|
||||
expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual(good)
|
||||
expect(p.calls()).toBe(2)
|
||||
expect(p.logs).toEqual(['offer published'])
|
||||
}
|
||||
})
|
||||
|
||||
it('does not retry 422 or any other final answer; a first-try 409 is somebody else’s record', async () => {
|
||||
for (const status of [422, 400, 401, 403, 404, 409, 200, 302]) {
|
||||
const p = plan([status, 201])
|
||||
|
||||
expect(await publishPairOffer(p.fetcher, input, p.options)).toBeNull()
|
||||
expect(p.calls()).toBe(1)
|
||||
expect(p.sleeps).toEqual([])
|
||||
expect(p.logs).toEqual(['offer failed after 1 try'])
|
||||
}
|
||||
})
|
||||
|
||||
it('409 after a lost answer or a 5xx is our own record (no token); 409 after 429 is not', async () => {
|
||||
for (const first of ['throw', 502] as const) {
|
||||
const p = plan([first, 409])
|
||||
|
||||
expect(await publishPairOffer(p.fetcher, input, p.options)).toEqual({ offerId: null, cancelToken: null })
|
||||
expect(p.calls()).toBe(2)
|
||||
expect(p.logs).toEqual(['offer published'])
|
||||
}
|
||||
|
||||
const limited = plan([429, 409])
|
||||
|
||||
expect(await publishPairOffer(limited.fetcher, input, limited.options)).toBeNull()
|
||||
expect(limited.calls()).toBe(2)
|
||||
|
||||
const told = plan([409])
|
||||
|
||||
expect(await publishPairOffer(told.fetcher, input, { ...told.options, earlierTryMayHaveLanded: true })).toEqual({ offerId: null, cancelToken: null })
|
||||
})
|
||||
|
||||
it('stops as soon as the pairing is over, without a log line', async () => {
|
||||
const p = plan(['throw', 'throw', 201])
|
||||
let over = false
|
||||
|
||||
const result = await publishPairOffer(p.fetcher, input, {
|
||||
...p.options,
|
||||
cancelled: () => over,
|
||||
sleep: async ms => {
|
||||
p.sleeps.push(ms)
|
||||
over = true
|
||||
}
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(p.calls()).toBe(1)
|
||||
expect(p.logs).toEqual([])
|
||||
})
|
||||
|
||||
it('cancelPairOffer sends nothing when there is no token', async () => {
|
||||
const urls: string[] = []
|
||||
|
||||
const fetcher = async (url: string) => {
|
||||
urls.push(url)
|
||||
|
||||
return { status: 200, json: async () => ({}) }
|
||||
}
|
||||
|
||||
await cancelPairOffer(fetcher, { offerId: null, cancelToken: null })
|
||||
expect(urls).toEqual([])
|
||||
await cancelPairOffer(fetcher, good)
|
||||
expect(urls).toEqual(['https://turkservis.online/v1/aiturk/mobile-pair/cancel'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pure helpers', () => {
|
||||
it('pairing code: alphabet, normalisation, display form, lookup digest', () => {
|
||||
for (let i = 0; i < 200; i++) {
|
||||
|
||||
@@ -247,9 +247,37 @@ export interface PairFetchResponse {
|
||||
|
||||
export type PairFetch = (url: string, init: Record<string, unknown>) => Promise<PairFetchResponse>
|
||||
|
||||
/**
|
||||
* A published rendezvous record. Both fields are null when the record is known
|
||||
* to exist but its answer never reached us (an earlier try failed on the wire
|
||||
* and the next one got 409): the code path works, there is just nothing to
|
||||
* cancel — the record expires on its own after five minutes.
|
||||
*/
|
||||
export interface PairOffer {
|
||||
offerId: string
|
||||
cancelToken: string
|
||||
offerId: null | string
|
||||
cancelToken: null | string
|
||||
}
|
||||
|
||||
export const PAIR_OFFER_MAX_TRIES = 4
|
||||
/** Waits between the tries: ~0.5 s, 1.5 s, 3 s. */
|
||||
export const PAIR_OFFER_RETRY_DELAYS_MS: readonly number[] = [500, 1500, 3000]
|
||||
|
||||
export interface PairOfferOptions {
|
||||
/** Injected wait (the controller passes its own timer). Default: a real timer. */
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
delaysMs?: readonly number[]
|
||||
maxTries?: number
|
||||
/** true = the pairing this record belongs to is over: stop trying. */
|
||||
cancelled?: () => boolean
|
||||
/** "offer published" / "offer failed after N tries" only — never the code, the lookup or a token. */
|
||||
log?: (line: string) => void
|
||||
/**
|
||||
* An earlier publish for the SAME code may have reached the gateway (its
|
||||
* answer was lost), so a 409 on the very first try also means "it is there".
|
||||
*/
|
||||
earlierTryMayHaveLanded?: boolean
|
||||
/** Called when a try ended without a usable answer (thrown error / 5xx), i.e. it may have been stored. */
|
||||
onMayHaveLanded?: () => void
|
||||
}
|
||||
|
||||
function pairFetchInit(body: unknown, headers: Record<string, string> = {}) {
|
||||
@@ -263,12 +291,22 @@ function pairFetchInit(body: unknown, headers: Record<string, string> = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the rendezvous record. Returns null on any failure — the QR path
|
||||
* keeps working and the UI shows "kodla bağlanma kullanılamıyor".
|
||||
* Publish the rendezvous record. Returns null when it could not be published —
|
||||
* the QR path keeps working and the UI shows "kodla bağlanma kullanılamıyor".
|
||||
*
|
||||
* Up to four tries. A long-idle process can lose its first request on a dead
|
||||
* pooled keep-alive connection, and one lost request must not close the code
|
||||
* path for the whole five minutes:
|
||||
* - a thrown network error, 5xx and 429 are tried again;
|
||||
* - 422 and every other answer are final (the request itself is wrong);
|
||||
* - 409 after a try that failed on the wire / with 5xx means that try DID
|
||||
* land and only its answer was lost: the record exists, the code path is
|
||||
* ready (no cancel token is known for it).
|
||||
*/
|
||||
export async function publishPairOffer(
|
||||
fetcher: PairFetch,
|
||||
input: { code: string; addresses: string[]; port: number; fingerprint: string; relay?: null | string }
|
||||
input: { code: string; addresses: string[]; port: number; fingerprint: string; relay?: null | string },
|
||||
options: PairOfferOptions = {}
|
||||
): Promise<null | PairOffer> {
|
||||
const relay = isRelayId(input.relay) ? input.relay : null
|
||||
|
||||
@@ -282,34 +320,93 @@ export async function publishPairOffer(
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetcher(
|
||||
`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`,
|
||||
pairFetchInit({ lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) })
|
||||
)
|
||||
const sleep = options.sleep ?? (ms => new Promise<void>(resolve => setTimeout(resolve, ms)))
|
||||
const delays = options.delaysMs ?? PAIR_OFFER_RETRY_DELAYS_MS
|
||||
const maxTries = Math.max(1, options.maxTries ?? PAIR_OFFER_MAX_TRIES)
|
||||
const cancelled = options.cancelled ?? (() => false)
|
||||
const body = { lookup: pairLookup(input.code), urls, fp: input.fingerprint, ...(relay ? { relay } : {}) }
|
||||
let mayHaveLanded = options.earlierTryMayHaveLanded === true
|
||||
let tries = 0
|
||||
|
||||
if (response.status !== 201) {
|
||||
return null
|
||||
}
|
||||
const failed = () => {
|
||||
options.log?.(`offer failed after ${tries} ${tries === 1 ? 'try' : 'tries'}`)
|
||||
|
||||
const data = (await response.json()) as null | Record<string, unknown>
|
||||
|
||||
if (!data || typeof data.offerId !== 'string' || !UUID_RE.test(data.offerId)) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (typeof data.cancelToken !== 'string' || !CANCEL_TOKEN_RE.test(data.cancelToken)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { offerId: data.offerId, cancelToken: data.cancelToken }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const published = (offer: PairOffer) => {
|
||||
options.log?.('offer published')
|
||||
|
||||
return offer
|
||||
}
|
||||
|
||||
while (tries < maxTries) {
|
||||
if (cancelled()) {
|
||||
return null
|
||||
}
|
||||
|
||||
tries++
|
||||
|
||||
let status: number
|
||||
let data: null | Record<string, unknown> = null
|
||||
|
||||
try {
|
||||
// A fresh init per try: the timeout signal must not be shared between tries.
|
||||
const response = await fetcher(`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/offers`, pairFetchInit(body))
|
||||
|
||||
status = response.status
|
||||
|
||||
if (status === 201) {
|
||||
data = (await response.json()) as null | Record<string, unknown>
|
||||
}
|
||||
} catch {
|
||||
// No (complete) answer: the request may or may not have been stored.
|
||||
status = 0
|
||||
}
|
||||
|
||||
if (status === 201) {
|
||||
if (
|
||||
!data ||
|
||||
typeof data.offerId !== 'string' ||
|
||||
!UUID_RE.test(data.offerId) ||
|
||||
typeof data.cancelToken !== 'string' ||
|
||||
!CANCEL_TOKEN_RE.test(data.cancelToken)
|
||||
) {
|
||||
return failed()
|
||||
}
|
||||
|
||||
return published({ offerId: data.offerId, cancelToken: data.cancelToken })
|
||||
}
|
||||
|
||||
if (status === 409 && mayHaveLanded) {
|
||||
return published({ offerId: null, cancelToken: null })
|
||||
}
|
||||
|
||||
const transient = status === 0 || status === 429 || (status >= 500 && status <= 599)
|
||||
|
||||
if (!transient) {
|
||||
return failed()
|
||||
}
|
||||
|
||||
if (status !== 429) {
|
||||
mayHaveLanded = true
|
||||
options.onMayHaveLanded?.()
|
||||
}
|
||||
|
||||
if (tries < maxTries) {
|
||||
await sleep(delays[Math.min(tries - 1, delays.length - 1)] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
return cancelled() ? null : failed()
|
||||
}
|
||||
|
||||
/** Best effort; the record expires on its own after five minutes anyway. */
|
||||
/** Best effort; the record expires on its own after five minutes anyway. No token = nothing to cancel. */
|
||||
export async function cancelPairOffer(fetcher: PairFetch, offer: PairOffer): Promise<void> {
|
||||
if (!offer.cancelToken) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetcher(
|
||||
`${MOBILE_PAIR_ORIGIN}/v1/aiturk/mobile-pair/cancel`,
|
||||
@@ -1380,6 +1477,8 @@ interface PairSession {
|
||||
addresses: string[]
|
||||
port: number
|
||||
fingerprint: string
|
||||
/** A publish got no answer at least once: a later 409 for this code is our own record. */
|
||||
offerMayHaveLanded: boolean
|
||||
}
|
||||
|
||||
export function createMobileLink(deps: MobileLinkDeps) {
|
||||
@@ -1526,6 +1625,42 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
|
||||
|
||||
/**
|
||||
* Publishes (or publishes again) the rendezvous record of `current` in the
|
||||
* background; the code path stays `pending` until every try is over. The
|
||||
* code and the QR are never touched here.
|
||||
*/
|
||||
function publishOffer(current: PairSession, relayId: null | string) {
|
||||
current.codePath = 'pending'
|
||||
|
||||
const input = { code: current.code, addresses: current.addresses, port: current.port, fingerprint: current.fingerprint, relay: relayId }
|
||||
|
||||
void publishPairOffer(deps.fetch, input, {
|
||||
sleep,
|
||||
cancelled: () => session !== current,
|
||||
log: line => deps.log?.(`pair ${line}`),
|
||||
earlierTryMayHaveLanded: current.offerMayHaveLanded,
|
||||
onMayHaveLanded: () => {
|
||||
current.offerMayHaveLanded = true
|
||||
}
|
||||
}).then(offer => {
|
||||
if (session !== current) {
|
||||
// Pairing ended meanwhile; do not leave the record behind.
|
||||
if (offer) {
|
||||
void cancelPairOffer(deps.fetch, offer)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
current.offer = offer
|
||||
current.codePath = offer ? 'ready' : 'unavailable'
|
||||
emit()
|
||||
})
|
||||
}
|
||||
|
||||
/** The relay registration changed while a QR is on screen: show the current address. */
|
||||
function refreshSessionQr(data: MobileLinkStored) {
|
||||
if (!session) {
|
||||
@@ -1892,27 +2027,31 @@ export function createMobileLink(deps: MobileLinkDeps) {
|
||||
}, Math.max(0, expiresAt - now()) + 50),
|
||||
addresses,
|
||||
port,
|
||||
fingerprint
|
||||
fingerprint,
|
||||
offerMayHaveLanded: false
|
||||
}
|
||||
|
||||
session = current
|
||||
ensureRelay(data)?.start()
|
||||
|
||||
// Not awaited: the QR must not wait for the gateway.
|
||||
void publishPairOffer(deps.fetch, { code, addresses, port, fingerprint, relay: relayUrl }).then(offer => {
|
||||
if (session !== current) {
|
||||
// Pairing ended meanwhile; do not leave the record behind.
|
||||
if (offer) {
|
||||
void cancelPairOffer(deps.fetch, offer)
|
||||
}
|
||||
publishOffer(current, relayUrl)
|
||||
|
||||
return
|
||||
}
|
||||
return snapshot(data)
|
||||
}),
|
||||
|
||||
current.offer = offer
|
||||
current.codePath = offer ? 'ready' : 'unavailable'
|
||||
emit()
|
||||
})
|
||||
/**
|
||||
* "Yeniden dene" next to "Kodla bağlanma şu an kullanılamıyor": publishes
|
||||
* the rendezvous record of the RUNNING pairing again. Same code, same QR,
|
||||
* same expiry; nothing happens unless the code path is `unavailable`.
|
||||
*/
|
||||
pairRepublish: () =>
|
||||
serial(async () => {
|
||||
const data = await load()
|
||||
|
||||
if (session?.codePath === 'unavailable' && proxy?.pairingOpen() && session) {
|
||||
publishOffer(session, data.relayId)
|
||||
}
|
||||
|
||||
return snapshot(data)
|
||||
}),
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
createRelayClient,
|
||||
encodeRelayFrame,
|
||||
isRelayId,
|
||||
MOBILE_NETWORK_RETRY_DELAY_MS,
|
||||
parseRelaySession,
|
||||
registerRelaySession,
|
||||
RELAY_BACKOFF_MS,
|
||||
@@ -925,10 +926,15 @@ describe('relay client', () => {
|
||||
})
|
||||
|
||||
it('a failing registration is a fixed code (no upstream text) and is retried; renew() swaps the registration only when the new one is stored', async () => {
|
||||
const h = clientHarness({ sessions: ['throw', 429, 500, 201, 500, 201] })
|
||||
const h = clientHarness({ sessions: ['throw', 'throw', 429, 500, 201, 500, 201] })
|
||||
|
||||
h.client.start()
|
||||
await h.settle()
|
||||
// A thrown network error gets one more try 0.5 s later; until then nothing is reported as failed.
|
||||
expect(h.client.state()).toEqual({ status: 'connecting', code: null })
|
||||
h.fire(500)
|
||||
await h.settle()
|
||||
expect(h.fetches).toHaveLength(2)
|
||||
expect(h.client.state()).toEqual({ status: 'failed', code: 'register_failed' })
|
||||
expect(h.sockets).toHaveLength(0)
|
||||
h.fire(2000)
|
||||
@@ -982,6 +988,60 @@ describe('relay client', () => {
|
||||
broken.stop()
|
||||
})
|
||||
|
||||
it('registration: a thrown network error is tried once more after 0.5 s; an HTTP answer or a timeout is not', async () => {
|
||||
const h = clientHarness({ sessions: ['throw', 201] })
|
||||
const pending = h.client.ensureRegistered()
|
||||
|
||||
await h.settle()
|
||||
expect(h.fetches).toHaveLength(1)
|
||||
expect(h.timers.map(timer => timer.ms)).toEqual([MOBILE_NETWORK_RETRY_DELAY_MS])
|
||||
expect(MOBILE_NETWORK_RETRY_DELAY_MS).toBe(500)
|
||||
h.fire(500)
|
||||
expect(await pending).toBe(RELAY_IDS[0])
|
||||
expect(h.fetches).toHaveLength(2)
|
||||
expect(h.fetches[1].init.signal).not.toBe(h.fetches[0].init.signal)
|
||||
expect(h.saved).toEqual([{ credentials: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, reissued: false }])
|
||||
|
||||
// The function itself: exactly one extra try, only for a thrown error.
|
||||
const sleeps: number[] = []
|
||||
|
||||
const run = async (outcomes: (number | 'throw' | 'timeout')[]) => {
|
||||
let calls = 0
|
||||
|
||||
const result = await registerRelaySession(
|
||||
async () => {
|
||||
const outcome = outcomes[calls++] ?? 201
|
||||
|
||||
if (outcome === 'throw') {
|
||||
throw new TypeError('fetch failed')
|
||||
}
|
||||
|
||||
if (outcome === 'timeout') {
|
||||
throw new DOMException('The operation was aborted due to timeout', 'TimeoutError')
|
||||
}
|
||||
|
||||
return { status: outcome, json: async () => ({ relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }) }
|
||||
},
|
||||
{
|
||||
sleep: async ms => {
|
||||
sleeps.push(ms)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return { result, calls }
|
||||
}
|
||||
|
||||
expect(await run(['throw', 201])).toEqual({ result: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] }, calls: 2 })
|
||||
expect(await run(['throw', 'throw', 201])).toEqual({ result: { error: 'register_failed' }, calls: 2 })
|
||||
expect(await run(['throw', 429])).toEqual({ result: { error: 'rate_limited' }, calls: 2 })
|
||||
expect(sleeps).toEqual([500, 500, 500])
|
||||
expect(await run([500, 201])).toEqual({ result: { error: 'register_failed' }, calls: 1 })
|
||||
expect(await run([429, 201])).toEqual({ result: { error: 'rate_limited' }, calls: 1 })
|
||||
expect(await run(['timeout', 201])).toEqual({ result: { error: 'register_failed' }, calls: 1 })
|
||||
expect(sleeps).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('streams: opened / close, bytes copied untouched in ≤ 64 KiB frames, unknown ids ignored, stream and buffer limits', async () => {
|
||||
const h = clientHarness({ stored: { relayId: RELAY_IDS[0], hostToken: HOST_TOKENS[0] } })
|
||||
|
||||
|
||||
@@ -92,24 +92,67 @@ export type RelayFailureCode =
|
||||
| 'busy'
|
||||
| 'timeout'
|
||||
|
||||
/** `POST /sessions`; a fixed failure code instead of any upstream text. */
|
||||
export async function registerRelaySession(fetcher: RelayFetch): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
try {
|
||||
const response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: '{}',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
/** Wait before the single second try of a request whose first try never got an answer. */
|
||||
export const MOBILE_NETWORK_RETRY_DELAY_MS = 500
|
||||
|
||||
if (response.status !== 201) {
|
||||
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
|
||||
/** An aborted / timed-out request already used its whole budget: it is not tried again. */
|
||||
export function isTimeoutError(error: unknown): boolean {
|
||||
const name = (error as null | { name?: unknown })?.name
|
||||
|
||||
return name === 'TimeoutError' || name === 'AbortError'
|
||||
}
|
||||
|
||||
export interface NetworkRetryOptions {
|
||||
/** Injected wait (tests: fake clock). Default: a real timer. */
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
retryDelayMs?: number
|
||||
}
|
||||
|
||||
const realSleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
|
||||
|
||||
/**
|
||||
* `POST /sessions`; a fixed failure code instead of any upstream text.
|
||||
*
|
||||
* A process that sat idle for a long time can fail its first request on a dead
|
||||
* pooled keep-alive connection. So a THROWN network error (no HTTP answer at
|
||||
* all) is tried once more after ~0.5 s. Any HTTP answer is final: no retry.
|
||||
*/
|
||||
export async function registerRelaySession(
|
||||
fetcher: RelayFetch,
|
||||
options: NetworkRetryOptions = {}
|
||||
): Promise<RelayCredentials | { error: RelayFailureCode }> {
|
||||
const sleep = options.sleep ?? realSleep
|
||||
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
let response: RelayFetchResponse
|
||||
|
||||
try {
|
||||
response = await fetcher(`${MOBILE_RELAY_ORIGIN}${MOBILE_RELAY_PATH}/sessions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: '{}',
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(MOBILE_RELAY_FETCH_TIMEOUT_MS)
|
||||
})
|
||||
} catch (error) {
|
||||
if (attempt >= 2 || isTimeoutError(error)) {
|
||||
return { error: 'register_failed' }
|
||||
}
|
||||
|
||||
await sleep(options.retryDelayMs ?? MOBILE_NETWORK_RETRY_DELAY_MS)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
|
||||
} catch {
|
||||
return { error: 'register_failed' }
|
||||
try {
|
||||
if (response.status !== 201) {
|
||||
return { error: response.status === 429 ? 'rate_limited' : 'register_failed' }
|
||||
}
|
||||
|
||||
return parseRelaySession(await response.json()) ?? { error: 'register_failed' }
|
||||
} catch {
|
||||
return { error: 'register_failed' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +264,8 @@ export function createRelayClient(deps: RelayClientDeps) {
|
||||
let retryTimer: unknown = null
|
||||
let silenceTimer: unknown = null
|
||||
const streams = new Map<number, Stream>()
|
||||
/** Same (injectable) clock as every other wait of this client. */
|
||||
const sleep = (ms: number) => new Promise<void>(resolve => void setTimer(resolve, ms))
|
||||
|
||||
function setState(status: RelayStatus, code: null | RelayFailureCode = null) {
|
||||
if (state.status === status && state.code === code) {
|
||||
@@ -270,7 +315,7 @@ export function createRelayClient(deps: RelayClientDeps) {
|
||||
return stored
|
||||
}
|
||||
|
||||
const fresh = await registerRelaySession(deps.fetch)
|
||||
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||
|
||||
if ('error' in fresh) {
|
||||
return fresh
|
||||
@@ -626,7 +671,7 @@ export function createRelayClient(deps: RelayClientDeps) {
|
||||
* only once the new one is stored; false = nothing changed.
|
||||
*/
|
||||
async renew(): Promise<boolean> {
|
||||
const fresh = await registerRelaySession(deps.fetch)
|
||||
const fresh = await registerRelaySession(deps.fetch, { sleep })
|
||||
|
||||
if ('error' in fresh) {
|
||||
return false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "aiturk-ide",
|
||||
"productName": "AITURK IDE",
|
||||
"private": true,
|
||||
"version": "1.0.0-beta.16",
|
||||
"version": "1.0.0-beta.17",
|
||||
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||
"author": "AITURK / TurkServis",
|
||||
"repository": {
|
||||
|
||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
||||
},
|
||||
"apps/desktop": {
|
||||
"name": "aiturk-ide",
|
||||
"version": "1.0.0-beta.16",
|
||||
"version": "1.0.0-beta.17",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@assistant-ui/core": "0.2.23",
|
||||
|
||||
Reference in new issue
Block a user