diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java index 50f04b2c901..2e0f91d0efb 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolver.java @@ -96,7 +96,7 @@ private CompletableFuture> resolveOne(DnsQuestionContext ctx, Dn }); future.handle((unused0, unused1) -> { // Maybe cancel the timeout scheduler. - ctx.cancel(); + ctx.setComplete(); return null; }); return future; @@ -112,7 +112,7 @@ CompletableFuture> resolveAll(DnsQuestionContext ctx, List { assert executor.inEventLoop(); - maybeCompletePreferredRecords(future, questions, results, order, records, cause); + maybeCompletePreferredRecords(ctx, future, questions, results, order, records, cause); return null; }); } @@ -140,7 +140,8 @@ CompletableFuture> resolveAll(DnsQuestionContext ctx, List> future, + static void maybeCompletePreferredRecords(DnsQuestionContext ctx, + CompletableFuture> future, List questions, Object[] results, int order, @Nullable List records, @@ -170,6 +171,7 @@ static void maybeCompletePreferredRecords(CompletableFuture> fut // Found a successful result. assert result instanceof List; future.complete(Collections.unmodifiableList((List) result)); + ctx.setComplete(); return; } @@ -181,6 +183,7 @@ static void maybeCompletePreferredRecords(CompletableFuture> fut unknownHostException.addSuppressed((Throwable) result); } future.completeExceptionally(unknownHostException); + ctx.setComplete(); } public DnsCache dnsCache() { diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java index a7147526fb5..35c8440d570 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/DnsQuestionContext.java @@ -29,6 +29,7 @@ final class DnsQuestionContext { private final long queryTimeoutMillis; private final CompletableFuture whenCancelled = new CompletableFuture<>(); private final ScheduledFuture scheduledFuture; + private boolean complete; DnsQuestionContext(EventExecutor executor, long queryTimeoutMillis) { this.queryTimeoutMillis = queryTimeoutMillis; @@ -48,12 +49,21 @@ boolean isCancelled() { return whenCancelled.isCompletedExceptionally(); } - void cancel() { + void cancelScheduler() { if (!scheduledFuture.isDone()) { scheduledFuture.cancel(false); } } + void setComplete() { + complete = true; + cancelScheduler(); + } + + boolean isCompleted() { + return complete; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -65,6 +75,7 @@ public boolean equals(Object o) { final DnsQuestionContext that = (DnsQuestionContext) o; return queryTimeoutMillis == that.queryTimeoutMillis && + complete == that.complete && whenCancelled.equals(that.whenCancelled) && scheduledFuture.equals(that.scheduledFuture); } @@ -74,6 +85,7 @@ public int hashCode() { int result = whenCancelled.hashCode(); result = 31 * result + scheduledFuture.hashCode(); result = 31 * result + (int) queryTimeoutMillis; + result = 31 * result + (complete ? 1 : 0); return result; } diff --git a/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java b/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java index 712c5189927..431d9e993c0 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java +++ b/core/src/main/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolver.java @@ -16,6 +16,7 @@ package com.linecorp.armeria.internal.client.dns; +import static com.google.common.base.MoreObjects.firstNonNull; import static com.google.common.collect.ImmutableList.toImmutableList; import java.util.List; @@ -28,6 +29,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.common.util.AbstractUnwrappable; @@ -60,15 +62,18 @@ private static List validateSearchDomain(List searchDomains) { return null; } String normalized = searchDomain; - if (searchDomain.charAt(0) != '.') { - normalized = '.' + searchDomain; + if (searchDomain.charAt(0) == '.') { + // Remove the leading dot. + normalized = searchDomain.substring(1); } - if (searchDomain.charAt(searchDomain.length() - 1) != '.') { + if (normalized.charAt(normalized.length() - 1) != '.') { + // Add a trailing dot. normalized += '.'; } try { // Try to create a sample DnsQuestion to validate the search domain. - DnsQuestionWithoutTrailingDot.of("localhost" + normalized, DnsRecordType.A); + DnsQuestionWithoutTrailingDot.of("localhost." + normalized, + DnsRecordType.A); return normalized; } catch (Exception ex) { logger.warn("Ignoring a malformed search domain: '{}'", searchDomain, ex); @@ -96,6 +101,11 @@ private CompletableFuture> resolve0(DnsQuestionContext ctx, new IllegalStateException("resolver is closed already")); } + if (ctx.isCompleted()) { + // Other DnsRecordType may be resolved already. + return UnmodifiableFuture.completedFuture(ImmutableList.of()); + } + return unwrap().resolve(ctx, question).handle((records, cause) -> { if (records != null) { return UnmodifiableFuture.completedFuture(records); @@ -126,14 +136,18 @@ static final class SearchDomainQuestionContext { private final DnsQuestion original; private final String originalName; private final List searchDomains; + private final int numSearchDomains; private final boolean shouldStartWithHostname; + private final boolean hasTrailingDot; private volatile int numAttemptsSoFar; SearchDomainQuestionContext(DnsQuestion original, List searchDomains, int ndots) { this.original = original; this.searchDomains = searchDomains; + numSearchDomains = searchDomains.size(); originalName = original.name(); - shouldStartWithHostname = hasNDots(originalName, ndots); + hasTrailingDot = originalName.endsWith("."); + shouldStartWithHostname = hasNDots(originalName, ndots) || hasTrailingDot || numSearchDomains == 0; } private static boolean hasNDots(String hostname, int ndots) { @@ -157,32 +171,46 @@ DnsQuestion nextQuestion() { @Nullable private DnsQuestion nextQuestion0() { final int numAttemptsSoFar = this.numAttemptsSoFar; - if (numAttemptsSoFar == 0) { - if (originalName.endsWith(".") || searchDomains.isEmpty()) { - return original; - } - if (shouldStartWithHostname) { - return newQuestion(originalName + '.'); + + final int searchDomainPos; + if (shouldStartWithHostname) { + searchDomainPos = numAttemptsSoFar - 1; + } else { + if (numAttemptsSoFar == numSearchDomains) { + // The last attempt uses the hostname itself. + searchDomainPos = -1; } else { - return newQuestion(originalName + searchDomains.get(0)); + searchDomainPos = numAttemptsSoFar; } } - int nextSearchDomainPos = numAttemptsSoFar; - if (shouldStartWithHostname) { - nextSearchDomainPos = numAttemptsSoFar - 1; + if (searchDomainPos >= numSearchDomains) { + // No more search domain to try. + return null; } - if (nextSearchDomainPos < searchDomains.size()) { - return newQuestion(originalName + searchDomains.get(nextSearchDomainPos)); - } - if (nextSearchDomainPos == searchDomains.size() && !shouldStartWithHostname) { - return newQuestion(originalName + '.'); + final String searchDomain; + // -1 means the hostname itself. + if (searchDomainPos == -1) { + searchDomain = null; + } else { + searchDomain = searchDomains.get(searchDomainPos); } - return null; + + return newQuestion(searchDomain); } - private DnsQuestion newQuestion(String hostname) { + private DnsQuestion newQuestion(@Nullable String searchDomain) { + searchDomain = firstNonNull(searchDomain, ""); + final String hostname; + if (hasTrailingDot) { + if (searchDomain.isEmpty()) { + return original; + } + hostname = originalName + searchDomain; + } else { + hostname = originalName + '.' + searchDomain; + } // - As the search domain is validated already, DnsQuestionWithoutTrailingDot should not raise an // exception. // - Use originalName to delete the cache value in RefreshingAddressResolver when the DnsQuestion diff --git a/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java b/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java index 68fb9c5d951..504f94cbf53 100644 --- a/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/client/TrailingDotAddressResolverTest.java @@ -26,6 +26,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import com.google.common.collect.ImmutableMap; @@ -43,10 +45,13 @@ import io.netty.handler.codec.dns.DefaultDnsResponse; import io.netty.handler.codec.dns.DnsRecord; import io.netty.handler.codec.dns.DnsSection; +import io.netty.resolver.ResolvedAddressTypes; import io.netty.util.ReferenceCountUtil; class TrailingDotAddressResolverTest { + private static final Logger logger = LoggerFactory.getLogger(TrailingDotAddressResolverTest.class); + @RegisterExtension static ServerExtension server = new ServerExtension() { @Override @@ -77,13 +82,15 @@ void resolve() throws Exception { new DefaultDnsQuestion("foo.com.", A), new DefaultDnsResponse(0).addRecord(ANSWER, newAddressRecord("foo.com.", "127.0.0.1"))), dnsRecordCaptor)) { - try (ClientFactory factory = ClientFactory.builder() - .domainNameResolverCustomizer(b -> { - b.serverAddresses(dnsServer.addr()); - b.searchDomains("search.domain1", "search.domain2"); - b.ndots(3); - }) - .build()) { + try (ClientFactory factory = + ClientFactory.builder() + .domainNameResolverCustomizer(b -> { + b.resolvedAddressTypes(ResolvedAddressTypes.IPV4_ONLY); + b.serverAddresses(dnsServer.addr()); + b.searchDomains("search.domain1", "search.domain2"); + b.ndots(3); + }) + .build()) { final BlockingWebClient client = WebClient.builder() .factory(factory) @@ -93,6 +100,7 @@ void resolve() throws Exception { "http://foo.com.:" + server.httpPort() + '/'); assertThat(response.contentUtf8()).isEqualTo("Hello, world!"); assertThat(dnsRecordCaptor.records).isNotEmpty(); + logger.debug("Captured DNS records: {}", dnsRecordCaptor.records); dnsRecordCaptor.records.forEach(record -> { assertThat(record.name()).isEqualTo("foo.com."); }); diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java index 4328afba130..27139316b1d 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/DefaultDnsResolverTest.java @@ -199,13 +199,16 @@ void shouldWaitForPreferredRecords() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List fooDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "1.2.3.4")); final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, null); assertThat(future).isNotCompleted(); - maybeCompletePreferredRecords(future, questions, results, 0, fooDnsRecord, null); + assertThat(ctx.isCompleted()).isFalse(); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, fooDnsRecord, null); assertThat(future).isCompletedWithValue(fooDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -216,12 +219,15 @@ void shouldWaitForPreferredRecords_ignoreErrorsOnPrecedence() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, null); assertThat(future).isNotCompleted(); - maybeCompletePreferredRecords(future, questions, results, 0, null, new AnticipatedException()); + assertThat(ctx.isCompleted()).isFalse(); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, null, new AnticipatedException()); assertThat(future).isCompletedWithValue(barDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -232,10 +238,12 @@ void resolvePreferredRecordsFirst() { DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.AAAA)); final Object[] results = new Object[questions.size()]; + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); final List fooDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "1.2.3.4")); - maybeCompletePreferredRecords(future, questions, results, 0, fooDnsRecord, null); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, fooDnsRecord, null); // The preferred question is resolved. Don't need to wait for the questions. assertThat(future).isCompletedWithValue(fooDnsRecord); + assertThat(ctx.isCompleted()).isTrue(); } @Test @@ -249,11 +257,14 @@ void shouldWaitForPreferredRecords_allQuestionsAreFailed() { final List barDnsRecord = ImmutableList.of(newAddressRecord("foo.com.", "2001:db8::1")); // Should not complete `future` and wait for the first result. final AnticipatedException barCause = new AnticipatedException(); - maybeCompletePreferredRecords(future, questions, results, 1, barDnsRecord, barCause); + final DnsQuestionContext ctx = new DnsQuestionContext(CommonPools.workerGroup().next(), Long.MAX_VALUE); + maybeCompletePreferredRecords(ctx, future, questions, results, 1, barDnsRecord, barCause); assertThat(future).isNotCompleted(); + assertThat(ctx.isCompleted()).isFalse(); final AnticipatedException fooCause = new AnticipatedException(); - maybeCompletePreferredRecords(future, questions, results, 0, null, fooCause); + maybeCompletePreferredRecords(ctx, future, questions, results, 0, null, fooCause); assertThat(future).isCompletedExceptionally(); + assertThat(ctx.isCompleted()).isTrue(); assertThatThrownBy(future::join) .isInstanceOf(CompletionException.class) .cause() diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java index c17c86c1978..d32bdec4615 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainDnsResolverTest.java @@ -18,6 +18,7 @@ import static org.assertj.core.api.Assertions.assertThat; +import java.net.UnknownHostException; import java.util.List; import java.util.Queue; import java.util.concurrent.CompletableFuture; @@ -76,6 +77,46 @@ public void close() {} DnsQuestionWithoutTrailingDot.of("example.com", "example.com.armeria.io.", DnsRecordType.A), DnsQuestionWithoutTrailingDot.of("example.com", "example.com.armeria.dev.", DnsRecordType.A), DnsQuestionWithoutTrailingDot.of("example.com", "example.com.", DnsRecordType.A)); - context.cancel(); + context.cancelScheduler(); + } + + @Test + void unknownHostnameEndingWithDot() { + final ByteArrayDnsRecord record = new ByteArrayDnsRecord("example.com", DnsRecordType.A, + 1, new byte[] { 10, 0, 1, 1 }); + final Queue questions = new LinkedBlockingQueue<>(); + final DnsResolver mockResolver = new DnsResolver() { + + @Override + public CompletableFuture> resolve(DnsQuestionContext ctx, DnsQuestion question) { + questions.add(question); + if ("trailing-dot.com.armeria.dev.".equals(question.name())) { + return UnmodifiableFuture.completedFuture(ImmutableList.of(record)); + } else { + return UnmodifiableFuture.exceptionallyCompletedFuture(new UnknownHostException()); + } + } + + @Override + public void close() {} + }; + + final List searchDomains = ImmutableList.of("armeria.io", "armeria.dev"); + final DnsQuestionContext context = new DnsQuestionContext(eventLoop.get(), 10000); + final DnsQuestionWithoutTrailingDot question = + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", DnsRecordType.A); + final SearchDomainDnsResolver resolver = new SearchDomainDnsResolver(mockResolver, searchDomains, 2); + final CompletableFuture> result = resolver.resolve(context, question); + + assertThat(result.join()).contains(record); + assertThat(questions).hasSize(3); + assertThat(questions).containsExactly( + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.", + DnsRecordType.A), + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.armeria.io.", + DnsRecordType.A), + DnsQuestionWithoutTrailingDot.of("trailing-dot.com.", "trailing-dot.com.armeria.dev.", + DnsRecordType.A)); + context.cancelScheduler(); } } diff --git a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java index d966239ae7c..1ea65cb7452 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/client/dns/SearchDomainTest.java @@ -39,15 +39,15 @@ void startsWithHostname(String hostname, int ndots) { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of(hostname, DnsRecordType.A); // Since `SearchDomainDnsResolver` normalizes search domains while being initialized, // `SearchDomainQuestionContext` should use a normalized search domain that - // starts and ends with a dot for testing . - final List searchDomains = ImmutableList.of(".armeria.io.", ".armeria.com.", - ".armeria.org.", ".armeria.dev."); + // ends with a dot for testing . + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, ndots); final DnsQuestion firstQuestion = ctx.nextQuestion(); assertThat(firstQuestion.name()).isEqualTo(hostname + '.'); for (String searchDomain : searchDomains) { final DnsQuestion expected = - DnsQuestionWithoutTrailingDot.of(hostname, hostname + searchDomain, + DnsQuestionWithoutTrailingDot.of(hostname, hostname + '.' + searchDomain, DnsRecordType.A); assertThat(ctx.nextQuestion()).isEqualTo(expected); } @@ -58,12 +58,12 @@ void startsWithHostname(String hostname, int ndots) { @ParameterizedTest void endsWithHostname(String hostname, int ndots) { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of(hostname, DnsRecordType.A); - final List searchDomains = ImmutableList.of(".armeria.io.", ".armeria.com.", - ".armeria.org.", ".armeria.dev."); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, ndots); for (String searchDomain : searchDomains) { final DnsQuestion expected = - DnsQuestionWithoutTrailingDot.of(hostname, hostname + searchDomain, + DnsQuestionWithoutTrailingDot.of(hostname, hostname + '.' + searchDomain, DnsRecordType.A); assertThat(ctx.nextQuestion()).isEqualTo(expected); } @@ -73,12 +73,64 @@ void endsWithHostname(String hostname, int ndots) { assertThat(ctx.nextQuestion()).isNull(); } + @Test + void trailingDot() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("foo.com.", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 3); + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("foo.com."); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("foo.com.", "foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + assertThat(ctx.nextQuestion()).isNull(); + } + + @Test + void nonTrailingDot_shouldStartWithHostnameByNdots() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("bar.foo.com", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 2); + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("bar.foo.com."); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("bar.foo.com", "bar.foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + assertThat(ctx.nextQuestion()).isNull(); + } + + @Test + void nonTrailingDot_shouldNotStartWithHostnameByNdots() { + final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("bar.foo.com", DnsRecordType.A); + final List searchDomains = ImmutableList.of("armeria.io.", "armeria.com.", + "armeria.org.", "armeria.dev."); + final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, searchDomains, 3); + for (String searchDomain : searchDomains) { + final DnsQuestion expected = + DnsQuestionWithoutTrailingDot.of("bar.foo.com", "bar.foo.com." + searchDomain, + DnsRecordType.A); + assertThat(ctx.nextQuestion()).isEqualTo(expected); + } + final DnsQuestion firstQuestion = ctx.nextQuestion(); + assertThat(firstQuestion.name()).isEqualTo("bar.foo.com."); + assertThat(ctx.nextQuestion()).isNull(); + } + @Test void noSearchDomain() { final DnsQuestion original = DnsQuestionWithoutTrailingDot.of("foo.com", DnsRecordType.A); final SearchDomainQuestionContext ctx = new SearchDomainQuestionContext(original, ImmutableList.of(), 2); - assertThat(ctx.nextQuestion()).isEqualTo(original); + assertThat(ctx.nextQuestion()).isEqualTo( + DnsQuestionWithoutTrailingDot.of("foo.com", "foo.com.", DnsRecordType.A)); assertThat(ctx.nextQuestion()).isNull(); } }