Conversation
A read from an SMB share over a VPN dies mid-transfer and takes the open handle with it, while the path stays reachable either side of the drop. The machinery for that was already here — a transient read reopens the handle and resumes from the last delivered chunk — but the classifier did not count a dropped session as transient, so the retry never fired and the file failed outright, exactly as Explorer does. Add the network-mount codes, each with the reason it qualifies: mid-copy a read proves the path resolved a moment ago, so these are a session that died rather than a name that was never right. ERROR_UNEXP_NET_ERR is the one Explorer surfaces as 0x8007003B before abandoning the transfer. The POSIX equivalents go in too, for a share mounted on Linux or macOS, where there is no winerror to short-circuit on.
The classifier short-circuits on winerror because it is the specific fact, and Python's errno mapping for a dropped session can land in the permanent set. Untested, that precedence could invert without any existing case noticing. Also pins the default: an unrecognised code is not retried, and Exhausted wrapping a network drop is not retried again at a coarser level.
74ac300 to
8c30365
Compare
owenpkent
left a comment
There was a problem hiding this comment.
Reviewed the added POSIX network errno values and Windows network error codes against the existing retry decision path. No additional actionable defect found in this diff.
Windows classification still gives winerror precedence over the mapped errno, and permanent missing-file/full-disk failures retain their existing behavior. This change expands which failures qualify for the existing retry machinery without altering source offsets, hash state, staging, or the verification verdict.
Validation: 56 targeted retry tests passed on this head, and git diff --check passed. These are injected-error tests, not evidence from a live SMB/VPN disconnect. The reopen-budget finding reported on parent PR #2 remains an inherited issue for this stack.
Summary
Offloading from a NAS over a VPN, a large file failed outright. The engine already had everything needed to survive it — a transient read closes the dead handle, reopens it, seeks back to the last delivered chunk and carries on — but
retry.py's classifier did not recognise a dropped SMB session as transient, so the retry never fired.The specific gap:
_TRANSIENT_WINERRORhad 64 (ERROR_NETNAME_DELETED) and 121 (ERROR_SEM_TIMEOUT) but not 59 (ERROR_UNEXP_NET_ERR) — which is what Windows Explorer reports as0x8007003Bbefore abandoning an entire transfer. Same failure, same outcome.Why these codes qualify
The module's thesis is that the discrimination matters more than the retrying, so each addition carries its reasoning: a read mid-copy proves the path resolved a moment earlier, so these are a session that died under us rather than a name that was never right. That is what separates them from
ENOENT.Added: 53, 54, 58, 59, 71, 1231, 1232, 1450 on Windows;
ECONNRESET,ECONNABORTED,ENETRESET,ENETUNREACH,EHOSTUNREACH,ENOTCONN,EPIPE,ESTALEfor a share mounted on Linux or macOS — only ever consulted there, sinceis_transientshort-circuits onwinerror, which Windows always sets.Recovery costs one re-read of the chunk in flight. Nothing else changes: no new option, no behaviour change for a local card.
Context
The link itself was healthy — direct Tailscale path, 46 ms RTT, zero packet loss over 20 pings either side of the drop. MTU was already clamped to 1280 at the IP layer, and disabling SMB Multichannel made no difference. The fault is the session, not the data, which is why retrying is the right response.
Test plan
test_dropped_network_session_is_retriedover all nine Windows codestest_posix_network_mount_errors_are_retriedfor the errno pathtest_retry.pyruffclean