packfile: recover when a multi-pack-index names a removed pack

A geometric repack writes a new pack and multi-pack-index and then
deletes the packs the new one subsumes.  A process still using the
previous MIDX keeps seeing a removed pack listed as the owner of some
objects.  Since a MIDX attributes each object to exactly one pack, such
an object is served only through its recorded owner; if that owner was
just removed, find_pack_entry() cannot serve it -- the MIDX lookup routes
to the missing pack, and the regular pack fallback deliberately skips
every MIDX-covered pack, so a surviving copy in another covered pack
(e.g. a kept base pack) is never consulted.

Unlike the ordinary "a pack's .idx is mapped but its .pack is gone"
race, the second read does not rescue us.  Reloading the on-disk pack set
does not reload the borrowed, cached MIDX (freeing it under the code that
caches the "struct multi_pack_index *" would be a use-after-free), so the
stale MIDX keeps routing to the removed pack and the surviving copy stays
hidden behind the covered-pack skip.  cat-file, rev-list and pack-objects
can thus all spuriously fail with "unable to read object".

Teach find_pack_entry() to recover.  The MIDX lookup now returns a
tri-state, distinguishing an object absent from the MIDX from one it owns
via a pack that can no longer be opened; in the latter case, once the
regular fallback has also missed, scan the MIDX's packs directly for a
surviving copy.  Because the return value is no longer a boolean, rename
fill_midx_entry() to midx_fill_entry() so callers must reckon with the
new enum rather than silently treat MIDX_FILL_OWNER_UNAVAILABLE as a hit.

Do the scan only on the second read (OBJECT_INFO_SECOND_READ): by then
the cheaper on-disk reload has run, so an object merely relocated into a
new (uncovered) pack has already been found by the regular fallback, and
only a genuine hidden duplicate reaches the rescan.  A QUICK caller that
skips the second read simply accepts the false negative, as QUICK is
designed to.

Reloading the stale MIDX would be a more complete fix but is much more
involved (the borrowers above need proper invalidation), so leave that
for later.

Assisted-by: Claude Opus 4.8 & GPT-5.6 Sol
Helped-by: Jeff King <peff@peff.net>
Signed-off-by: Elijah Newren <newren@gmail.com>
Signed-off-by: Junio C Hamano <gitster@pobox.com>
seen
Elijah Newren 2026-08-29 07:00:31 +00:00 committed by Junio C Hamano
parent 22eef58fba
commit 8f909ff4e9
6 changed files with 108 additions and 19 deletions

View File

@ -1786,7 +1786,7 @@ static int want_object_in_pack_mtime(const struct object_id *oid,
struct multi_pack_index *m = get_multi_pack_index(files->packed);
struct pack_entry e;

if (m && fill_midx_entry(m, oid, &e, NULL)) {
if (m && midx_fill_entry(m, oid, &e, NULL) == MIDX_FILL_HIT) {
want = want_object_in_pack_one(e.p, oid, exclude, found_pack, found_offset, found_mtime);
if (want != -1)
return want;

20
midx.c
View File

@ -589,23 +589,23 @@ uint32_t nth_midxed_pack_int_id(struct multi_pack_index *m, uint32_t pos)
(off_t)pos * MIDX_CHUNK_OFFSET_WIDTH);
}

int fill_midx_entry(struct multi_pack_index *m,
const struct object_id *oid,
struct pack_entry *e,
struct packed_git **bad_pack)
enum midx_fill_result midx_fill_entry(struct multi_pack_index *m,
const struct object_id *oid,
struct pack_entry *e,
struct packed_git **bad_pack)
{
uint32_t pos;
uint32_t pack_int_id;
struct packed_git *p;

if (!bsearch_midx(oid, m, &pos))
return 0;
return MIDX_FILL_MISS;

midx_for_object(&m, pos);
pack_int_id = nth_midxed_pack_int_id(m, pos);

if (prepare_midx_pack(m, pack_int_id))
return 0;
return MIDX_FILL_OWNER_UNAVAILABLE;
p = m->packs[pack_int_id - m->num_packs_in_base];

/*
@ -616,19 +616,19 @@ int fill_midx_entry(struct multi_pack_index *m,
* loaded!
*/
if (!is_pack_valid(p))
return 0;
return MIDX_FILL_OWNER_UNAVAILABLE;

if (oidset_size(&p->bad_objects) &&
oidset_contains(&p->bad_objects, oid)) {
if (bad_pack && !*bad_pack)
*bad_pack = p;
return 0;
return MIDX_FILL_MISS;
}

e->offset = nth_midxed_offset(m, pos);
e->p = p;

return 1;
return MIDX_FILL_HIT;
}

/* Match "foo.idx" against either "foo.pack" _or_ "foo.idx". */
@ -1032,7 +1032,7 @@ int verify_midx_file(struct odb_source_packed *source, unsigned flags)

nth_midxed_object_oid(&oid, m, pairs[i].pos);

if (!fill_midx_entry(m, &oid, &e, NULL)) {
if (midx_fill_entry(m, &oid, &e, NULL) != MIDX_FILL_HIT) {
midx_report(_("failed to load pack entry for oid[%d] = %s"),
pairs[i].pos, oid_to_hex(&oid));
continue;

21
midx.h
View File

@ -117,8 +117,25 @@ uint32_t nth_midxed_pack_int_id(struct multi_pack_index *m, uint32_t pos);
struct object_id *nth_midxed_object_oid(struct object_id *oid,
struct multi_pack_index *m,
uint32_t n);
int fill_midx_entry(struct multi_pack_index *m, const struct object_id *oid,
struct pack_entry *e, struct packed_git **bad_pack);
/*
* Result of looking an object up in a multi-pack-index. MIDX_FILL_HIT means
* "e was filled in"; the two miss variants distinguish an object the midx does
* not know about (MIDX_FILL_MISS) from one it does know about but whose owning
* pack we can no longer open (MIDX_FILL_OWNER_UNAVAILABLE -- the signature of a
* concurrent repack having removed that pack). A known-bad (corrupt) object
* reports MIDX_FILL_MISS but also sets *bad_pack, if provided, to the owning
* pack so the caller can tell "corrupt" apart from "absent".
*/
enum midx_fill_result {
MIDX_FILL_MISS = 0,
MIDX_FILL_HIT,
MIDX_FILL_OWNER_UNAVAILABLE,
};

enum midx_fill_result midx_fill_entry(struct multi_pack_index *m,
const struct object_id *oid,
struct pack_entry *e,
struct packed_git **bad_pack);
int midx_contains_pack(struct multi_pack_index *m,
const char *idx_or_pack_name);
int midx_layer_contains_pack(struct multi_pack_index *m,

View File

@ -17,13 +17,18 @@
static int find_pack_entry(struct odb_source_packed *store,
const struct object_id *oid,
struct pack_entry *e,
enum object_info_flags flags,
struct packed_git **bad_pack)
{
struct packfile_list_entry *l;
enum midx_fill_result midx_result = MIDX_FILL_MISS;

odb_source_prepare(&store->base, 0);
if (store->midx && fill_midx_entry(store->midx, oid, e, bad_pack))
return 1;
if (store->midx) {
midx_result = midx_fill_entry(store->midx, oid, e, bad_pack);
if (midx_result == MIDX_FILL_HIT)
return 1;
}

for (l = store->packs.head; l; l = l->next) {
struct packed_git *p = l->pack;
@ -35,6 +40,33 @@ static int find_pack_entry(struct odb_source_packed *store,
}
}

/*
* Recovery for a concurrent-repack race: a stale MIDX may still name a
* vanished owning pack even though the object survives in another pack
* the same MIDX covers. The regular fallback above skips MIDX-covered
* packs, and repreparing the on-disk pack set does not reload the
* borrowed, cached MIDX, so scan its packs directly for the survivor.
*
* Do this only on the second read, by which point repreparing packs has
* already had a chance to find an object merely relocated into a new,
* uncovered pack; only a genuine hidden duplicate reaches here.
*/
if (midx_result == MIDX_FILL_OWNER_UNAVAILABLE &&
(flags & OBJECT_INFO_SECOND_READ)) {
struct multi_pack_index *m = store->midx;
uint32_t i;

for (i = 0; i < m->num_packs + m->num_packs_in_base; i++) {
struct packed_git *p;

if (prepare_midx_pack(m, i))
continue;
p = nth_midxed_pack(m, i);
if (p && packfile_fill_entry(p, oid, e, bad_pack))
return 1;
}
}

return 0;
}

@ -57,7 +89,7 @@ static enum odb_read_status odb_source_packed_read_object_info(struct odb_source
if (flags & OBJECT_INFO_SECOND_READ)
odb_source_prepare(source, ODB_PREPARE_FLUSH_CACHES);

if (!find_pack_entry(packed, oid, &e, &bad_pack)) {
if (!find_pack_entry(packed, oid, &e, flags, &bad_pack)) {
/*
* The lookup may have failed because the object is known to be
* corrupt in one of the packfiles. Report the object as
@ -105,7 +137,7 @@ static int odb_source_packed_read_object_stream(struct odb_read_stream **out,
struct odb_source_packed *packed = odb_source_packed_downcast(source);
struct pack_entry e;

if (!find_pack_entry(packed, oid, &e, NULL))
if (!find_pack_entry(packed, oid, &e, 0, NULL))
return -1;

return packfile_read_object_stream(out, oid, e.p, e.offset);
@ -611,7 +643,7 @@ static int odb_source_packed_freshen_object(struct odb_source *source,
timesp = &times;
}

if (!find_pack_entry(packed, oid, &e, NULL))
if (!find_pack_entry(packed, oid, &e, 0, NULL))
return 0;
if (e.p->is_cruft)
return 0;

View File

@ -82,7 +82,7 @@ static int read_midx_file(const char *object_dir, const char *checksum,
for (i = 0; i < m->num_objects; i++) {
nth_midxed_object_oid(&oid, m,
i + m->num_objects_in_base);
fill_midx_entry(m, &oid, &e, NULL);
midx_fill_entry(m, &oid, &e, NULL);

printf("%s %"PRIu64"\t%s\n",
oid_to_hex(&oid), e.offset, e.p->pack_name);

View File

@ -1393,4 +1393,44 @@ test_expect_success 'pack.preferBitmapTips interprets patterns as hierarchy' '
)
'

test_expect_success 'lookup recovers object whose midx-owning pack was removed' '
test_when_finished "rm -fr repo" &&
git init repo &&
(
cd repo &&

# "keep" ends up only in the big pack; "dup" is deliberately
# placed in two packs so the midx has to choose an owner.
test_commit keep &&
echo duplicated-content >dup &&
git add dup &&
git commit -m dup &&
dup_oid=$(git rev-parse HEAD:dup) &&

# Roll every object, including dup, into a single big pack.
git repack -adq &&

# Build a second, "moderate" pack that also contains dup, so dup
# now lives in two packs that the midx will cover.
moderate=$(echo "$dup_oid" |
git pack-objects --quiet $objdir/pack/pack) &&

# Attribute dup to the moderate pack in the midx.
git multi-pack-index write \
--preferred-pack="pack-$moderate.idx" &&

# Simulate a concurrent "git repack" retiring the moderate pack:
# its files disappear, but the now-stale midx still names it as
# the owner of dup. A valid copy of dup survives in the big pack.
rm -f $objdir/pack/pack-$moderate.* &&

# The midx routes the lookup to the deleted pack, and the regular
# pack fallback skips midx-covered packs, so without recovery dup
# would appear missing even though it is physically present.
echo blob >expect &&
git cat-file -t "$dup_oid" >actual &&
test_cmp expect actual
)
'

test_done