Skip to content

Fix wds2idx offsets of members with GNU long names - #6547

Merged
jantonguirao merged 2 commits into
NVIDIA:mainfrom
MaxFreedomPollard:fix/wds2idx-gnu-long-names
Oct 29, 2026
Merged

jantonguirao merged 2 commits into
NVIDIA:mainfrom
MaxFreedomPollard:fix/wds2idx-gnu-long-names

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

Category:

Bug fix (non-breaking change which fixes an issue)

Description:

The GNU tar path of tools/wds2idx.py computes the data offset of a member as (block + 1) * 512, where block comes from tar --list --block-num. GNU tar prints the block of the first header of a member. In the GNU format, a name that does not fit in the 100-byte name field is stored in a ././@LongLink header, followed by the name, placed before the member header, and that is the block GNU tar prints: simple_print_header() in GNU tar's src/list.c subtracts recent_long_name_blocks and recent_long_link_blocks from the block of the member header (the code is the same in tar 1.35).

For such members the index points at the stored name instead of the file data, so fn.readers.webdataset with index_paths returns the name, the member header and the start of the payload. GNU is GNU tar's default format and was Python's tarfile default before 3.8, so tar cf on files with long paths is enough to hit this. Three things are already correct: the index the reader infers itself (libtar opened with TAR_GNU), the tarfile fallback of wds2idx, and PAX archives. For PAX, GNU tar prints the block of the member header, which is what #4226 relied on.

The fix reads the header at the printed block and skips GNU long name and long link headers before adding the header size. For a member without a long name this adds one 512-byte read and a type flag check, about 1.6 µs per member on a 100k-member archive. TarInfo.frombuf is only called to get the size of a long name header.

Index of a GNU-format shard with a sample images/<100 x>/000001.{jpg,cls}, before and after:

before: jpg 512 10 images/xxx...   -> bytes at 512:  b'images/xxx'
after:  jpg 1536 10 images/xxx...  -> bytes at 1536: b'JPEGDATA-1'

Additional information:

Affected modules and functionalities:

  • tools/wds2idx.py: offsets in the GNU tar path (_get_data_tar), new _get_data_offset helper.
  • dali/test/python/reader/test_wds2idx.py: two new tests.

Key points relevant for the review:

_get_data_offset(). Most of the diff in _get_data_tar is the loop being re-indented under with open(self.uri, "rb"), so it is easier to read with whitespace changes hidden.

Tests:

  • Existing tests apply
  • New tests added
    • Python tests
      • test_wds2idx.py: test_wds2idx_gnu_tar_path_offsets_with_gnu_long_member_names, test_wds2idx_gnu_tar_path_offsets_with_pax_long_member_names
    • GTests
    • Benchmark
    • Other
  • N/A

The tests build an archive with names of 161 and 604 characters plus a short one, run _get_data_tar() and read each member back at the returned offset. The GNU test fails on main. The PAX test passes before and after and covers the case fixed in #4226.

What I could not run: I am on macOS, where tar is bsdtar (no --block-num), with no GPU and no DALI build. So I ran test_wds2idx with nose2 on Python 3.10, 3.12 and 3.13 against a small stand-in tar script. The script prints GNU tar's --list --block-num and --verbose --list --numeric-owner output, with block numbers computed as in src/list.c. With it, the GNU test fails on main and all four tests pass with the fix. For all members of a 100k-member GNU archive (10% with long names), _get_data_offset() matches TarInfo.offset_data. black 26 and flake8 with the repo config are clean. The new tests have not run against a real GNU tar, and I did not run the L0 suites or the reader end to end.

Checklist

Documentation

  • Existing documentation applies
  • Documentation updated
    • Docstring
    • Doxygen
    • RST
    • Jupyter
    • Other
  • N/A

DALI team only

Requirements

  • Implements new requirements
  • Affects existing requirements
  • N/A

REQ IDs: N/A

JIRA TASK: N/A

🤖 Generated with Claude Code

`tar --list --block-num` reports the block of the first header of an
archive member. In the GNU tar format, a member whose name does not fit
in the 100-byte name field of the header is preceded by a
`././@LongLink` header holding the name, and GNU tar reports the block
of that header. wds2idx assumed the reported block is the member header
itself, so for such members the index pointed at the stored name instead
of the file data and fn.readers.webdataset returned the wrong bytes.
Index inference inside the reader is not affected.

Skip the GNU long name and long link headers when computing the data
offset in the GNU tar path of wds2idx.

Signed-off-by: Max Freedom Pollard <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
@copy-pr-bot

copy-pr-bot Bot commented Oct 22, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@greptile-apps

greptile-apps Bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The change reaches GNU-tar index generation and indexed WebDataset reads.

The error messages must meet the repository’s explicit requirements before merging.

Findings

  1. P1 Archive errors lack guidance ▶

Summary

Fixes the offsets that wds2idx writes for GNU tar members with long names.

  • GNU tar indexes now point past long names to member data.

Reviews (2) · Last reviewed commit: "Reject a negative long name size in wds2..." · Reviewed by Greptile

Comment thread tools/wds2idx.py
header = archive.read(self.tar_block_size)
offset += self.tar_block_size
# the type flag is stored in the byte 156 of a tar header
if header[156:157] not in (tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Nit] GNUTYPE_LONGLINK can't precede an entry that reaches this code: _get_data_tar only passes regular files (entry_type == b"-"), and a long link target is only stored for symlinks/hardlinks. Harmless to keep for robustness, but then it's untested - the new tests only exercise GNUTYPE_LONGNAME. Either drop it or add a short comment saying it's defensive.

Comment thread tools/wds2idx.py
return offset
# skip the blocks holding the long name
size = tarfile.TarInfo.frombuf(header, tarfile.ENCODING, "surrogateescape").size
offset += (size + self.tar_block_size - 1) // self.tar_block_size * self.tar_block_size

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Major] This loop can spin forever on a crafted archive. TarInfo.frombuf accepts a negative base-256 size (Python 3.10 parses a size field of -512 as -512), so offset advances by tar_block_size and then goes back by 512: net zero, and the same header is read again on every iteration. main doesn't have this problem for such an archive (it just produces a garbage index), so this is a regression; shards are often downloaded from third parties, so a hung indexer is worth guarding against.

Suggestion: reject a negative size (raise a clear error), and/or require the offset to strictly increase; optionally also stop when offset reaches the file size, so a truncated archive can't make it read past EOF either. A small test with a hand-crafted L header with a negative size would pin this down.

TarInfo.frombuf accepts a negative base-256 size, so a crafted
`././@LongLink` header with a size of -512 moved the offset back to the
same header and _get_data_offset looped forever. Raise a ValueError for
a negative size, and for a header cut short by the end of the archive,
so every iteration either moves forward by at least one block or stops.

Add tests with hand-crafted long name headers for both cases, and note
that skipping a long link header is defensive, since only links, which
are filtered out before, have one.

Signed-off-by: Max Freedom Pollard <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Comment thread tools/wds2idx.py
archive.seek(offset)
header = archive.read(self.tar_block_size)
if len(header) < self.tar_block_size:
raise ValueError(f"Truncated tar header at offset {offset} in {self.uri}")

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.

P1 Archive errors lack guidance

[Style] The new errors in _get_data_offset omit details required by the repository’s error-message rules. The truncated-header error does not show expected versus actual byte counts. Neither error ends with a period or tells users how to fix the damaged archive.

Include the byte counts, state that sizes must be nonnegative, and suggest replacing or rebuilding the archive.

Rule Used: Error messages must name the affected parameter/operator, show expected vs. actual values, and end as a complete sentence. Not "is 5" but "got 5 channels, expected 1 or 3.". Use make_string(...) for C++ concatenation; f-strings in Python. (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor Author

Thanks, good catch. A negative long name size now raises a ValueError, and so does a header cut short by the end of the archive, so every pass either moves forward at least one block or stops. Two tests with a hand-crafted L header cover both cases. I kept the GNUTYPE_LONGLINK skip with a comment that it is defensive, since GNU tar also counts K blocks when it prints the block number.

@jantonguirao

Copy link
Copy Markdown
Collaborator

!build

@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [77269466]: BUILD STARTED

Comment on lines +101 to +103
"sample/" + "a" * 150 + ".jpg": b"\xff\xd8\xff\xe0first payload",
"short.cls": b"1",
"b" * 600 + ".jpg": b"\xff\xd8\xff\xe0second payload",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Nit: those fake JPEG magics don't help much here - tbh, I see no reason to even have JPEG files here in the first place. If we're only testing the script, why not have text files with easy to inspect payloads?

@mzient mzient left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

OK, with a slight remark about the "JPEG" files in the test archive.

@dali-automaton

Copy link
Copy Markdown
Collaborator

CI MESSAGE: [77269466]: BUILD PASSED

@jantonguirao
jantonguirao merged commit 34d0c6c into NVIDIA:main Oct 29, 2026
10 checks passed
@JanuszL

JanuszL commented Oct 29, 2026

Copy link
Copy Markdown
Contributor

@MaxFreedomPollard thank you for your contribution.

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.

5 participants