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