diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt index 7e778d071493..f81392d16689 100644 --- a/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt @@ -195,20 +195,7 @@ class FakeDns( } } - // QR = 1 (Response) - // OPCODE = 0 (standard query) - // RD = 1 (Recursion Desired) - // RA = 1 (Recursion Available) - // RCODE = 0 (success) - // QR OPCODE AA TC RD RA Z RCODE - val flags = 0b1___0000__0__0__1__1_000__0000 - - return DnsMessage( - id = request.id, - flags = flags, - questions = request.questions, - answers = answers, - ) + return dnsResponse(request, answers) } private fun ResourceRecord.matches(question: Question): Boolean { @@ -336,3 +323,23 @@ class FakeDns( ) : Request } } + +fun dnsResponse( + request: DnsMessage, + answers: List, +): DnsMessage { + // QR = 1 (Response) + // OPCODE = 0 (standard query) + // RD = 1 (Recursion Desired) + // RA = 1 (Recursion Available) + // RCODE = 0 (success) + // QR OPCODE AA TC RD RA Z RCODE + val flags = 0b1___0000__0__0__1__1_000__0000 + + return DnsMessage( + id = request.id, + flags = flags, + questions = request.questions, + answers = answers, + ) +} diff --git a/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/DnsCallStateMachineTest.kt b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/DnsCallStateMachineTest.kt new file mode 100644 index 000000000000..f123821707fe --- /dev/null +++ b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/DnsCallStateMachineTest.kt @@ -0,0 +1,211 @@ +/* + * Copyright (c) 2026 OkHttp Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:OptIn(OkHttpInternalApi::class) + +package okhttp3.internal.dns + +import java.io.IOException +import java.net.InetAddress +import kotlin.test.Test +import okhttp3.Dns +import okhttp3.Protocol +import okhttp3.internal.OkHttpInternalApi + +class DnsCallStateMachineTest { + @Test + fun `happy path`() = + testDnsCallStateMachine( + request = Dns.Request(hostname = "lysine.dev"), + ) { + enqueue() + + val query0 = takeQuery("lysine.dev", TYPE_HTTPS) + val query1 = takeQuery("lysine.dev", TYPE_AAAA) + val query2 = takeQuery("lysine.dev", TYPE_A) + + respondIpAddresses( + query = query1.query, + addresses = listOf(InetAddress.getByName("1:2::3:4")), + ) + takeOnRecordsIpAddresses( + addresses = listOf(InetAddress.getByName("1:2::3:4")), + ) + + respondIpAddresses( + query = query2.query, + addresses = listOf(InetAddress.getByName("10.20.30.40")), + ) + takeOnRecordsIpAddresses( + addresses = listOf(InetAddress.getByName("10.20.30.40")), + ) + + respondServiceMetadata( + query = query0.query, + alpnIds = listOf("h2"), + ) + takeOnRecordsServiceMetadata( + last = true, + alpnIds = listOf(Protocol.HTTP_2), + ) + } + + @Test + fun `failure returned last`() = + testDnsCallStateMachine( + request = Dns.Request(hostname = "lysine.dev"), + ) { + enqueue() + + val query0 = takeQuery("lysine.dev", TYPE_HTTPS) + val query1 = takeQuery("lysine.dev", TYPE_AAAA) + val query2 = takeQuery("lysine.dev", TYPE_A) + + respondFailure( + query = query1.query, + e = IOException("boom!"), + ) + + respondIpAddresses( + query = query2.query, + addresses = listOf(InetAddress.getByName("10.20.30.40")), + ) + takeOnRecordsIpAddresses( + addresses = listOf(InetAddress.getByName("10.20.30.40")), + ) + + respondServiceMetadata( + query = query0.query, + alpnIds = listOf("h2"), + ) + takeOnRecordsServiceMetadata( + alpnIds = listOf(Protocol.HTTP_2), + ) + + takeOnFailure("boom!") + } + + /** + * Confirm that the state machine calls doesn't call any [Dns.Callback] methods until the previous + * call to a [Dns.Callback] method has returned. + * + * Usually this will be a concurrency problem, but we can exercise it just as well by making a + * re-entrant call on a single thread. + */ + @Test + fun `calls to onRecords are serialized`() = + testDnsCallStateMachine(request = Dns.Request(hostname = "lysine.dev")) { + enqueue() + + val query0 = takeQuery("lysine.dev", TYPE_HTTPS) + val query1 = takeQuery("lysine.dev", TYPE_AAAA) + val query2 = takeQuery("lysine.dev", TYPE_A) + + onNextEvent = { + respondIpAddresses( + query = query2.query, + addresses = listOf(InetAddress.getByName("10.20.30.40")), + ) + respondIpAddresses( + query = query1.query, + addresses = listOf(InetAddress.getByName("1:2::3:4")), + ) + } + respondServiceMetadata( + query = query0.query, + alpnIds = listOf("h2"), + ) + takeOnRecordsServiceMetadata( + alpnIds = listOf(Protocol.HTTP_2), + ) + takeOnRecordsIpAddresses( + last = true, + addresses = + listOf( + InetAddress.getByName("10.20.30.40"), + InetAddress.getByName("1:2::3:4"), + ), + ) + } + + /** + * The implementation still enqueues canceled queries, because that's an easy way to jump to a + * dispatcher thread to post the failures back to the callback. + */ + @Test + fun `cancel before enqueue`() = + testDnsCallStateMachine( + request = Dns.Request(hostname = "lysine.dev"), + ) { + call.cancel() + enqueue() + + val query0 = takeCancel("lysine.dev", TYPE_HTTPS) + takeQuery("lysine.dev", TYPE_HTTPS) + val query1 = takeCancel("lysine.dev", TYPE_AAAA) + takeQuery("lysine.dev", TYPE_AAAA) + val query2 = takeCancel("lysine.dev", TYPE_A) + takeQuery("lysine.dev", TYPE_A) + + respondFailure(query0.query, IOException("canceled")) + respondFailure(query1.query, IOException("canceled")) + respondFailure(query2.query, IOException("canceled")) + + takeOnFailure("canceled") + } + + /** Cancels are asynchronous and if the canceled query completes anyway, that's fine. */ + @Test + fun `cancel ignored if canceled query completes`() = + testDnsCallStateMachine( + request = Dns.Request(hostname = "lysine.dev"), + ) { + enqueue() + + val query0 = takeQuery("lysine.dev", TYPE_HTTPS) + val query1 = takeQuery("lysine.dev", TYPE_AAAA) + val query2 = takeQuery("lysine.dev", TYPE_A) + + respondIpAddresses( + query = query1.query, + addresses = listOf(InetAddress.getByName("1:2::3:4")), + ) + takeOnRecordsIpAddresses( + addresses = listOf(InetAddress.getByName("1:2::3:4")), + ) + + call.cancel() + + takeCancel("lysine.dev", TYPE_HTTPS) + takeCancel("lysine.dev", TYPE_A) + + respondIpAddresses( + query = query2.query, + addresses = listOf(InetAddress.getByName("10.20.30.40")), + ) + takeOnRecordsIpAddresses( + addresses = listOf(InetAddress.getByName("10.20.30.40")), + ) + + respondServiceMetadata( + query = query0.query, + alpnIds = listOf("h2"), + ) + takeOnRecordsServiceMetadata( + last = true, + alpnIds = listOf(Protocol.HTTP_2), + ) + } +} diff --git a/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/DnsCallStateMachineTester.kt b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/DnsCallStateMachineTester.kt new file mode 100644 index 000000000000..0e088aae3423 --- /dev/null +++ b/okhttp/src/commonTest/kotlin/okhttp3/internal/dns/DnsCallStateMachineTester.kt @@ -0,0 +1,301 @@ +@file:OptIn(OkHttpInternalApi::class) + +package okhttp3.internal.dns + +import assertk.assertThat +import assertk.assertions.hasMessage +import assertk.assertions.isEqualTo +import java.io.IOException +import java.net.InetAddress +import java.util.concurrent.LinkedBlockingDeque +import okhttp3.Dns +import okhttp3.Protocol +import okhttp3.dnsResponse +import okhttp3.internal.OkHttpInternalApi +import okhttp3.internal.dns.DnsCallStateMachineTester.Event.OnRecords +import okhttp3.internal.dns.DnsCallStateMachineTester.Event.QueryEnqueued +import okio.ByteString + +/** + * Test the DNS state machine. + * + * This has helpers to operate on the state machine: + * + * This tracks all effects from the state machine as events: creating queries, canceling queries, + * calling callbacks. + */ +fun testDnsCallStateMachine( + request: Dns.Request, + includeIPv6: Boolean = true, + includeServiceMetadata: Boolean = true, + block: DnsCallStateMachineTester.() -> Unit, +) { + val tester = DnsCallStateMachineTester(request, includeIPv6, includeServiceMetadata) + tester.block() +} + +class DnsCallStateMachineTester internal constructor( + request: Dns.Request, + includeIPv6: Boolean = true, + includeServiceMetadata: Boolean = true, +) { + private val events = LinkedBlockingDeque() + var onNextEvent: (() -> Unit)? = null + + /** Defend against re-entrant calls. */ + private var acceptCallbacks: Boolean = true + + private val transport = + object : DnsCallStateMachine.Transport { + override fun newQuery(dnsMessage: DnsMessage) = Query(dnsMessage) + + override fun enqueue(query: Query) { + postEvent(QueryEnqueued(query)) + } + + override fun cancel(query: Query) { + postEvent(Event.QueryCanceled(query)) + } + } + + val call: Dns.Call = + object : Dns.Call { + override val request: Dns.Request = request + + override fun enqueue(callback: Dns.Callback) { + stateMachine.start(callback) + } + + override fun cancel() { + stateMachine.cancel() + } + + override fun isCanceled() = stateMachine.canceled + } + + private val callback = + object : Dns.Callback { + override fun onRecords( + call: Dns.Call, + last: Boolean, + records: List, + ) { + check(call == this@DnsCallStateMachineTester.call) + check(acceptCallbacks) { "unexpected callback" } + + acceptCallbacks = false + try { + postEvent(OnRecords(last, records)) + } finally { + acceptCallbacks = true + } + } + + override fun onFailure( + call: Dns.Call, + e: IOException, + ) { + check(call == this@DnsCallStateMachineTester.call) + check(acceptCallbacks) { "unexpected callback" } + + acceptCallbacks = false + try { + postEvent(Event.OnFailure(e)) + } finally { + acceptCallbacks = true + } + } + } + + val stateMachine = + DnsCallStateMachine( + transport = transport, + call = call, + canceledException = null, + includeIPv6 = includeIPv6, + includeServiceMetadata = includeServiceMetadata, + ) + + /** Start the DNS call. */ + fun enqueue() { + check(acceptCallbacks) { "unexpected enqueue" } + + acceptCallbacks = false + try { + call.enqueue(callback) + } finally { + acceptCallbacks = true + } + } + + private fun postEvent(e: Event) { + events.put(e) + + onNextEvent?.invoke() + onNextEvent = null + } + + /** Asserts that the next-posted event is a query enqueue. */ + fun takeQuery( + hostname: String, + type: Int, + ): QueryEnqueued { + val event = events.take() as QueryEnqueued + assertThat(event.hostname).isEqualTo(hostname) + assertThat(event.type).isEqualTo(type) + return event + } + + /** Asserts that the next-posted event is a query cancel. */ + fun takeCancel( + hostname: String, + type: Int, + ): Event.QueryCanceled { + val event = events.take() as Event.QueryCanceled + assertThat(event.hostname).isEqualTo(hostname) + assertThat(event.type).isEqualTo(type) + return event + } + + /** Respond to a [TYPE_A] or [TYPE_AAAA] query with a (possibly-empty) list of IP addresses. */ + fun respondIpAddresses( + query: Query, + timeToLive: Int = 300, + addresses: List = listOf(), + ) { + stateMachine.onQueryResponse( + query, + dnsResponse( + query.dnsMessage, + addresses.map { address -> + ResourceRecord.IpAddress( + name = + query.dnsMessage.questions + .single() + .name, + timeToLive = timeToLive, + address = address, + ) + }, + ), + ) + } + + /** Respond to a [TYPE_HTTPS] query with service metadata. */ + fun respondServiceMetadata( + query: Query, + timeToLive: Int = 300, + alpnIds: List? = null, + echConfigList: ByteString? = null, + ) { + stateMachine.onQueryResponse( + query, + dnsResponse( + query.dnsMessage, + listOf( + ResourceRecord.Https( + name = + query.dnsMessage.questions + .single() + .name, + timeToLive = timeToLive, + alpnIds = alpnIds, + echConfigList = echConfigList, + ), + ), + ), + ) + } + + /** Respond to any query with a failure. */ + fun respondFailure( + query: Query, + e: IOException, + ) { + stateMachine.onQueryFailure(query, e) + } + + /** + * Asserts that the next-posted event is a call to [Dns.Callback.onRecords] with a list of IP + * addresses. + */ + fun takeOnRecordsIpAddresses( + last: Boolean = false, + addresses: List, + ): OnRecords { + val event = events.take() as OnRecords + assertThat(event.last).isEqualTo(last) + assertThat(event.records.map { (it as Dns.Record.IpAddress).address }) + .isEqualTo(addresses) + return event + } + + /** + * Asserts that the next-posted event is a call to [Dns.Callback.onRecords] with service metadata. + */ + fun takeOnRecordsServiceMetadata( + last: Boolean = false, + alpnIds: List? = null, + echConfigList: ByteString? = null, + ): OnRecords { + val event = events.take() as OnRecords + assertThat(event.last).isEqualTo(last) + + val serviceMetadata = event.records.single() as Dns.Record.ServiceMetadata + assertThat(serviceMetadata.alpnIds).isEqualTo(alpnIds) + assertThat(serviceMetadata.echConfigList).isEqualTo(echConfigList) + return event + } + + /** Asserts that the next-posted event is a call to [Dns.Callback.onFailure]. */ + fun takeOnFailure(message: String): Event.OnFailure { + val event = events.take() as Event.OnFailure + assertThat(event.e).hasMessage(message) + return event + } + + class Query( + val dnsMessage: DnsMessage, + ) + + sealed interface Event { + data class QueryEnqueued( + val query: Query, + ) : Event { + val hostname: String + get() = + query.dnsMessage.questions + .single() + .name + val type: Int + get() = + query.dnsMessage.questions + .single() + .type + } + + data class QueryCanceled( + val query: Query, + ) : Event { + val hostname: String + get() = + query.dnsMessage.questions + .single() + .name + val type: Int + get() = + query.dnsMessage.questions + .single() + .type + } + + data class OnRecords( + val last: Boolean, + val records: List, + ) : Event + + data class OnFailure( + val e: IOException, + ) : Event + } +}