Skip to content

Feature: IP Scanner / Port Scanner: bug fixes, faster and more reliable scans - #3653

Merged
mergify[bot] merged 34 commits into
mainfrom
feature/improve-scanners
Oct 23, 2026
Merged

mergify[bot] merged 34 commits into
mainfrom
feature/improve-scanners

Conversation

@BornToBeRoot

Copy link
Copy Markdown
Owner

Review of the IP Scanner and Port Scanner (bugs, behaviour, performance), fixed one commit per finding.

Bug fixes (IP Scanner + Port Scanner)

  • A scan ends exactly once (completed, canceled or failed): the tab no longer gets stuck when canceled right before the scan starts or on an unexpected error. "Canceled by user" and "hostnames could not be resolved" are no longer replaced by "No reachable hosts / open ports found".
  • Port probe: only a refused connection is closed, no answer is timed out (before every error was closed); connections are closed with a reset, so open ports no longer pile up TIME_WAIT sockets and exhaust the local ports.
  • Closed ports are reported immediately: Windows repeated the connection request after a reset and reported it after ~2 s (= the new default timeout, so closed ports showed as timed out). SYN retransmissions are disabled per socket (SIO_TCP_INITIAL_RTO, RTO = timeout); new Attempts setting (default 1) to retry ports without answer on unreliable networks.
  • Overlapping inputs are scanned once (e.g. 10.0.0.1; 10.0.0.0/24, also Ping Monitor), different hostnames of one IP are kept; ports are scanned once (80; 1-100).
  • Very large inputs: confirmation above 65,536 IP addresses (Port Scanner: hosts × ports), refused above 16,777,216 instead of running out of memory (/1).
  • IP ranges across 128.0.0.0 are accepted by the validator.
  • Port Scanner: sorting by IP address with multiple hosts crashed the app; results are sorted by IP and port.
  • ICMP buffer limited to 65500 bytes (IP Scanner, Ping Monitor, Traceroute) - above, every host was "unreachable".

IP Scanner

  • ICMP Attempts were ignored (first lost ping = host down).
  • A host that blocks ICMP but refuses a port (TCP reset) is detected as up.
  • NetBIOS column sort / visibility, Copy menu visibility, Copy > Status copies Up/Down.
  • Custom commands: {{Hostname}} (from DNS/NetBIOS, i.e. from the network) is only used if it's a valid hostname (command injection, e.g. & with cmd.exe /c); Edit/Delete need a selection.
  • MAC address: neighbor table read once per scan (refresh only when needed) instead of per host, only for hosts in a local subnet; own IPv6 / link-local addresses. Resolve MAC address enabled by default for new installations (as documented).
  • NetBIOS: only the answer of the scanned host is accepted; response without MAC block keeps the names.

Performance

  • New defaults: timeouts 4000 → 2000 ms, IP Scanner 128 hosts × 16 ports (all default ports at once; max 256 × 32), Port Scanner 4 × 128. Settings still on an old default are migrated. Empty /24: ~9 s instead of ~50 s.
  • The first hosts / ports of a scan are started one after another (10 ms / 1 ms) instead of ~1400 connection requests at once, afterwards without delay.
  • Port probe and NetBIOS without exceptions (SocketAsyncEventArgs) - the app no longer lags while scanning with a debugger attached.
  • Host range expansion without parallel bag + full sort, allocation-free IP compare, export in the background (in the order shown in the grid).

Docs: settings (defaults, ranges, attempts, threads/firewall note), context menu, result, keyboard shortcuts.

To-Do

Contributing

By submitting this pull request, I confirm the following:

BornToBeRoot and others added 30 commits October 23, 2026 22:50
- The model raises ScanComplete exactly once with ScanCompleteArgs
  (IsCanceled, Exception); UserHasCanceled is removed.
- The token is no longer passed to Task.Run, so a cancel right before the
  scan starts can't skip the completion event (tab stuck).
- Unexpected exceptions (also GetNetworkInterfaces) are reported instead of
  being swallowed as "nothing found" and are logged in the view model.
- The view model resets its state in one place and unsubscribes the
  scanner's handlers; "Canceled by user" is no longer overwritten.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Catch every exception while the hosts are resolved and the ports are
  converted (Start() is fire-and-forget) and end the scan via the single
  completion path, so the tab can't get stuck.
- Append "No reachable hosts found" / "No open ports found" to a message
  from the preparation (unresolved hostnames) instead of replacing it.
- Detect subnet: try/catch/finally, so the button can't stay disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
.NET's Ping throws an ArgumentException for buffers larger than 65500
bytes, which made every host unreachable in the IP Scanner (and broke
Ping Monitor / Traceroute). The settings allowed up to 65535.

- Maximum 65500 in the IP Scanner, Ping Monitor and Traceroute settings
- Lower stored values above 65500 on upgrade
- Docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PingAsync returned on the first non-success reply, so a single lost echo
marked the host as unreachable. Try again while the request timed out
(up to the configured attempts); other states (e.g. destination host
unreachable) are final. Cancellation is handled by SendPingAsync.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Only a refused connection (TCP reset) is "Closed"; other connect errors
  (host/network unreachable, local socket errors) mean no answer and are
  "TimedOut" now. Before, every error was "Closed".
- Close with SO_LINGER 0 (reset), so open ports no longer leave sockets in
  TIME_WAIT that can exhaust the ephemeral ports on large scans.

Shared by the IP Scanner and Port Scanner.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A closed port (connection refused / TCP reset) is an answer from the host,
so the host is reachable even if ICMP is blocked and no port is open (same
as nmap's host discovery). The port scan returns every port now; closed
and timed out ports are only shown with "Show unreachable".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The time per host was dominated by the port probes: 11 default ports with
4 port threads = 3 rounds x 4000 ms = ~12 s for every host with filtered
ports (all unreachable hosts), an empty /24 took ~48 s.

IP Scanner:
- ICMP / port / NetBIOS timeout 4000 -> 2000 ms (Windows' initial SYN RTO
  is 1 s, so 2 s still allows one retransmission)
- Max. concurrent port threads 4 -> 16 (all default ports at once), max 32
- Max. concurrent host threads 64 -> 128, max 512 -> 256
  (128 x 11 = ~1.4k connections in flight, worst case 256 x 32 = 8k, half
  of the ephemeral port range)

Port Scanner:
- Timeout 4000 -> 2000 ms, Max. concurrent port threads 64 -> 128

Migration in UpgradeToLatest replaces the existing block: values still on
the default of the last release (2026.7.7) or of a pre-release of this
release are updated, IP Scanner host threads above 256 are lowered.
Docs explain what a "thread" is and that a firewall may block too many
simultaneous connections.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
HostRangeHelper (IP Scanner, Port Scanner, Ping Monitor):
- Remove duplicate IP addresses of overlapping inputs, keep the entry with
  a hostname
- Expand ranges sequentially into a pre-sized list instead of Parallel.For
  into a ConcurrentBag; the result of a single range is already sorted, so
  the sort is skipped after one linear check
- Check for cancellation while expanding (Parallel.For's state.Break()
  didn't stop it)

IPAddressHelper.CompareIPAddresses no longer allocates two arrays per call
(stackalloc + TryWriteBytes), used by every sorted IP list in the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A /1 (allowed by the input validation) created 2^31 entries and ran out of
memory. HostRangeHelper.GetHostCount counts the inputs without expanding
or resolving them (hostname = 1), the scan is then
- confirmed above 65,536 IP addresses (Port Scanner: hosts x ports)
- refused above 16,777,216 (a /8) with a status message
before any state is changed. Limits in GlobalStaticConfiguration.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PortRangeHelper.ConvertPortRangeToIntArray removes duplicates (first
occurrence, order kept), e.g. "80; 1-100" or multiple selected port
profiles scanned port 80 twice (two rows, counted twice).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MultipleHostsRangeValidator (IP Scanner, Port Scanner, Ping Monitor,
profiles, run command) compared the signed IPv4 value, so a range like
127.255.255.250 - 128.0.0.5 was rejected as "start > end". Compare the
unsigned value like HostRangeHelper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Sorting the IP address column used the built-in sort on IPAddress,
  which isn't IComparable -> exception as soon as the results contained
  more than one host. Sort with IPAddressHelper.CompareIPAddresses (+ port)
  in a Sorting handler like the IP Scanner.
- Results are sorted by IP address and port by default instead of the
  order in which the ports finished.
- The PTR lookup runs while the ports are scanned (each result awaits the
  hostname, which is needed for the grouping) instead of before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…, C6)

- "NetBIOS reachable" column: SortMemberPath pointed to a property that
  doesn't exist (no sort), now NetBIOSInfo.IsReachable; hidden with NetBIOS
  disabled like the row details.
- Copy > Hostname / MAC address / Vendor use the same visibility as their
  columns (DNS / MAC resolution OR NetBIOS).
- Copy > Status copies "Up" / "Down" (new HostReachableToStringConverter)
  instead of True / False.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… B15, T2)

- {{Hostname}} comes from the network (PTR / NetBIOS name). A name with
  e.g. "&" could run additional commands with "cmd.exe /c ...". Only a
  valid hostname (RegexHelper.HostnameOrDomainRegex) is used, otherwise an
  empty value + log warning. Docs note.
- Settings: Edit / Delete need a selected command (F2 / Del without a
  selection opened an empty dialog / asked to delete nothing); the delete
  confirmation names the command.
- Log a warning if a custom command no longer exists (old ToDo).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…B12, D2)

- NeighborTable.GetMACAddressMap() reads the table once into a map
  (IPv6 without scope id); it replaces GetMACAddress, which read and
  marshalled the whole table for every reachable host.
- The IP Scanner caches the map per scan and only reads it again on a miss
  if the snapshot is older than the time the host answered. Before a host
  can answer, its ARP/NDP entry exists, so a host is always looked up in a
  table read after its answer; unreachable hosts never trigger a read.
- Local MAC: also the own IPv6 (incl. link-local) addresses; link-local
  neighbors entered without scope id are found.
- Resolve MAC address and vendor: enabled by default for new installations
  (documented as enabled, the code default was false).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copy the results (all or selected) on the UI thread - they may still grow
while a scan runs - and run ExportManager.Export with Task.Run, so a large
export (e.g. a /16 with "show unreachable") doesn't freeze the window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- IP Scanner: complete context menu table (open in other tools, DNS lookup
  / RDAP sub items, custom commands, Add profile...), multi selection
- Result section: up/down or open/closed summary, row details, which
  results are shown, status messages, port states (open / closed / timed out)
- Keyboard shortcuts: Enter, Esc, Ctrl+T

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The size check added for B6 runs before the preparation's try/catch, so an
unexpected parse error would have been lost in the fire-and-forget Start().
Show it as status message and log it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All hosts of a "wave" started at the same moment and, because of the same
timeouts, also finished at the same moment: ~1400 connection requests within
a few milliseconds (128 hosts x 11 ports) and a progress that moved in large
jumps. StartScheduler gives every host / port the next free start time
(one per interval) and only waits until then, so the waits don't add up and
there is no delay once all concurrent slots are busy.

- IP Scanner: one host every 10 ms (128 hosts ramp up in ~1.3 s, empty /24
  ~9.3 s instead of ~8 s)
- Port Scanner: one port every 1 ms across all hosts (a larger interval
  would limit the throughput of 4 x 128 ports)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Nearly every probe of a scan ended with a caught exception: a port timeout
(OperationCanceledException), a refused / unreachable connection
(SocketException) and every host without a NetBIOS answer - ~12 per host,
~3000 for a /24. Cheap without a debugger, but with Visual Studio attached
every first-chance exception pauses the process, so the UI lagged while
scanning.

- SocketOperation: runs a SocketAsyncEventArgs operation and returns its
  SocketError as Task result (shared by PortProbe and NetBIOSResolver)
- PortProbe: Socket.ConnectAsync(e); timeout / cancellation abort it with
  Socket.CancelConnectAsync (completes with OperationAborted). Same states
  as before (Success -> Open, ConnectionRefused -> Closed, else TimedOut),
  only a cancellation by the caller still throws.
- NetBIOSResolver: SendToAsync / ReceiveFromAsync(e); timeout / cancellation
  close the socket. The response is parsed from the received bytes only.

Tested on Linux (open / refused / dropped / IPv6 / 400 parallel probes,
NetBIOS with a fake responder, no answer, cancellation): no first-chance
exceptions except for the caller's cancellation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The confirmation for a large scan is shown before IsRunning is set, so
closing the tab while the dialog was open didn't stop anything (OnClose only
stops a running scan). Confirming afterwards started the scan in the closed
tab, where it kept running in the background and was never canceled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The unsigned conversion unchecked((uint)IPv4Address.ToInt32(...)) was
written out in HostRangeHelper (twice) and the range validator. B14 came
from comparing the signed value - one helper makes it the obvious choice
for the next caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A host behind a router is never in the neighbor table (it contains the
router), so every reachable remote host missed the cached snapshot and read
the whole table again - one at a time under a lock, while up to 128 host
workers blocked thread-pool threads waiting for it.

- Look up the neighbor table only for hosts in a local subnet: IPv4 via the
  subnet mask of the local interfaces, IPv6 link-local or the same /64 as a
  local address. Remote hosts use the NetBIOS MAC address (as before, the
  neighbor table never had their MAC).
- SemaphoreSlim.WaitAsync instead of lock, so waiting hosts don't block a
  thread while another one reads the table.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The fixed interval (IP Scanner 10 ms, Port Scanner 1 ms) was a hard cap of
100 hosts / 1000 ports per second. With short timeouts and many threads the
scan can finish more (e.g. 256 host threads, 1 x 500 ms ICMP: ~500 hosts/s).

StartScheduler.GetInterval: the interval is at most duration / concurrency
(a longer one starts fewer operations per second than can finish).
- IP Scanner: duration = slowest of ICMP attempts x timeout, port scan
  (timeout x rounds) and NetBIOS timeout; defaults -> 10 ms as before
- Port Scanner: duration = timeout, concurrency = host x port threads;
  defaults -> 1 ms as before

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…duplicating (review #1, #3)

SortAndRemoveDuplicates merged all entries with the same IP address into one.
Two hostnames of one IP (e.g. web1/web2 of a load balancer) became one
entry - in Ping Monitor one monitor instead of two - and because List.Sort
is unstable, which one survived could change from run to run. The compare
also ignored the IPv6 scope id, so fe80::1%3 and fe80::1%7 (two devices on
different interfaces) were merged.

- Every different hostname (case-insensitive) is kept once; an entry
  without hostname only if there is none with a hostname (B7 unchanged:
  "10.0.0.1; 10.0.0.0/24" and "server; <its IP>" give one entry)
- Stable sort (OrderBy), entries with the same IP keep the input order
- Same bytes but different scope id = different hosts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both scanners exported the Results collection, which is in the order the
hosts / ports were finished, while the grid shows them sorted (Port Scanner
since PS2, IP Scanner by IP address or the user's sort).

- IP Scanner: export ResultsView (sorted)
- Port Scanner: export group by group (grouped by host, sorted within the
  group) - like the grid also when the user sorts by another column
- Selected results: in the same order instead of the selection order

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows retries a refused connect and reports the reset only after about
1 s, so with a port timeout below ~1000 ms closed ports become "timed out"
and the IP Scanner can't detect a host by a closed port. The default
(2000 ms) is not affected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Windows retries the connection request (SYN) after a reset like after a
lost request and reports ConnectionRefused only after ~2 s (measured:
2013-2075 ms). That's as long as the new default port timeout (2000 ms),
so closed ports were shown as timed out and the IP Scanner couldn't detect
a host by a closed port; with the old default every closed port blocked a
slot for 2 s.

SIO_TCP_INITIAL_RTO with TCP_INITIAL_RTO_NO_SYN_RETRANSMISSIONS (measured:
ConnectionRefused after 0 ms) and the probe timeout as initial RTO: the
request is sent once, a reset is reported immediately, and Windows waits
for an answer as long as the probe does. Ignored if not supported.

Docs: replace the note from 7e9c5b6 (it said ~1 s and < 1000 ms).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The StartScheduler reserved a start time for every operation, so it capped
a scan at one start per interval also after the ramp-up: the IP Scanner at
100 hosts/s, the Port Scanner at 1000 probes/s. Since closed ports are
reported immediately, a host refusing most ports took >= 65 s for 1-65535.

Only the first N starts (N = concurrency: min(hosts, host threads), for the
Port Scanner x min(ports, port threads)) are spread. After that every slot
is busy and an operation starts when another one has finished - spread like
the ramp-up, without delay.

Simulated (Task.Delay as work): 256 hosts x 4 s unchanged (9.3 s), 1024
hosts x 20 ms 1.3 s instead of >= 10.2 s, 65535 ports x 2 ms 2.1 s instead
of >= 65 s. Comments also mention the ~15.6 ms timer resolution of
Task.Delay (sub-tick intervals start a few operations per tick).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The length check only covered the name table, but ExtractNames always read
the 6 MAC bytes after it. A response without them (no statistics block)
threw IndexOutOfRangeException and the catch dropped the names that were
already parsed. Read the MAC address only if the response contains it (and
don't format an empty one, GetDefaultFormat would throw as well).

Tested with a fake responder: with MAC -> names + MAC, without -> names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BornToBeRoot and others added 3 commits October 24, 2026 00:44
ReceiveFromAsync accepted the first datagram from any sender, so a stray
or spoofed reply to the ephemeral port could be credited to the scanned
host (wrong computer name / MAC, marked reachable). Ignore datagrams from
another address and keep waiting until the timeout.

Tested: reply only from another address -> no answer (timeout), stray
reply first and the real one 100 ms later -> real one is used.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If SIO_TCP_INITIAL_RTO fails (should not happen since Windows 10 1703, but
e.g. after a wrong refactoring of the buffer layout), closed ports silently
went back to being reported after ~2 s. Log a warning once per application
run (it would fail for every probe).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…) (review #3)

Since the SYN retransmissions are disabled (closed ports are reported
immediately), a lost connection request makes an open port "timed out".
Like the ICMP Attempts: another connection request is sent if there was no
answer (timed out); open and closed ports are final, so only ports without
answer take attempts x timeout.

- Settings: IPScanner_PortScanAttempts, PortScanner_Attempts (default 1,
  1-10), next to the port timeout in both settings pages
- PortProbe.ProbeAsync(..., attempts, ...) loops over ProbeOnceAsync
- The start interval / host duration include the attempts
- Docs: tip for LAN (1) vs Wi-Fi / VPN / internet (2)

Tested: dropped port 300 ms x 1/2/3 -> 317/601/901 ms TimedOut, refused
with 3 attempts -> Closed after 1 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Copilot AI balanced review requested due to automatic review settings October 23, 2026 22:52
@github-actions github-actions Bot added this to the next-release milestone Oct 23, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota.

@mergify

mergify Bot commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

This pull request does not currently match the merge queue conditions, so it cannot be queued from here. The box comes back if it matches again.

@mergify
mergify Bot merged commit d4e2141 into main Oct 23, 2026
5 checks passed
@mergify
mergify Bot deleted the feature/improve-scanners branch October 23, 2026 23:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants