compact produces orphan INLINE tag without corresponding NAME tag, resulting in silent data loss
Summary
During metadata compaction (lfs_dir_compact), a file's INLINE data tag is written to the output but its corresponding NAME tag is silently dropped. This results in an unreachable orphan entry — the file data exists on disk but has no file name, making it permanently inaccessible. This is a silent, irreversible data loss bug.
Environment
- littlefs version: v2.4.0 (on-disk version 0x00020004)
- Platform: BL602 SoC (RISC-V), NuttX RTOS
- Flash: 4MB internal, data partition = 64KB (16 blocks × 4096 bytes)
- Metadata pair: Block 0 + Block 1 (single directory, all files are inline)
Observed Behavior
After a compaction event, the resulting metadata block contains:
INLINE(id=16, size=5, data=010000009b) ← file data present (value=1, valid CRC8)
NAME(id=16) ← MISSING — no file name for this idAll other file entries (id=1 through id=15) have correctly paired NAME + INLINE tags. Only id=16 has an INLINE without a NAME.
The CRC32 of the commit is valid, confirming this was written by littlefs code (not a hardware error or power loss).
Expected Behavior
lfs_dir_compact should either write BOTH the NAME and INLINE tags for a file, or NEITHER. It should never produce an unpaired INLINE orphan.
Reproduction Context
The directory contains 17 files (id=0 to id=16). One file at a lower id (offline_info, id=11) undergoes frequent DELETE → CREATE cycles. The affected file (work_mode, originally at id=15 or 16) has its NAME tag dropped during compaction while its INLINE tag survives.
Evidence from the flash dump
Block 0 (rev=33), commit #1 — this is a compact output:
| Offset | Tag Type | ID | Content |
|---|---|---|---|
| 96 | INLINE | 16 | 010000009b (5 bytes, valid PSM CRC8) |
| ... | ... | ... | ... |
| — | NAME(id=16) | — | NOT PRESENT |
All 13 commits in Block 0 have valid CRC32 checksums.
Block 0, commits #2–#13 show offline_info (id=11) being repeatedly deleted and recreated:
Commit #2: DELETE(id=11) + CREATE(id=11) + NAME(id=11,"offline_info")
Commit #3: INLINE(id=11, 77 bytes)
Commit #4: INLINE(id=11, 0 bytes) — clear
Commit #5: INLINE(id=11, 77 bytes)
Commit #6: DELETE(id=11)
Commit #7: CREATE(id=11) + NAME(id=11,"offline_info")
Commit #8: INLINE(id=11, 77 bytes)
Commit #9: DELETE(id=11)
Commit #10: CREATE(id=11) + NAME(id=11,"offline_info")
Commit #11: INLINE(id=11, 77 bytes)
Commit #12: DELETE(id=11)
Commit #13: CREATE(id=11) + NAME(id=11,"offline_info")Block 1 (rev=34), commit #1 — the subsequent compact of Block 0:
This compact output does NOT contain the orphan INLINE(id=16) at all — it was filtered out because it had no NAME. The file data is now permanently lost.
Root Cause Analysis
The bug is in lfs_dir_traverse (called by lfs_dir_compact) at the filter sub-traverse stage.
Mechanism
During compaction, each tag in the source block is processed by lfs_dir_traverse. For each tag, a filter sub-traverse is launched (line 784–789 in v2.4, or the equivalent stack-based logic in v2.9+) that scans forward from the current tag's disk position to check for duplicates and apply splice (CREATE/DELETE) adjustments:
// lfs.c — filter sub-traverse starts from current position
int filter = lfs_dir_traverse(lfs,
dir, off, ptag, attrs, attrcount, // ← off = current tag position
0, 0, 0, 0, 0,
lfs_dir_traverse_filter, &tag);Within lfs_dir_traverse_filter, SPLICE tags adjust the current tag's id:
if (lfs_tag_type1(tag) == LFS_TYPE_SPLICE &&
lfs_tag_id(tag) <= lfs_tag_id(*filtertag)) {
*filtertag += LFS_MKTAG(0, lfs_tag_splice(tag), 0);
}After filtering, a range check determines whether the tag should be included in the compact output:
if (!(lfs_tag_id(tag) >= begin && lfs_tag_id(tag) < end)) {
continue; // tag is dropped
}The Problem
A file's NAME tag and INLINE tag are typically at different disk positions within the source metadata block (NAME is written when the file is created; INLINE may be updated later in a subsequent commit). Because the filter sub-traverse scans forward from each tag's own position:
- NAME (at an earlier disk position) → its sub-traverse sees MORE subsequent SPLICE tags → id gets adjusted MORE
- INLINE (at a later disk position) → its sub-traverse sees FEWER subsequent SPLICE tags → id gets adjusted LESS
If the difference in splice adjustment causes NAME's final id to reach >= end while INLINE's final id remains < end, the NAME is dropped and the INLINE survives — producing an orphan.
Why this is position-dependent
The sub-traverse scans from off (current tag position) to the end of the source block, then through attrs. Tags at different positions see different subsets of SPLICE operations. This is by design for deduplication purposes, but it creates an asymmetry for tags that belong to the same logical file entry.
Trigger conditions
- A file at a lower id undergoes repeated CREATE/DELETE cycles (producing SPLICE tags)
- These SPLICE tags accumulate between the disk positions of the target file's NAME and INLINE
- The net splice adjustment seen by NAME exceeds that seen by INLINE by enough to push NAME's id past
end
In our case: offline_info (id=11) is repeatedly deleted and recreated. Its CREATE tags (which increment ids of entries with id ≥ 11) are seen by NAME(work_mode) but not by INLINE(work_mode), because INLINE is positioned after those CREATEs on disk.
Impact
- Silent data loss: The file becomes permanently inaccessible (no NAME = no file name)
- Irreversible: The next compact will discard the orphan INLINE entirely
- No error reported: The compact completes successfully with valid CRC
- Affects all versions: Confirmed by code review that v2.9.3 has identical logic in the filter sub-traverse path
Code References (v2.9.3)
lfs_dir_traverse_filter: splice adjustment logic (unchanged from v2.4)lfs_dir_traverse: filter sub-traverse start position uses currentoff(unchanged)- Range check after filter:
lfs_tag_id(tag) >= begin && lfs_tag_id(tag) < end(unchanged)
Possible Fix Direction
The fundamental issue is that lfs_dir_traverse can apply different splice adjustments to tags that belong to the same file entry (same id). Possible approaches:
- Ensure NAME and INLINE are always emitted together: If a NAME passes the range check, force its corresponding INLINE to also pass (or vice versa)
- Normalize splice adjustments: Make the adjustment independent of disk position by always scanning all SPLICE tags in the source (not just those after the current position)
- Validate compact output: After compact, verify that every INLINE has a corresponding NAME; if not, treat as corruption and retry
Source: littlefs-project/littlefs