Repository navigation
feat(nameresolving): add system resolver worker pool and factory - #3384
richard-ramos wants to merge 5 commits into
Conversation
95975d2 to
b11fb8e
Compare
|
/review |
|
🔍 OpenCodeReview found 2 issue(s) in this PR.
Model: |
4df7d94 to
7aa78f4
Compare
7aa78f4 to
c06c767
Compare
| checkTrackers() | ||
|
|
||
| asyncTest "switch resolves localhost with a caller-managed system resolver": | ||
| let resolver = newNameResolver(rng()) |
There was a problem hiding this comment.
should be
let resolver = SystemResolver.new(rng)
in test files here, since they are testing this type.
There was a problem hiding this comment.
newNameResolver() should be used only in test that test libp2p/nameresolving.nim file
| @@ -0,0 +1,359 @@ | |||
| # SPDX-License-Identifier: Apache-2.0 OR MIT | |||
| const | ||
| MaxSystemAddresses = 100 | ||
| IPv4MappedMarkerByte = 0xff'u8 | ||
|
|
There was a problem hiding this comment.
most of methods in this file seem to raise instead of returning LPResult - which should be norm in this repo. is intentional?
|
|
||
| proc newNameResolver*(rng: Rng, nameServers: seq[TransportAddress]): DnsResolver = | ||
| ## Use only the supplied DNS servers. An empty list produces no DNS results. | ||
| DnsResolver.new(nameServers, rng) | ||
|
|
||
| proc newNameResolver*(rng: Rng): SystemResolver = | ||
| ## Resolve IPs through the OS. Discovers TXT nameservers synchronously during | ||
| ## construction, which can block the calling thread while reading system config. | ||
| ## Requires threads. The caller must await stop() to release its workers. | ||
| SystemResolver.new(rng) |
There was a problem hiding this comment.
seems unnecessary, its more descriptive and nicer if downstream users use exactly the type they would like, becasue newNameResolver actually hides what implementation is used. even in our code in libary is better to use Type.new:
DnsResolver.new(nameServers, rng)
or
SystemResolver.new(rng)
nit DnsResolver.new(rng, nameServers) (rng as first arg if possible)
| @@ -0,0 +1,182 @@ | |||
| # SPDX-License-Identifier: Apache-2.0 OR MIT | |||
| # Copyright (c) Status Research & Development GmbH | |||
There was a problem hiding this comment.
tests need better organization:
- test that test
SystemResolvershould be intest_system_resolvefile - test that test
DnsResolvershould be intest_dns_resolverfile
both of above should/must not use Switch for testing. they should be 100% pure type unit tests. and they should cover all invariant of these types.
since they are testing same interface NameResolver there should be suite_name_resolver that will assert basic invariant of any NameResolver (similarly to transport testes, KadStore tests, AutoTLS state tests)
test_system_resolve = suite_name_resolver + all specific tests for SystemResolver (if any, but i guess there is)
test_dns_resolver = suite_name_resolver + all specific tests for DnsResolver (if any)
^
both of above will just factor method to pass concrete NameResolver implementation to suite_name_resolver.
with all tests from above there is not need to extensively use Switch tests, as all invariant of these types are full testes in isolation.
there could be tests that test integration of NameResolver + other components in Switch. but those should only cover that integration parts, it should not cover part that test if NameResolver are working as expected because those test are already tested in tests above.
|
|
||
| import std/[sequtils, strutils, tables] | ||
| import chronos, chronos/threadsync | ||
| import ../../libp2p/nameresolving |
There was a problem hiding this comment.
in theory test_{filename}.nim should only import and test libp2p2/{filename}.nim type. it should be unit test for that type.
| requestSignal, responseSignal: ThreadSignalPtr | ||
| when defined(libp2p_testing): | ||
| workerEntered, workerRelease: ThreadSignalPtr | ||
| workerGateReleased: Atomic[bool] |
There was a problem hiding this comment.
initially usage of libp2p_testing was envisioned only for exporting. because conditional logic can have behavior that may lead to unexpected outcomes when flag libp2p_testing is not used. note that tests only test when libp2p_testing flag is used.
in order to avoid such complexity, libp2p_testing was started to be used as export only, because worst thing that can happen (to downstream) is there is just more exported things.
note that this very similar principle as conditional feature flags, which we already agreed no to use.
- is it really necessary to have this in tests?
- if yes, could there be some new abstraction "new logic" that could be tested in isolation (for example running and maintaining workers and worker loop). so that responsibility of
SystemResolveris minimized only to providing and describing needed work, to that "new logic" that drives loop, maintains workers, synchronizes, etc..
There was a problem hiding this comment.
ideally it would be nicer to have logic specific to system_resolver here, anything related to managing threads and loop should be in different type. because when someone is looking at this file they care about what system_resolver does, and now how thread pool is managed. the later abstraction could potentially be used in other other components.
| workerEntered, workerRelease: ThreadSignalPtr | ||
| workerGateReleased: Atomic[bool] | ||
|
|
||
| PendingRequest = object |
There was a problem hiding this comment.
could async-channels be used here to avoid/minimize shared state?
https://github.com/status-im/nim-async-channels/
| proc isV4Mapped(address: TransportAddress): bool = | ||
| if address.family != AddressFamily.IPv6: | ||
| return false | ||
| for i in 0 ..< 10: | ||
| if address.address_v6[i] != 0: | ||
| return false | ||
| address.address_v6[10] == IPv4MappedMarkerByte and | ||
| address.address_v6[11] == IPv4MappedMarkerByte |
There was a problem hiding this comment.
I think we could use isIPv6/4 from utils/ipaddr.nim, or add these helpers there
| let workerRelease = shared[].workerRelease | ||
| if not workerRelease.isNil: | ||
| shared[].workerRelease = nil | ||
| discard shared[].workerEntered.fireSync() | ||
| let waitResult = workerRelease.waitSync(2.seconds) | ||
| shared[].workerGateReleased.store(waitResult.isOk and waitResult.get()) |
There was a problem hiding this comment.
Extract this into a function to make it easier to read
| if req.domain == Domain.AF_INET6 and address.isV4Mapped(): | ||
| continue |
There was a problem hiding this comment.
Can the reverse not happen? i.e. domain.AF_INET4 and address.isV6mapped
| if req.resultsLen == MaxSystemAddresses: | ||
| break |
There was a problem hiding this comment.
why not
| if req.resultsLen == MaxSystemAddresses: | |
| break | |
| if req.resultsLen >= MaxSystemAddresses: | |
| break |
There was a problem hiding this comment.
also maybe log this? also now looking at it why is this not tested before the loop is even running?
| shared[].lock.acquire() | ||
| req.next = shared[].completedHead | ||
| shared[].completedHead = req | ||
| shared[].lock.release() |
There was a problem hiding this comment.
can we use withLock here? other places too
| deinitLock(shared[].lock) | ||
| deallocShared(shared) | ||
| raise newException( | ||
| TransportAddressError, "Failed to spawn resolver worker: " & exc.msg, exc | ||
| ) |
There was a problem hiding this comment.
This pattern exists 4 times in the code, maybe do
proc destroy(shared: ptr SharedState) =
## Release every resource `shared` owns. Safe on a partially initialized state.
if not shared[].requestSignal.isNil:
discard shared[].requestSignal.close()
if not shared[].responseSignal.isNil:
discard shared[].responseSignal.close()
deinitLock(shared[].lock)
deallocShared(shared)then use it as
shared[].requestSignal = ThreadSignalPtr.new().valueOr:
shared.destroy()
raise newException(TransportAddressError, "Failed to create request signal: " & error)
shared[].responseSignal = ThreadSignalPtr.new().valueOr:
shared.destroy()
raise newException(TransportAddressError, "Failed to create response signal: " & error)
...
except ResourceExhaustedError as exc:
workers.setLen(started)
shared[].running.store(false)
discard shared[].requestSignal.fireSync()
joinThreads(workers)
shared.destroy()
raise newException(TransportAddressError, "Failed to spawn resolver worker: " & exc.msg, exc)| ): Future[seq[TransportAddress]] {. | ||
| async: (raises: [CancelledError, TransportAddressError]) | ||
| .} = | ||
| if self.stopped or address.len == 0 or address.len > 255 or '\0' in address: |
There was a problem hiding this comment.
| if self.stopped or address.len == 0 or address.len > 255 or '\0' in address: | |
| if self.stopped or not address.isValid(): |
| var expected = ord(RequestState.Pending) | ||
| discard req.state.compareExchange(expected, ord(RequestState.Discarded)) |
There was a problem hiding this comment.
Huh... can this be a settle template?
proc settle(req: ptr SharedRequest, state: RequestState): bool {.discardable.} =
## Move `req` from Pending to `state`. False if it already left Pending.
var expected = ord(RequestState.Pending)
req.state.compareExchange(expected, ord(state))| var expected = ord(RequestState.Pending) | |
| discard req.state.compareExchange(expected, ord(RequestState.Discarded)) | |
| proc cancellation(udata: pointer) {.gcsafe, raises: [].} = | |
| req.settle(RequestState.Discarded) |
Can be used in the worker loop too
req.settle(RequestState.Done)
except TransportAddressError:
req.settle(RequestState.Discarded)| fut.cancelCallback = cancellation | ||
| self.pending.add(PendingRequest(req: req, fut: fut)) | ||
|
|
||
| shared[].lock.acquire() |
| return | ||
|
|
||
| let shared = self.shared | ||
| shared[].lock.acquire() |
Summary
Resolve names through the host's OS configuration, including hosts files, localhost, and container DNS. Blocking lookups run on a lazily created worker pool; TXT queries use the system DNS servers discovered by the parent PR.
Add
newNameResolver: omitting the server list selectsSystemResolver; supplying a list selectsDnsResolver, including an empty list that returns no DNS results. Cover family filtering, concurrent lookups, cancellation, asynchronous shutdown, concurrent stops, reopening, and real switch integration.Impact on Library Users
Import
libp2p/nameresolving.newNameResolver(rng)returns aSystemResolver. The caller must await itsstop()after stopping dependent switches. Workers start lazily on first use;start()permits reuse after shutdown.newNameResolver(rng, servers)returns aDnsResolver, which requires no lifecycle calls. Passing either resolver towithNameResolverleaves ownership with the caller. A TXT resolver supplied toSystemResolver.newalso remains caller-managed: the system resolver does not start or stop it.The system resolver requires threads, and shutdown waits asynchronously for active OS lookups. Unless a TXT resolver is supplied, construction discovers TXT nameservers synchronously and can block the calling thread while reading system configuration.
Stack
2 of 3 — merge in this order:
master.feat/system-name-resolver.feat/system-resolver-backend.All three PRs are drafts. Review this diff against its parent branch.
Validation
System-resolver suite, including caller-managed lifecycle and real switch DNS dialing: 13 checks passed after rebasing.
References
Closes #3378. Continues the resolver implementation from #3114, originally authored by @nate-staked.