diff --git a/apps/ios/Plannotator/App/SourceSession.swift b/apps/ios/Plannotator/App/SourceSession.swift index 9e3e43ae73..4ccfd081a1 100644 --- a/apps/ios/Plannotator/App/SourceSession.swift +++ b/apps/ios/Plannotator/App/SourceSession.swift @@ -64,6 +64,11 @@ final class SourceSession { private var stream: Task? private var pendingRefresh: Task? private var pickChains: [String: Task] = [:] + /// Counts thread reads as they start and this phone's changes to a thread's + /// questions (a pick drawn, the Inbox's answer to a pick or a Send), so a + /// read that started before a change cannot draw the thread as it was. + private var threadClock = 0 + private var questionsChanged: [String: Int] = [:] private var tickChains: [String: Task] = [:] /// Per guide while taps are in flight: how many, and the ticks the Inbox last confirmed. private var ticksInFlight: [String: Int] = [:] @@ -535,8 +540,17 @@ final class SourceSession { if threads[id] == nil { threadProblems[id] = .unreachable } return false } + threadClock += 1 + let started = threadClock do { let answer = try await client.thread(id) + // Read before this phone changed the thread's questions: on a slow path its answer + // can land after the pick's own, and would take the pick off the screen (the Send + // then had nothing to send). The read after the change brings both. + guard questionsChanged[id, default: 0] < started else { + threadProblems[id] = nil + return true + } threads[id] = answer.thread threadProblems[id] = nil cache.write(answer.thread, "thread-\(id)") @@ -929,6 +943,7 @@ final class SourceSession { let q = thread.messages[m].questions?.firstIndex(where: { $0.key == question.key }) else { return } change(&thread.messages[m].questions![q]) threads[id] = thread + changedQuestions(thread: id) } private func editMessage(_ message: String, thread id: String, _ change: (inout InboxMessage) -> Void) { @@ -945,6 +960,12 @@ final class SourceSession { } thread.messages[m].questions = current threads[id] = thread + changedQuestions(thread: id) + } + + private func changedQuestions(thread id: String) { + threadClock += 1 + questionsChanged[id] = threadClock } } diff --git a/apps/ios/PlannotatorUITests/ProofTests.swift b/apps/ios/PlannotatorUITests/ProofTests.swift index 8d01240088..e26367046b 100644 --- a/apps/ios/PlannotatorUITests/ProofTests.swift +++ b/apps/ios/PlannotatorUITests/ProofTests.swift @@ -80,11 +80,14 @@ final class ProofTests: ProofCase { let ticketRow = app.buttons.containing(NSPredicate(format: "label CONTAINS 'Which ticket page should I take forward'")).firstMatch scrollTo(ticketRow) ticketRow.tap() - XCTAssertTrue(element("thread-unreachable").waitForExistence(timeout: 30)) + try require(element("thread-unreachable").waitForExistence(timeout: 30), "Can't reach, with the computer out of reach") try await control.shot("thread-unreachable") + // Nothing listens while the path is down, so the app stays on "Can't reach" until Try Again. try await control.post("/proxy", ["mode": "pass"]) - app.buttons["Try Again"].firstMatch.tap() - XCTAssertTrue(app.buttons["choice-The dark one"].waitForExistence(timeout: 30)) + let tryAgain = app.buttons["Try Again"].firstMatch + try require(tryAgain.wait(for: \.isHittable, toEqual: true, timeout: 30), "Try Again on Can't reach") + tryAgain.tap() + try require(app.buttons["choice-The dark one"].waitForExistence(timeout: 30), "the thread after Try Again") back() app.swipeDown() app.swipeDown() diff --git a/apps/ios/scripts/proof.ts b/apps/ios/scripts/proof.ts index a1d7d43642..355e7fc9c0 100644 --- a/apps/ios/scripts/proof.ts +++ b/apps/ios/scripts/proof.ts @@ -529,21 +529,25 @@ async function guide(): Promise> { // ─── The door, through a proxy the test can break ─── // 'gone': what `tailscale serve` answers when nothing listens behind it (502), for M5's lock-screen answer. +// 'down': the computer out of reach. Nothing listens on the proxy's port (a call is refused, and +// the connections open over it close), and the Inbox's socket to the relay is cut. A Response +// cannot stand in for that: Bun ends one whose body errors as a clean, empty 200, which the app's +// health probe read as reachable, so the app read again every 2 s through the outage and opened +// the thread by itself the moment the path came back, under the test's Try Again tap (runs +// 37987495244, 38013707345 attempt 3, 38031955782). let proxyMode: 'pass' | 'down' | 'drop-reply' | 'gone' = 'pass'; /** Requests passing through now (the event stream among them), cut when the path goes down (M6). */ const passing = new Set(); /** Sends, and all requests, that reached the door through this proxy, the tailnet's stand-in (M6). */ let doorReplies = 0; let doorRequests = 0; -/** A response that never completes: the connection dies, as a network drop does. */ +/** The answer to a Send the Inbox applied, lost: Bun ends this as an empty 200, which the phone cannot read. */ const dropped = () => new Response(new ReadableStream({ start: (controller) => controller.error(new Error('dropped')) })); -const proxy = Bun.serve({ +const proxyOptions = { hostname: '127.0.0.1', - port: 0, idleTimeout: 120, - async fetch(request) { + async fetch(request: Request) { const url = new URL(request.url); - if (proxyMode === 'down') return dropped(); if (proxyMode === 'gone') return new Response('Bad Gateway', { status: 502 }); if (request.method === 'POST' && url.pathname.endsWith('/reply')) doorReplies++; doorRequests++; @@ -569,7 +573,10 @@ const proxy = Bun.serve({ } return new Response(forwarded.body, { status: forwarded.status, headers: forwarded.headers }); }, -}); +}; +let proxy = Bun.serve({ ...proxyOptions, port: 0 }); +/** Kept across 'down': the proxy listens on this port again when the path comes back. */ +const proxyPort = proxy.port; // ─── The simulator ─── @@ -659,14 +666,26 @@ const control = Bun.serve({ writeFileSync(planPath, text); return Response.json({ ok: true }); } - case '/proxy': + case '/proxy': { + const wasMode = proxyMode; proxyMode = body.mode as typeof proxyMode; // The tailnet going away ends the event stream that was open over it, as a dropped connection does. if (proxyMode === 'down' || proxyMode === 'gone') { for (const abort of passing) abort.abort(); passing.clear(); } + if (proxyMode === 'down') { + proxy.stop(true); + // Out of reach on every path: the phone is paired with the relay on, and an Inbox + // still on its relay socket would answer through it. + relaySocketBlocked = true; + for (const socket of inboxSockets) socket.close(1011); + } else if (wasMode === 'down') { + proxy = Bun.serve({ ...proxyOptions, port: proxyPort }); + relaySocketBlocked = false; + } return Response.json({ mode: proxyMode }); + } case '/delete-on-computer': { const answer = await windowRoute(`/api/inbox/threads/${body.thread}/delete`, { method: 'POST', body: '{}' }); return Response.json({ ok: answer.ok }, { status: answer.ok ? 200 : 500 }); @@ -684,18 +703,18 @@ const control = Bun.serve({ const link = new URL(offer.link); // The test may stand in a hostile link: a name that reads like an // address, or an address with user info in it. - link.searchParams.set('tailnet', body.tailnet ?? `127.0.0.1:${proxy.port}`); + link.searchParams.set('tailnet', body.tailnet ?? `127.0.0.1:${proxyPort}`); if (body.name) link.searchParams.set('name', body.name); link.searchParams.delete('lan'); link.searchParams.delete('fp'); - return Response.json({ url: link.toString(), address: `127.0.0.1:${proxy.port}`, name: offer.computer.name }); + return Response.json({ url: link.toString(), address: `127.0.0.1:${proxyPort}`, name: offer.computer.name }); } case '/link-message': { // An agent's message carrying a live pairing link as a markdown link. const answer = await windowRoute('/api/inbox/pairing', { method: 'POST', body: '{}' }); const offer = (await answer.json()) as { link: string }; const link = new URL(offer.link); - link.searchParams.set('tailnet', `127.0.0.1:${proxy.port}`); + link.searchParams.set('tailnet', `127.0.0.1:${proxyPort}`); const writer = await agent('Claude Code', 'claude-code'); newsAgents.push(writer); const sent = await writer.send({ project_path: projects['ledger']!, subject: 'The export diff is ready', body: `The export changed in two files. [open the diff](${link.toString()}) when you can.` }); @@ -713,11 +732,13 @@ const control = Bun.serve({ const answer = await windowRoute('/api/inbox/pairing', { method: 'POST', body: '{}' }); const offer = (await answer.json()) as { offer: { code: string } }; if (!answer.ok) throw new Error(`pairing offer: ${answer.status} ${JSON.stringify(offer)}`); - return Response.json({ address: `127.0.0.1:${proxy.port}`, code: offer.offer.code }); + return Response.json({ address: `127.0.0.1:${proxyPort}`, code: offer.offer.code }); } case '/reply': { // What the asking agent's wait_for_reply received for that thread. const result = await Promise.race([replies.get(body.thread ?? '') ?? Promise.resolve(null), sleep(90_000).then(() => null)]); + // What the phone shows when no reply came: the thread, and whether its Send went out. + if (!result) await shot('no-reply'); return Response.json(result ?? { error: 'no reply within 90 s' }, { status: result ? 200 : 504 }); } case '/devices': @@ -869,9 +890,9 @@ const control = Bun.serve({ link.searchParams.set('fp', fp.replace(/^./, fp[0] === '0' ? '1' : '0')); link.searchParams.delete('tailnet'); } else { - link.searchParams.set('tailnet', `127.0.0.1:${proxy.port}`); + link.searchParams.set('tailnet', `127.0.0.1:${proxyPort}`); } - return Response.json({ url: link.toString(), lan: `127.0.0.1:${lanPort}`, tailnet: `127.0.0.1:${proxy.port}` }); + return Response.json({ url: link.toString(), lan: `127.0.0.1:${lanPort}`, tailnet: `127.0.0.1:${proxyPort}` }); } case '/hits': // Which path carried what: Sends at the door through the tailnet's stand-in, the phone's commands and reads at the relay. @@ -989,7 +1010,11 @@ try { relay.kill(); relayProxy.stop(true); apple.close(); - if (status !== 0) writeFileSync(join(shots, 'relay-wrangler.log'), relayLog.join('')); + if (status !== 0) { + writeFileSync(join(shots, 'relay-wrangler.log'), relayLog.join('')); + // The Inbox's own log: each relay command it applied and the door's answer to it. + if (existsSync(join(dataDir, 'inbox', 'inbox.log'))) copyFileSync(join(dataDir, 'inbox', 'inbox.log'), join(shots, 'inbox.log')); + } await Promise.allSettled([claude, ...Object.values(others), ...newsAgents, ...(filesAgent ? [filesAgent] : [])].map((a) => a.close())); await fetch(`${inbox}/api/inbox/control/stop`, { method: 'POST', headers: { Authorization: `Bearer ${registry.token}` } }).catch(() => {}); if (status !== 0) {