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

+340 -4
View File
@@ -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++) {