Skip to content

Three hand-rolled copies of the unlink-then-rename fallback, with two return conventions - #754

Merged
xroche merged 4 commits into
masterfrom
fix/unify-rename-fallback
Jul 27, 2026
Merged

Three hand-rolled copies of the unlink-then-rename fallback, with two return conventions#754
xroche merged 4 commits into
masterfrom
fix/unify-rename-fallback

Conversation

@xroche

@xroche xroche commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Three copies of the Windows unlink-then-rename dance had drifted into two return conventions, and only one of the three ran its paths through fconv(). Copying the wrong one next time gets you an inverted success test.

hts_rename_over() replaces all four call sites (htsback.c has two): hts_boolean return, fconv() on both paths. Converting everywhere is the conservative call. On POSIX fconv is a plain copy, and every path involved is bounded near 2 KB against an 8 KB buffer, so it cannot truncate. On Windows it is mostly defensive, since _wrename accepts forward slashes and the long-path branch normalizes them; the one input where it changes the outcome is a verbatim \\?\ path, which skips normalization and today fails on a /.

The helper lands in htsname.c rather than the htstools.c the issue suggested, because #718 is editing that file and this is not worth a conflict. htsname.c already hosts url_savename_refname_remove(), the same shape of helper, and every caller sees htsname.h through htscore.h. Moving it later is one hunk.

Equivalence is measured rather than argued. With both trees instrumented to log every (src, dst, result), the suite performs 31 rename-overs (18 from the backing engine, 11 single-file, 2 wacz) and the tuples come out identical before and after. The unlink fallback is dead code on POSIX, where rename(2) clobbers, so I exercised it under an LD_PRELOAD rename() that refuses an existing target the way Windows does: 21 refusals, every operation still succeeded, and the same probe with the fallback deleted fails every one of the five tests it covers.

The wacz test only ever did the happy rename. It now runs a cacheless second pass over a poisoned copy of the first pass's package, so repackaging has to clobber a file that is already there. 43 and 74 also assert the failure warnings stay out of the log, which is all an inverted return leaves behind at those two sites; the other two fail loudly on their own. Two bare RENAME() calls in htsback.c stay as they are: the .delayed placeholder move is deliberately non-clobbering, and the #77 backup creation would change behaviour if it started clobbering a stale .bak.

Closes #726

replace_file() (htsback.c), wacz_rename_over() (htswarc.c) and the inline
block in singlefile_rewrite_file() each worked around Windows' non-clobbering
rename, with two opposite return conventions and only one of the three
converting path separators. Copying the wrong one gets you an inverted success
test.

hts_rename_over() replaces all four call sites: hts_boolean return, fconv() on
both paths. It lands in htsname.c rather than the htstools.c the issue named,
to stay clear of an in-flight PR over that file.

The wacz test now runs a cacheless second pass over a poisoned copy of the
package the first pass wrote, so repackaging has to clobber an existing file.
43 and 74 also assert the failure warnings stay out of the log, which is all
an inverted return leaves behind at those two sites.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <xroche@gmail.com>
xroche and others added 3 commits July 27, 2026 08:29
Issue #726 asked for htstools.c; it went to htsname.c only because #718 held
the file at the time. Kept internal, not HTSEXT_API: htscore.h pulls in both
headers, so every call site reaches it either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: Xavier Roche <roche@httrack.com>
@xroche
xroche merged commit de8c0ee into master Jul 27, 2026
22 checks passed
xroche added a commit that referenced this pull request Jul 27, 2026
`back_finalize()` moves an existing file aside to `<file>.bak` before truncating it, so an aborted re-fetch can put the previous copy back. That rename was a bare `RENAME`, and Windows refuses to rename onto an existing target, so a `.bak` outliving a killed run made every later re-fetch of that URL fail the rename, take the `tmpfile = NULL` branch with nothing logged, and truncate the live file with no backup at all. The #77 guard was off for that file, silently and for good. `hts_rename_over()` (#754) unlinks and retries, which fixes it.

Worth a look, because clobbering a stale `.bak` is not free. A run killed between the rename-aside and the finalize leaves the previous complete copy in `.bak` and a partial in the live file, and the next re-fetch now overwrites the good one. It still looks like the right trade: POSIX `rename()` has behaved exactly this way since #77 landed, so the alternative is leaving Windows with a guard that stays dead until someone deletes the file by hand, and a leftover `.bak` is engine garbage that nothing advertises or ever restores. The clobber is logged now, so it is at least visible.

Any failure to create the backup is logged too, instead of quietly disabling the safety net. `htscache.c`'s static `hts_rename()` wrapper and the hand-rolled `old.zip` unlink at its only call site go the same way, which removes a copy of the unlink-then-rename idiom rather than adding a fifth.

Test 101 plants both kinds of leftover before the update pass: a stale file, which has to be clobbered so the `-M` abort can still restore the pass-1 copy, and a directory, which cannot be clobbered and has to be reported instead. Only the Windows leg arms the first half, since POSIX clobbers on its own; the second is armed everywhere. Test 37 gains a live update pass so the dead pass rotates onto an existing `old.zip`, which nothing covered.

Two older bugs on the same path came out of the review and are filed separately: #774 (the `.bak` name collides with a mirrored file of the same name, reproduced) and #775 (a failed `filecreate()` on a chunked re-fetch commits the backup away).

Closes #758

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
xroche added a commit that referenced this pull request Jul 27, 2026
…784)

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

Signed-off-by: Xavier Roche <roche@httrack.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xroche
xroche deleted the fix/unify-rename-fallback 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.

Three hand-rolled copies of the unlink-then-rename fallback, with two different return conventions

1 participant