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
@@ -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)
|
||||
}),
|
||||
|
||||
Reference in new issue
Block a user