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:
yilsemandClaude Opus 5.5 committed 2026-10-05 14:27:59 +03:00
1 parent 2ed9a6b2de
commit 58dbb4523b
13 files changed
+729 -76

No files matched your search

+178 -39
View File
@@ -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)
}),