@@ -51,6 +51,14 @@ public sealed class IPScanner(IPScannerOptions options)
5151 /// </summary>
5252 private const int MaxConcurrentNeighborVerifications = 4 ;
5353
54+ /// <summary>
55+ /// Maximum age of the neighbor table snapshot that tells if the entry of a host was already
56+ /// <see cref="NeighborState.Reachable" /> before its probes (see
57+ /// <see cref="WasNeighborReachableBeforeAsync" />): the minimum time Windows keeps an entry reachable (reachable
58+ /// time 30 s, randomized to 15 to 45 s).
59+ /// </summary>
60+ private static readonly TimeSpan MaxNeighborSnapshotAgeBeforeProbes = TimeSpan . FromSeconds ( 15 ) ;
61+
5462 // Snapshot of the neighbor table (IP address -> entry), see GetNeighborEntryAsync. Replaced, never changed.
5563 // Snapshot and the time it was read (before reading) in one object, so they are always read together without a
5664 // lock. Only a read of the table takes the semaphore.
@@ -249,15 +257,13 @@ await ScanHostAsync(host.ipAddress, portScanParallelOptions, networkInterfaces,
249257 private async Task ScanHostAsync ( IPAddress ipAddress , ParallelOptions portScanParallelOptions ,
250258 List < NetworkInterfaceInfo > networkInterfaces , CancellationToken cancellationToken )
251259 {
252- // Neighbor entry before the probes are sent (from the cached snapshot, read before this host started): a
253- // Reachable entry is not resolved again by the probes, so after them it doesn't prove an answer (see
254- // IPScannerNeighborCheck.Check)
260+ // Neighbor entry before the probes are sent: a Reachable entry is not resolved again by the probes, so after
261+ // them it doesn't prove an answer (see IPScannerNeighborCheck.Check)
255262 var isNeighborCheckEnabled = options . ARPEnabled && IsInLocalSubnet ( ipAddress , networkInterfaces ) ;
256263
257264 var wasNeighborReachableBefore = isNeighborCheckEnabled &&
258- ( await GetNeighborEntryAsync ( ipAddress , DateTime . MinValue , false ,
259- cancellationToken ) . ConfigureAwait ( false ) ) ? . State ==
260- NeighborState . Reachable ;
265+ await WasNeighborReachableBeforeAsync ( ipAddress , cancellationToken )
266+ . ConfigureAwait ( false ) ;
261267
262268 // Completed by the port scan when the first port answers (open or refused), so the host is found before
263269 // all ports are finished
@@ -690,7 +696,7 @@ private async Task UpdateWebPageAsync(IPAddress ipAddress, IPScannerHostInfo hos
690696 // host it contains the router), an unreachable host only reads it if it was not read yet.
691697 if ( IsInLocalSubnet ( ipAddress , networkInterfaces ) )
692698 macAddress = ( await GetNeighborEntryAsync ( ipAddress , discoveredAt , false , cancellationToken )
693- . ConfigureAwait ( false ) ) ? . MACAddress . ToString ( ) ?? string . Empty ;
699+ . ConfigureAwait ( false ) ) . entry ? . MACAddress . ToString ( ) ?? string . Empty ;
694700
695701 // Check if it is the local mac
696702 if ( string . IsNullOrEmpty ( macAddress ) )
@@ -735,19 +741,21 @@ private async Task UpdateWebPageAsync(IPAddress ipAddress, IPScannerHostInfo hos
735741 /// <see cref="NeighborState.Incomplete" /> / <see cref="NeighborState.Stale" /> in an older snapshot).
736742 /// </param>
737743 /// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
738- /// <returns>Neighbor entry or null.</returns>
739- private async Task < NeighborInfo > GetNeighborEntryAsync ( IPAddress ipAddress , DateTime discoveredAt ,
740- bool requireReachable , CancellationToken cancellationToken )
744+ /// <returns>Neighbor entry or null, and the time the snapshot it is from was read .</returns>
745+ private async Task < ( NeighborInfo entry , DateTime timestamp ) > GetNeighborEntryAsync ( IPAddress ipAddress ,
746+ DateTime discoveredAt , bool requireReachable , CancellationToken cancellationToken )
741747 {
742748 // Cached snapshot, no lock needed: an entry, or no newer entry since the snapshot was read after the host
743749 // answered. With the scope id if it was entered (e.g. "fe80::1%3" and "fe80::1%7" are two hosts on different
744750 // interfaces), see NeighborTable.GetNeighborMap.
745- if ( TryGetCachedNeighborEntry ( _neighborTable , ipAddress , discoveredAt , requireReachable , out var entry ) )
746- return entry ;
751+ var snapshot = _neighborTable ;
752+
753+ if ( TryGetCachedNeighborEntry ( snapshot , ipAddress , discoveredAt , requireReachable , out var entry ) )
754+ return ( entry , snapshot . Timestamp ) ;
747755
748756 // Hosts that finish in the meantime share the next read - wait without holding the lock, so lookups that are
749757 // answered from the cache never wait for it
750- var wait = _neighborTable . Timestamp + MinNeighborTableReadInterval - DateTime . UtcNow ;
758+ var wait = snapshot . Timestamp + MinNeighborTableReadInterval - DateTime . UtcNow ;
751759
752760 if ( wait > TimeSpan . Zero )
753761 await Task . Delay ( wait , cancellationToken ) . ConfigureAwait ( false ) ;
@@ -758,8 +766,10 @@ private async Task<NeighborInfo> GetNeighborEntryAsync(IPAddress ipAddress, Date
758766 try
759767 {
760768 // Read by another host in the meantime
761- if ( TryGetCachedNeighborEntry ( _neighborTable , ipAddress , discoveredAt , requireReachable , out entry ) )
762- return entry ;
769+ snapshot = _neighborTable ;
770+
771+ if ( TryGetCachedNeighborEntry ( snapshot , ipAddress , discoveredAt , requireReachable , out entry ) )
772+ return ( entry , snapshot . Timestamp ) ;
763773
764774 // Timestamp before reading the table, so entries created while reading are covered by the next read
765775 var timestamp = DateTime . UtcNow ;
@@ -786,14 +796,34 @@ private async Task<NeighborInfo> GetNeighborEntryAsync(IPAddress ipAddress, Date
786796
787797 _neighborTable = new NeighborTableSnapshot ( entries , timestamp ) ;
788798
789- return _neighborTable . Entries . GetValueOrDefault ( ipAddress ) ;
799+ return ( entries . GetValueOrDefault ( ipAddress ) , timestamp ) ;
790800 }
791801 finally
792802 {
793803 _neighborTableSemaphore . Release ( ) ;
794804 }
795805 }
796806
807+ /// <summary>
808+ /// Checks if the neighbor entry of a host was already <see cref="NeighborState.Reachable" /> before its probes
809+ /// are sent (see <see cref="IPScannerNeighborCheck.Check" />), from the cached snapshot. A snapshot older than
810+ /// <see cref="MaxNeighborSnapshotAgeBeforeProbes" /> doesn't tell: the entry (e.g.
811+ /// <see cref="NeighborState.Stale" /> in the snapshot) may have been confirmed by other traffic since and still
812+ /// be Reachable from it, also if the host is gone - it is treated as reachable, so a Reachable entry after the
813+ /// probes is verified.
814+ /// </summary>
815+ /// <param name="ipAddress">IP address of the host (in a local subnet, see <see cref="IsInLocalSubnet" />).</param>
816+ /// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
817+ /// <returns>True if the entry was reachable before the probes, or if this is not known.</returns>
818+ private async Task < bool > WasNeighborReachableBeforeAsync ( IPAddress ipAddress , CancellationToken cancellationToken )
819+ {
820+ var ( entry , timestamp ) = await GetNeighborEntryAsync ( ipAddress , DateTime . MinValue , false , cancellationToken )
821+ . ConfigureAwait ( false ) ;
822+
823+ return entry ? . State == NeighborState . Reachable ||
824+ timestamp < DateTime . UtcNow - MaxNeighborSnapshotAgeBeforeProbes ;
825+ }
826+
797827 /// <summary>
798828 /// Looks up an entry in a snapshot of the neighbor table, see <see cref="GetNeighborEntryAsync" />.
799829 /// </summary>
@@ -822,13 +852,16 @@ private static bool TryGetCachedNeighborEntry(NeighborTableSnapshot snapshot, IP
822852 /// <see cref="MaxConcurrentNeighborVerifications" /> at the same time).
823853 /// </summary>
824854 /// <param name="ipAddress">IP address of the host (in a local subnet, see <see cref="IsInLocalSubnet" />).</param>
825- /// <param name="wasReachableBefore">The entry was already reachable before the probes were sent.</param>
855+ /// <param name="wasReachableBefore">
856+ /// The entry was already reachable before the probes were sent, or this is not known (see
857+ /// <see cref="WasNeighborReachableBeforeAsync" />).
858+ /// </param>
826859 /// <param name="cancellationToken">Token to monitor for cancellation requests.</param>
827860 /// <returns>True if the host is reachable.</returns>
828861 private async Task < bool > IsNeighborAsync ( IPAddress ipAddress , bool wasReachableBefore ,
829862 CancellationToken cancellationToken )
830863 {
831- var entry = await GetNeighborEntryAsync ( ipAddress , DateTime . UtcNow , true , cancellationToken )
864+ var ( entry , _ ) = await GetNeighborEntryAsync ( ipAddress , DateTime . UtcNow , true , cancellationToken )
832865 . ConfigureAwait ( false ) ;
833866
834867 // Gateways (computed once per scan, like the roles of the hosts)
0 commit comments