Skip to content

hts_rename_over() deletes its destination when the source is missing - #784

Merged
xroche merged 7 commits into
masterfrom
fix/rename-over-enoent
Jul 27, 2026
Merged

hts_rename_over() deletes its destination when the source is missing#784
xroche merged 7 commits into
masterfrom
fix/rename-over-enoent

Conversation

@xroche

@xroche xroche commented Jul 27, 2026

Copy link
Copy Markdown
Owner

The unlink-then-rename fallback in hts_rename_over() is there because Windows' rename() refuses an existing target, but it fired on any failed rename, ENOENT on the source included. A caller moving a temp file it never managed to write lost the destination and got HTS_FALSE back, which reads as "nothing happened". #754 unified four hand-rolled copies into this helper, so every call site inherited it.

The unlink now runs only for EEXIST, and only with a source that exists. EEXIST is the value that matters: the CRT maps ERROR_ALREADY_EXISTS there and keeps EACCES for a source another process holds, so accepting EACCES as well would have deleted the destination for a failure it had no part in, and the retry would then fail with the destination already gone. The source check is belt and braces for a CRT that reports neither. hts_rename_utf8() preserves errno across its free() calls now, since the gate reads it.

The existing call sites all derive their source's existence from a create that had to succeed first, so the bug was latent rather than live, but three of those destinations are user data: a mirrored file, a finished .wacz, and a rewritten page nothing will re-fetch. What is left of the window after this is #790.

The selftest probes what rename() does to an existing target and asserts against the regime it finds, so it runs three ways on Linux: native, then under an LD_PRELOAD rename() with Windows' shape, then under one reporting a locked source. The harness pins the expected regime per platform, so no leg can pass having exercised the other half. Seven mutants of the gate were each confirmed to fail it.

Closes #779

The unlink-then-rename fallback exists for Windows, whose rename() refuses an
existing target. It fired on any failed rename, so a caller asking to move a
temp file it never managed to write lost the destination and got HTS_FALSE
back, which reads as "nothing happened".

Gate the unlink on the failure it was added for: EACCES/EEXIST, and a source
that exists. The CRT does not promise ENOENT over EACCES when both hold, so the
source check is not redundant. hts_rename_utf8() now preserves errno across its
free() calls, which the gate depends on.

Closes #779

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
The contract said dst is never removed unless it is replaced. The retry after
the unlink can still fail, so say what actually holds and point at #790.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
xroche and others added 5 commits July 27, 2026 10:40
Review of the first commit found the gate too wide: the CRT maps
ERROR_ALREADY_EXISTS to EEXIST and reserves EACCES for a source another
process holds, so accepting EACCES would remove dst for a failure dst had no
part in, and the retry would then fail with dst already gone.

The selftest now probes what rename() does to an existing target and asserts
against the regime it finds, so the interposed legs cover the fallback and a
failure the unlink cannot fix. Follow-up in #791.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
…/rename-over-enoent

# Conflicts:
#	src/htstools.c
#	src/htstools.h
The all-or-nothing precheck was documented as a workaround for hts_rename_over
deleting its destination, which it no longer does. The loop still earns its
place: a swap stopping halfway mixes two runs' segments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Two ways the LD_PRELOAD legs broke outside a plain Linux build: ASan aborts
when a preloaded library loads ahead of its runtime, and --disable-shared
(what the MSan job uses) builds no module to preload at all.

The first is safe to waive here, the shim allocates nothing. The second is a
real skip rather than a failure, told apart from a missing module by libtool's
own dlname, so a build that should have one still fails loudly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche merged commit 95f8eba into master Jul 27, 2026
22 checks passed
xroche added a commit that referenced this pull request Jul 27, 2026
Every other network crawl runs under a deadline through local-crawl.sh. This
test drives its own FTP fixture directly, so a wedge would hang the job until
CI cancels it and discards the log (#796).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche deleted the fix/rename-over-enoent branch July 27, 2026 18:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

hts_rename_over() deletes the destination when the source is missing

1 participant