Commit Graph

415 Commits

Author SHA1 Message Date
Christopher Haster 15cd1d29e0 Added explicit test over directory ordering
It turned out the previous version had a subtle ordering bug when names
where the same length that went unnoticed for years. And at this point
is probably baked into the on-disk format permanently.

This redesign, with a named-ordered btree, relies quite a bit more on
name ordering, so it's unlikely the same mistake would make it through
without breaking something. And sure enough this bug was unintentionally
fixed at some point.

But still, better safe than sorry. Added tests over character ordering
and length ordering. Open to more ordering tests in the future.

Found by andriyndev
2024-02-03 18:17:12 -06:00
Christopher Haster 40b926b947 Removed mid argument from lfsr_mdir_lookup*
With lfsr_mdir_t being a logical cursor pointing to a specific metadata
entry in the on-disk mdir, we don't really need the mid to be provided
on every lookup call (may have jumped the gun a bit in the attr-list
changes).

In the rare case we need to lookup unrelated mids, we call always call
lfsr_rbyd_lookup on the underlying rbyd.

This saves a little bit of code/stack:

            code          stack
  before:  33964           2896
  after:   33852 (-0.3%)   2888 (-0.3%)
2024-02-03 18:17:10 -06:00
Christopher Haster d30ed42c7f Ported/cleaned up mtree tests
Well this was quite tedious, but these tests are valuable since our
mtree has a number of hard-to-reach edge cases.

This mainly ports over to the new attr-list format for mdir commits, but
also cleans up a couple of lingering tedious TODO things:

- mtree tests now use the new mdir commit attr-list format.

- Reoriented most tests to use namelookups instead of mid lookups.

  Using mid lookups in testing is/was really fragile, since it depends
  on exactly how mids get split and moved around.

  namelookups are more robust, by design they don't care about the
  underlying mtree structure. And really, namelookups are what we care
  about in the mtree, mids are just a mechanism for mtree updates to
  work.

  We don't remove all mid checks though, we just compare against
  namelookup-derived mids when it matters (the mtree_opened tests for
  example).

- The names we use in testing have also been updated to no longer create
  invalid mtrees, i.e. names are ordered correctly and always have a
  did.

  The previous mess always risked triggering asserts with false
  positives.

- By adopting namelookup in the tests, we can actually test the on-disk
  state of fuzz testing.

  Though note we can't change names once written, without invalidating
  our mtree. This limits fuzz testing a little bit, but it's still  a
  big improvement over the previous fuzz tests.

- Dropped mtree tests that no longer really make sense.

  Mainly that the did should never be deleted, so you can never end up
  with an empty mtree, dropping the left-most mdir, etc.

  There were still a few of things lingering around.

With this, all tests are working again with the attr-list changes. Wooh.
2024-02-03 18:17:09 -06:00
Christopher Haster 3a90d1046b Reverted insert tags appending, fixed insert issues in named btrees
Changing insert tags to append seems to have broken insertion into named
btrees in a subtle way.

Consider what happens when we insert immediately before a bid that
splits the btree:
1. namelookup returns the right rbyd, with rid=-1
2. converting this into a bid gives us the left rbyd, with rid=weight
3. the commit to insert the bid ends up inserting into the left rbyd

This doesn't initially seem like an issue, both entries are effectively
the same right? Well, not when you have names. The split name tells you
what _follows_, so this unintentional flipping causes the new name to
get placed in the wrong bucket.

It's not clear if it's possible to fix this, at least not without
inverting the split names to indicate what precedes, but that's a step
too far.

This was not detected earlier because I disabled the low-level
rbyd/btree/mtree tests temporarily due to high porting cost. Guess that
goes to show there's a cost to deferring test ports for too long.

---

This issue, along with being inconsistencies between rids/bids and mids,
and being a relatively unintuitive pattern, is the final nail in the
coffin for insert tags inserting after.

Now, insert tags insert before, like in most other systems, and insert
tags in attr-list just have an implicit +1 before them to allow splits
in attr-lists to work.

This is not a pure revert, as some of the changes with all the code
moving around revealed some better detail-level ideas.

And yes, rbyd/btree tests are up to date now. Unfortunately the mtree
tests require a bit more work.

---

One thing definitely worth noting, btree merges were broken! A mistake
in the has-parent condition meant we were never attempting to merge
btrees!

This hid some bugs in the actual btree merge code caused by mixing the
implicit swap of child rbyds to deduplicate code paths with btree commit
now needing to track bid/rid separately from the attr-list.

This should be fixed now. Interesting to note this bug has been in
lfsr_btree_commit_ for a while now! I think ever since we switched to
using trunks for the has-parent check. We just haven't been merging
btree nodes at all. But since not-merging isn't technically an error,
it's difficult to test for.

Code changes:

            code          stack
  before:  33808           2896
  after:   33964 (+0.5%)   2896 (+0.0%)
2024-02-03 18:17:07 -06:00
Christopher Haster 7868ec7122 Ported over most rbyd+btree tests to new attr-list format
Found a bug, and maybe a fundamental issue:

- The lfs_btree_lookupnext_ in lfsr_btree_commit_ no longer needs the
  min32, since we never commit with bid pointing past the end of the
  btree anymore.

  This was mixing the unsigned min32 with our now-signed bid type,
  causing the wrong btree leaf to be fetched when inserting at bid=-1 in
  a non-empty btree.

  Easy fix.

- lfsr_btree_commit_ with bid!=-1, rid=-1 (inserting at the beginning of
  not-the-first rbyd) now actually appends to the leaf to the left of
  the rbyd instead of inserting into the expected rbyd because of how
  lfs_btree_lookup_ works.

  Initially, this doesn't seem like it would be an issue, these should
  be more-or-less equivalent, but this doesn't match
  lfsr_btree_namelookup! This is a big problem!

  This wasn't noticed because it's rare for the high-level tests to
  trigger that many btree splits with names. Named btrees are only used
  for the mtree, and we need mdirs to split before the mtree even splits
  once.

  Not an easy fix.

On the upside, these low-level tests continue to prove themselves
valuable, if tedious to maintain...
2024-02-03 18:17:06 -06:00
Christopher Haster f323ea1bda Made mkdir tests a bit more paranoid
Unfortunately the current dir+bookmark+grm design has a high risk of
the system becoming out-of-sync and losing bookmarks. In theory this
should cause test failures, but the previous grm-mid-off-by-one bug has
left me a bit paranoid.

So when dbglfs.py starting flashing bookmark errors, I started
investigating. But just I can't reproduce these errors in a controlled
way, and they cause no test failures...

My current setup involves this script to copy the disk file
"atomically", so even though dbglfs.py is slow, we shouldn't be reading
blocks from different filesystem states. Uh, beauty is in the eye of the
beholder and all that jazz?:

  ./scripts/watch.py -b -Kdisk bash -c "cp disk disk_ \
      && ./scripts/dbglfs.py disk_ -B4096 \
          --color=always -s -a -T -f -g 2>&1 \
      | head -n32"

But some brief investigation suggests cp is not atomic. After all, how
could it be?

My guess is we occasionaly catch blocks from different filesystem states
when a write occurs during a cp operation. So a false positive.

Still, might as well keep these extra asserts for a bit of extra
confidence we're not losing bookmarks during heavy mkdir operations.
2024-02-03 18:17:04 -06:00
Christopher Haster e04748dadd Renamed SUB/SUPWIDE -> SUB/SUPMASK
This name makes more sense to me given what these bits are doing. Though
that may just be from the embedded engineer side.
2024-02-03 18:16:54 -06:00
Christopher Haster 3c13afd5c2 Added explicit test over unreachable tag holes
Unreachable tag holes, null tags that _should_ be unreachable but
actually are reachable, are an unfortunate quirk to our alt tag
encoding. Because we only have an altgt, not altge, our "unreachable"
tag ends up encoded with an altgt 0, an alt, which you may notice, does
not guarantee unreachability.

Fortunately, tag 0, the null tag, should intentionally be unused. So as
long as we never lookup tag 0, nothing should break.

If you do lookup tag 0, you end up with spurious null tags, which can
complicate things.

The solution here is a tag_ = max(tag, 1) in lfsr_rbyd_lookupnext.

---

One interesting thing to note, as I was writing these tests I discovered
that setting tag=max(tag,1) in lfsr_rbyd_appendattr had no effect.
appendattr needs zip the rbyd tree to keep everything connected during
range removals, so tag=0/tag=1 both end up with the same tree.

So might as well drop the tag=max(tag,1) in lfsr_rbyd_appendattr.

A side effect of this, both before and after this commit, is that any
null tag holes created during range removals sort of stick around until
the next compaction.

---

Why altgt and not altge? altgt is the inverse of altle, requiring only
a single bit flip to flip between the two. And trust me, it would be
much more costly to make altle/altgt flips more complicated than a bit
flip.

---

Why altgt/altle and not altge/altlt? This is because our rbyds are
right-leaning, that is, lookups always find the requested rid+tag, or
the next smallest rid+tag.

Consider a simple tree:

       <5
  .----'|
 >=2    |
  |'-.  |
  1  2  5

What should lookup(3) return? If we are right-leaning, the answer
_should_ be 5. But we need to take the <5 branch to determine if there
is a hidden 3 or 4 in that subtree.

altgt/altle does not have that problem:

      <=2
  .----'|
  >1    |
  |'-.  |
  1  2  5

It might seem like you can workaround this by conservatively using the
neighbor +1 as the alt target, but this runs into tag overflow problems.
UATTR(0xff)+1 (0x057f+1) becomes UATTR(0x100) (0x0580) which is not
allowed due to reserving bit 7 for future subtype extensions.

Maybe you can workaround this workaround by using (tag+0x81)&~0x80
anywhere you need to increment (including lookupnext/iteration calls!),
but this becomes a bit of a mess. And there are still concerns about
overflows at the 0x77f boundary and 0xf7f boundary.
2024-02-03 18:16:52 -06:00
Christopher Haster 5f25f32ff1 Adopted SUPWIDE tag bit, parallel to the SUBWIDE (was WIDE) bit
Like SUBWIDE, SUPWIDE allows for "mask-like" operation during rbyd
commits, where you replace an entire subrange of tags with a single tag.

- SUBWIDE - Replace all subtypes of the given suptype - Useful for
  changing the subtype of an attr, for example replacing a BTREE with a
  BSHRUB.

- SUPWIDE - Replace all suptypes of the given rid - Useful for changing
  the suptype of an attr, for example replacing a REG file with an
  ORPHAN file.

These are effectively the same modifier, just with different ranges.

One benefit is this simplifies mid-level operations a bit, rename,
remove, etc, and decreases the stack cost of the related attr lists.
Though this isn't on the hot-path, so not measurable:

            code          stack
  before:  33956           2912
  after:   33928 (-0.1%)   2912 (+0.0%)

But the real motivation for this change is to remove cases where
lfsr_mdir_commit needs to operate on multiple mids. There may be an API
simplification here.
2024-02-03 18:16:50 -06:00
Christopher Haster 6fc040db1a Adopted paren-cond ternary operator style
So:

  x = (cond) ? yes : no;

Where there are always parentheses around the condition, even if not
required for disambiguity. Additional parentheses are always allowed,
but the parenthesized condition helps signal that a ternary operator is
coming earlier in the expression.

This style has grown on me as I think it helps code readability. It
reminds me of the required parentheses for if/while statements.

Might as well adopt codebase-wide.
2024-02-03 18:16:42 -06:00
Christopher Haster 96b62ff804 Dropped conditional/noop attrs, prefer incremental attr allocation
So instead of using C's ternary operator everywhere:

  (condition)
      ? LFSR_ATTR(rid, tag, delta, data)
      : LFSR_ATTR_NOOP

Use incremental attr allocation instead:

  lfsr_attr_t attrs[1];
  lfs_size_t attr_count = 0;

  if (condition) {
      attrs[attr_count++] = LFSR_ATTR(rid, tag, delta, data);
  }

  LFS_ASSERT(attr_count <= sizeof(attrs)/sizeof(lfsr_attr_t));

Incremental attr allocation is more flexible, allowing nested conditions
and conditions that span multiple attrs without sacrificing readability,
though at a verbosity cost.

We already need this for lfsr_btree_commit and lfsr_file_carve, adopting
it everywhere we need conditional attrs allows us to drop the noop attr
and avoid messy and hard-to-read C expressions.

This also changes the lfsr_btree_commit to explicitly omit noop grows.
We were relying on lfsr_rbyd_appendattr implicitly skipping these to
avoid unnecessary attr commits, but I think it's probably better to make
these noops explicit.

This does add some code cost though, I'm guessing sequential conditional
attrs landing at different offsets complicates code generation a bit:

            code          stack
  before:  33940           2928
  after:   34052 (+0.3%)   2928 (+0.0%)
2024-02-03 18:16:39 -06:00
Christopher Haster 9adb22eee0 Enforced stat/dir_read of a dir results in size=0
The size field in lfs_info doesn't really make sense for stat/dir_read
when the file is a directory. Still, we should probably set it to 0 os
it's not uninitialized.

Fortunately we were already setting size=0 in _most_ cases, this commit
is mostly just checking for size=0 in more test cases.
2024-02-03 18:16:32 -06:00
Christopher Haster 1db215309b Dropped lfsr_mdir_bid/rid convenience functions
Much like the lfsr_o_* functions, I think we should avoid too many
convenience layers for what really are operations on struct fields.

Otherwise you quickly end up with a lot of boilerplate that just saves a
couple extra characters at invocation. Characters that also help convey
what is being accessed.
2024-02-03 18:16:26 -06:00
Christopher Haster 942427dc8c Reworked lfsr_mtree_pathlookup a bit to better leverage internal errors
This avoids implicit info, mid.mid=-1 implying a bad path, and mid.mid=0
implying the root directory, at a tradeoff of potentially making the
returned error codes a bit confusing (0 means the file is NOT found!).

Here are the now possible return codes, aside from lower-level errors
(IO, CORRUPT, etc):

- 0      => path is valid, file NOT found
- EXIST  => path is valid, file found
- INVAL  => path is valid, but points to root
- NOENT  => path is NOT valid, intermediate dir missing
- NOTDIR => path is NOT valid, intermediate dir is not a dir

Since the root has no real mdir entry, I think the special INVAL return
code is warranted. It needs special behavior in relevant functions
anyways.

Note that orphaned files still need special handling.

Code changes:

            code          stack
  before:  33944           2944
  after:   34036 (+0.3%)   2944 (+0.0%)
2024-02-03 18:16:24 -06:00
Christopher Haster 3eaee6877c Fixed issue where fixorphans deleted opened mids
We were just missing a check here to make sure orphaned files aren't
open in-device (these aren't really orphaned because we still have a
reference).

This can't happen during mount, but can happen if fixorphans is
triggered because of an orphaned/zombied file.

Also added a test over this case to prevent regression.

Actually the test was harder to implement than the fix.
2024-02-03 18:15:40 -06:00
Christopher Haster 15593ccc49 Renamed scratch files -> orphan files
I was originally avoiding naming these orphans, as they're _technically_
not orphans. They do exist in the mtree. But the name orphan just
describes this types purpose too well.

This does lead to some confusing terms, such as the fact that orphan
files can be non-orphaned if there are any in-device references. But I
think this makes sense?

- LFSR_TAG_SCRATCH -> LFSR_TAG_ORPHAN
- LFSR_F_UNCREAT -> LFSR_F_ORPHAN
- test_fscratch.toml -> test_forphan.toml
2024-02-03 18:15:38 -06:00
Christopher Haster f6742eefb3 Fixed test failures caused by changes to open semantics
- Fixed fsync tests, which needed more lfsr_file_sync calls so multiple
  file handles can be opened correctly.

  Though this points out there's no way to open a rdonly file on an
  uncreated file until sync is called... But I guess you wouldn't be
  able to recieve broadcasts until sync anyways? at which point the file
  would be created?

- Update mtree tests based on the new remove behavior for regular files.

  Before this changed the mid to -1, now it points to the next mid with
  the zombie flag set. Upper layers use this to migrate mdirs to a
  scratch file if necessary.

- Removed the orphaned mdir test. We don't create orphaned mdirs
  anymore.

  Technically, orphaned mdirs are currently possible if we lose power in
  the middle of the mtree update, but this is a bug and should be fixed
  (previous revisions did not have this issue).
2024-02-03 18:15:37 -06:00
Christopher Haster 0c6db4c9a7 Extended fscratch tests to cover renames + different sizes
With this I think it's safe to say file renaming is decently tested.

The increased range of sizes means we should be testing a good range of
sprout/shrub/btree file structs.
2024-02-03 18:15:36 -06:00
Christopher Haster 7385d84df5 Fixed/implemented renaming open files
This requires two things:

1. Any opened file handles need to have their mid/mdir updated after the
   rename succeeds.

2. Any shrubs/sprouts need to be copied over to the new mdir, even if
   they aren't in-tree.

The LFSR_TAG_MOVE operation is starting to look an awfully lot like
lfsr_mdir_compact... Unfortunately lfsr_mdir_compact, uh, compacts,
whereas LFSR_TAG_MOVE appends to the rbyd like normal, so it's not clear
exactly _how_ to deduplicate.
2024-02-03 18:15:34 -06:00
Christopher Haster f51dc5c5af Implemented zombied file handles
A "zombie file" is a term I just made up to describe what happens when
you remove a file that is currently open.

To match POSIX, the opened file handle should still be available for
reading/writing, even though the file doesn't really exist in the
filesystem anymore.

We don't have inodes, which makes this a bit more complicated, but this
is where scratch files are handy again. By creating a scratch file when
we remove an opened file, we preserve the mid slot for the file's
sprout/shrub. We also mark the opened file as desync, so the existing
orphan reclaimation circuitry kicks in when the last file handle is
closed.

Really the only difference between zombie files and desync files is what
happens when you call lfsr_file_sync:

- Desynced lfsr_file_sync => Become synced, broadcast file state.
- Zombied lfsr_file_sync => Return ENOENT, you can't sync a zombie.

This _is_ a bit different from POSIX, where sync on a removed file
returns 0. I considered returning 0 in this case, but with all the extra
behavior around sync/desync state, I figured returning ENOENT was
clearer at indicating to the user sync is no longer possible.

Worst case, ENOENT is not returned from sync for any other reason, so
users can always treat ENOENT and 0 as the same in higher layers. The
zombie file is already desynced, so close will never error.

---

Implementation wise, zombies get a bit crazy.

Fortunately they add little extra code, but they make up for it by
adding extra subtlety. Zombie files introduce a ton of corner cases, now
even directories can have zombied shrubs.

This means more tests.

- Seemingly unrelated operations need to be able to remove scratch files
  (mkdir, rename, etc).

- UNCREAT state needs to be broadcasted in seemingly unrelated
  operations (mkdir, rename, etc).

- Zombied files need to be copied over during seemingly unrelated rename
  operations.

- And I'm sure more corner cases I'm already forgetting.

One interesting tweak that simplifies things that's worth mentioning is
the change to the implicitly file mid updates on rm in lfsr_mdir_commit.

For non-reg files, an rm attr causes lfsr_mdir_commit to increment the
mid to the next mid in the mtree. This is the correct behavior for dirs,
traversals, etc.

Previously, reg files were a special case that marks the mid as -1. But
by changing this to also increment the mid, as well as set the zombie
flag, upper layers can broadcast zombie changes by simply creating a new
file and then deleting the old file in the same commit.

This seems to Just Work^TM, and avoids needing to do additional state
broadcasting in upper layers, which gets tricky since we may not know
exactly what the new mid is post-mdir-commit.

Downside: The order matters, we need to create the new file first. This
violates the normal delete-then-insert order we use elsewhere to avoid
overflow issues. This isn't that bad here, since we increment by at
most 1. But it is something to be wary of...

Still, this is much better than any other option I can think of right
now.

---

Uh, ignore the test_fscratch_rename* tests for now. I somehow forgot
file renaming was not yet implemented...
2024-02-03 18:15:33 -06:00
Christopher Haster 99156b5573 Implemented orphans resulting from closing desynced scratch files
This is a fun corner case. What happens when you close a desynced
scratch file?

The obvious answer seems to be just remove the scratch file in
lfsr_file_close.

But then what if the file is rdonly? desynced because of an error?

We really shouldn't write to disk at all when closing a desync or rdonly
file. This needs to be a hard rule.

So the only option is to defer the work until later somehow.

Fortunately, we already have several mechanisms that lead to a very nice
solution. I'm very happy with this:

1. There's nothing that says our in-device grm queue needs to always
   match what's on-disk (we need a separate copy for xoring anyways
   because of the risk of leb128 encoding differences). So if we have
   <=2 orphans, we can just push these onto our grm.

   On the next write operation, the normal grm fixing code takes over
   and removes the pending orphans O(1).

2. If we have >2 orphans, the best we can do is mark the filesystem as
   having orphans, and trigger an orphan scan on the next write
   operation O(nlogn).

   But how often do you think littlefs's use cases will end up with >2
   orphans?

Note we also need to scan the opened-file list to make sure we're the
_last_ reference to the scratch file. Otherwise we corrupt other opened
file handles!

---

This commit also includes a fix for a bug where the traversal mdir fell
out of sync when dropping mdirs as a part of scratch file cleanup. Found
when adding more tests, this would cause scratch files to go
unreclaimed.
2024-02-03 18:15:32 -06:00
Christopher Haster 0510c4b185 Added tests over shared scratch files
One downside of scratch files is that there are a lot of corner cases to
consider.
2024-02-03 18:15:31 -06:00
Christopher Haster ba505c2a37 Implemented scratch file basics
"Scratch files" are a new file type added to solve the zero-sized
file problem. Though they have a few other uses that may be quite
valuable.

The "zero-sized file problem" is a common surprise for users, where what
seems like a simple file create+write operation:

  lfs_file_open(&lfs, &file, "hi",
          LFS_O_WRONLY | LFS_O_CREAT | LFS_O_EXCL);
  lfs_file_write(&lfs, &file, "hello!", strlen("hello!"));
  lfs_file_close(&lfs, &file);

Can end up create a zero-sized file under powerloss, breaking user
assumptions and their code.

The tricky thing is that this is actually correct behavior as defined by
POSIX. `open` with O_CREAT creats a file entry immediately, which is
initially zero-sized. And the fact that power can be lost between `open`
and `close` isn't really avoidable.

But this is a common enough footgun that it's probably worth deviating
from POSIX here.

But how to avoid zero-sized files exactly? First thought: Delay the file
creation until sync/close, tracking uncreated files in-device until
then. This solves the problem and avoids any intermediary state if we
lose power, but came with a number of headaches:

1. Since we delay file creation, we don't immediately write the filename
   to disk on open. This implies we need to keep the filename allocated
   in RAM until the first sync/close call.

   The requirement to keep the filename allocated for new files until
   first sync/close could be added to open, and with the option to call
   sync immediately to save the filename (and accept the risk of
   zero-sized files), I don't think it would be _that_ bad of an API.

   But it would still be pretty bad. Extra bad because 1. there's no
   way to warn on misuse at compile-time, 2. use-after-free bugs have a
   tendency to go unnoticed annoyingly often, 3. it's a regression from
   the previous API, and 4. who the heck reads the more-or-less same
   `open` documentation for every filesystem they adopt.

2. Without an allocated mid, tracking files internally gets a lot
   harder. The best option I could think of was to keep the opened-file
   linked-list sorted by mid + (in-device) file name.

   This did not feel like a great solutiona and was going to add more
   code cost.

3. Handling mdir splits containing uncreated files adds another
   headache. Complicated lfsr_mdir_estimate further as it needs to
   decide in which mdir the uncreated files will end up, and potentially
   split on a filename that isn't even created yet.

4. Since the number of uncreated files can be potentially unbounded, you
   can't prevent an mdir from filling up with only uncreated files. On
   disk this ends up looking like an "empty" mdir, which need specially
   handling in littlefs to reclaim after powerloss.

   Support for empty mdirs -- the orphaned mdir scan -- was already
   added earlier. We already scan each mdir to build gstate, so it
   doesn't really add much cost.

Notice that last bullet point? We already scan each mdir during mount.
Why not, instead of scanning for orphaned mdirs, scan for orphaned
files?

So this leads to the idea of "scratch files". Instead of actually
delaying file creation, fake it. Create a scratch file during open, and
on the first sync/close, convert it to a regular file. If we lose power,
scan for scratch files during mount, and remove them on first write.

Some tradeoffs:

1. The orphan scan for scratch files is a bit more expensive than for
   mdirs on storage with large block sizes. We need to look at each file
   entry vs just each mdir, which pushed the runtime up to O(BlogB) vs
   O(B).

   Though if you also consider large mtrees, the worst case is still
   O(nlogn).

2. Creating intermediate scratch files adds another commit to file
   creation.

   This is probably not a big issue for flash, but may be more of a
   concern on devices with large prog sizes.

3. Scratch files complicate unrelated mkdir/rename/etc code a bit, since
   we need to consider what happens when the dest is a scratch file.

But the end result is simple. And simple is good. Both for
implementation headaches, and code size. Even if the on-disk state is
conceptually more complicated.

You may have noticed these scratch files are basically isomorphic to
just setting an "uncreated" flag on the file, and that's true. There may
have been a simpler route to end up with the design, but hey, as long as
it works.

As a plus, scratch files present a solution for a couple other things:

1. Removing an open file can become a scratch file until closed.

2. Scratch files can be used as temporary files. Open a file with
   O_DESYNC and never call sync and you have yourself a temporary file.

   Maybe in the future we should add O_TMPFILE to avoid the need for
   unique filenames, but that is low priority.
2024-02-03 18:15:29 -06:00
Christopher Haster 1344d416d2 Relaxed asserts, allow syncing rdonly files, error on unsync
This is a compromise on consistency and not breaking expected
invariants.

The problem: rdonly files can become unsynced:

1. file is opened rdonly + desync
2. the same file is opened and written to
3. we try to sync our original file handle

What we want:

1. sync should ensure disk + files are in-sync
2. rdonly implies sync should not write to disk

Without desync, and in other systems, this is not a problem, because
rdonly files can never become unsynced.

But with desync, a state (albiet a roundabout one) can be reached where
we can't satisfy both of these invariants.

I wanted to just assert on syncing a rdonly file, but this is supported
on POSIX and other systems, and it makes sense that you would want to
unconditionally call sync in certain circumstances (ensuring close can't
write to disk for example).

So adopts the approach of allowing flush and sync on rdonly files when
possible, and when not possible, sync simply returns LFS_ERR_INVAL and
makes it the user's problem.

For the above example, this has the side effect of making the rdonly file
desync again, so close can complete without touching disk.

As a plus, a desynced rdonly file can now be used to test if a file has
been written to. Though I'm not sure when this would be useful... Or
if it's a good idea to suggest this use of the API...
2024-02-03 18:15:22 -06:00
Christopher Haster 122864f4b6 Reverted LFS_O_SYNC implicit noop sync broadcasting
Reading more into POSIX, it seems that most of the write functions do
have special behavior built into what would implicitly be a noop.

It's difficult to find, since it usually doesn't matter, but consider
the m_time field. The following operations do _not_ update m_time:

- write when size=0
- truncate when size does not change
- fruncate when size does not change

I think it's safe to extend these to sync broadcasts in littlefs, and
only guarantee sync broadcasts when the file state has changed (even
though that may mean other file handles may remain out-of-date!).

In this interpretation, the "write operations" described in POSIX more
mean the implicit write operations effected by write/truncate/fruncate.

That being said, it's not clear what the best approach is, desync files
make this all a bit more muddled... This may also be reverted.
2024-02-03 18:15:21 -06:00
Christopher Haster 4e7a68ff08 Fixed another subtle corner cases with noop sync broadcasting
This is an extension of the noop-sync after unrelated write-sync after
desync corner case:

  op                 a state         b state
                     in-sync         in-sync
  desync(b)          in-sync         desync
  write(a)           unsync          desync
  sync(a)            in-sync'        desync
  sync(b)            in-sync         in-sync

But instead of explicitly calling lfsr_file_sync, what if you implicitly
triggered sync through something like a write on a file with the
LFS_O_SYNC flag, but not a normal write, a noop write, write(0)?

If the definition of LFS_O_SYNC is taken literally as "lfsr_file_write
and friends implicitly call lfsr_file_sync after every call", then this
should behave just as if lfsr_file_sync had been called, and
unconditionally broadcast the sync. Since this is the simplest
interpretation, I think this is what we should implement.

Added tests, and adopted this behavior. Fortunately this just involves
some small gotos (https://xkcd.com/292):

            code          stack
  before:  33020           2976
  after:   33026 (+0.0%)   2976 (+0.0%)
2024-02-03 18:15:19 -06:00
Christopher Haster fdc8c8caf1 Fixed noop sync broadcasting, added more specific sync tests
There are a number of nuanced cases to watch out for when mixing sync,
desync, and "noop syncs" (sync when no write operation has occured):

1. Noop-sync after unrelated write:

     op                 a state         b state
                        in-sync         in-sync
     write(a)           unsync          in-sync
     sync(b)            in-sync         in-sync

   In this case, a should be clobbered by b when b syncs. But this
   gets tricky since b is still up to date with the disk, so b's
   unsynced flag is not set.

   The solution here is to just unconditionally broadcast all sync
   operations irregardless of on-disk state. This is all in-device
   anyways, so it shouldn't really add any overhead.

2. Noop-sync after unrelated write-sync after desync:

     op                 a state         b state
                        in-sync         in-sync
     desync(b)          in-sync         desync
     write(a)           unsync          desync
     sync(a)            in-sync'        desync
     sync(b)            in-sync         in-sync

   In this case, a should again be clobbered by b, even though a is
   in-sync with the disk. This is not tricky because of a's state, but
   because b doesn't know it is no longer in-sync with the disk.

   The solution here is to set the unsynced flag on all desynced files
   when an unrelated file is synced. This way, b knows it needs to
   update disk if sync is called. We already scan all opened files to
   update in-sync files, so this has very little cost.

3. Readonly-sync after unrelated write-sync after desync?

   This is basically the same as 2., but involves a readonly file:

     op                 a state         b state (rdonly)
                        in-sync         in-sync
     desync(b)          in-sync         desync
     write(a)           unsync          desync
     sync(a)            in-sync'        desync
     sync(b)            ???             in-sync

   In this case, I have no idea what should happen.

   I would guess the least surprising result would be for b to write
   its contents to a/disk? Bringing everything in-sync?

   But this implies that b, a readonly file, should write to disk.

   This isn't the only place a read operation would result in a write.
   RDWR files, for example, can flush buffers during a file read. But at
   least there, the file is open RDWR, not strictly RDONLY.

   It seems like writing during sync on a readonly file breaks some sort
   of invariant users expect.

   But the alternative: Dropping the current state of b in favor of a's
   state, is inconsistent with sync on WRONLY/RDWR files, and seems like
   it breaks some sort of invariant about sync modifying the current
   file's state...

   Given this situation, I think the best course of action is to just
   disallow sync on readonly files. It is now an assert.

   There is some precedent for this, upstream we already omit sync when
   compiled in LFS_READONLY mode. Though this does deviate from POSIX
   behavior...

   Worst case, by asserting, this leaves us free to introduce different
   readonly-sync behavior in the future without breaking backwards
   compatibility.

   ---

   Maybe there should be some sort of lfsr_file_resync function to
   discard current changes? Though this can be done with a close+open
   cycle, so I think the value would be low.

Added tests over these cases and fixed where they broke, except for 3.,
lfsr_file_sync and lfsr_file_flush get asserts now to prevent their use
on readonly files.

Also added a couple more specific tests to cover cases I was concerned
about.
2024-02-03 18:15:18 -06:00
Christopher Haster 0891f6264f Renamed test_fmulti -> test_fsync
This should avoid confusion between "multiple handles" and "multiple
files" (name undecided) test suites.

It also fits well because this suite really is just testing nuanced
sync/desync behavior.
2024-02-03 18:15:17 -06:00
Christopher Haster dfdf109505 Revert back to single typed linked-list for opened mdirs
While the multi per-type linked-lists were cool and could save RAM in
some structs (at the cost of RAM in the lfs_t struct), this is simpler,
and simpler is good.

The motivation to revert:

1. I noticed most file types have some sort of flags: files,
   traversals (future), (not dirs but maybe in the future). These flags
   can be merged with the type field to give us typed mdirs at almost
   no RAM cost.

2. Using a single linked-list makes it cheaper to add more file types,
   which may be useful for managing bookmarks (differently) and scratch
   files.

   This comes at a runtime cost, since all scans look at all opened
   structs, but we really, _really_ don't care about a constant non-IO
   runtime cost.

There are code benefits, since we don't need nested iterators to access
all opened mdirs, but also some code cost when we want to filter by
type. As expected stack took a small hit. Humorously, the struct savings
in lfs_t perfectly canceled out the struct hit to lfsr_dir_t:

            code          stack          structs
  before:  32992           2968             1080
  after:   33004 (+0.0%)   2976 (+0.3%)     1080 (+0.0%)
2024-02-03 18:15:15 -06:00
Christopher Haster b0bd026b87 Reworked ftree/bshrub/shrub relationship, staging in ftree now
This is an attempt to simplify things a bit by moving more logic into
the ftree layer, instead of spreading things around between the
bshrub/bsprout functions.

Now, functionality is organized into high-level ftree operations and
low-level shrub/sprout operations, which only care about the inlined
portion of the shrub/sprout. No more lfsr_bshrub_commit/
lfsr_bshrub_commit__ which were mostly unrelated.

This also adds a lfsr_shrub_t type, which, by taking advantage of the
unused write-related rbyd fields to store the shrub estimate, has the
same size as lfsr_rbyd_t, but can still be casted to an rbyd/btree for
use in readonly rbyd/btree functions.

I considered merging shrub/sprout esimate and shrub/sprout compact into
some sort of ftree_estimate/compact, but it's not obvious what the
benefit would be, so leaving that on the table for now.

---

One nice change is our staging copies are now at the ftree level
(ftree.u and ftree.u_, maybe not the best names, but this is what I've
been using for unions where the name doesn't really matter, god I want
unnamed unions). This simplifies staging, and avoids staging issues
where the underlying type changes.

---

A bit unrelated, but necessary to integrate lfsr_ftree_traverse, a
generalized lfsr_tinfo_t type for all traversal functions was added
(adopted from lfsr_traversal_t really). This is a straightforward tagged
union with relevant traversal types.

The benefit of a generalized tinfo type is better chance we can just
pass the tinfo pointer through multiple layers.

Code changes:

            code          stack
  before:  33368           2984
  after:   33260 (-0.3%)   3024 (+1.3%)
2024-02-03 18:15:07 -06:00
Christopher Haster b76ff63e53 Added more sync tests, fixed some bugs, found some design flaws
Now mixing in truncate/fruncate, along with desync<->sync state
transitions.

Found bugs:

- Fixed propagating LFS_F_UNSYNCED/LFS_F_UNFLUSHED state during sync
  broadcasts. This is important for tracking small files correctly.

- We were not clearing the btree erased-state of other opened file
  handles when we started using it, leading other file handles to have
  out-of-date erased-state.

  I considered moving this into lfsr_btree_commit, but file btrees are
  really the only place where shared references make sense, and it feels
  weird to scan file btrees every time we commit to the mtree.

- Fixed syncs not propagating to other file handles when file is synced
  with disk.

  It's interesting that lfsr_file_sync can actually have an effect on
  the system when the disk in is-sync.

- Added O_FLUSH/O_SYNC support to lfsr_file_truncate/fruncate. This
  omission was just an oversight.

  Unfortunately this did add quite a bit more complexity to both
  functions.

You may notice in the fix for that last bug, that lfsr_file_ftruncate
sort of drops the ball with regards to error-idempotency. This is
because, as I was trying to figure out how to recoverably move the
buffer around when fruncating small files, I realized we don't handle
small files in lfsr_file_write correctly w.r.t. error-idempotency, and
that fixing this may be intractable...

The issue is how handle overwrites for unflushed buffers.

In general, the correct thing to do when an incoming write overlaps our
file buffer, is to just write over the buffer with the new data.

Ah, but if we do this, how do we get the old data back if we run into an
error writing the data to disk? It's gone!

For normal files, this is not an issue. We can always flush to disk to
reclaim our buffer, and since a flush doesn't change the file contents,
it's fine to make this our new fallback state.

But for small files, flush is a noop, we keep these entirely in RAM.

There are some possible workarounds:

- Flush small files to disk before overwriting, sort of defeats the
  purpose of caching these in RAM...

- Reread small files from disk, because that's definitely what you want
  to do when you hit an error...

  Also, to always have something we can read from disk implies flush
  on overwrite, see above.

- Sacrificing half our buffer for staging small files. Because RAM cost
  is totally not a priority...

Long story short, rethinking idempotent errors.
2024-02-03 18:14:57 -06:00
Christopher Haster b336e92c66 Exposed lfsr_file_flush, LFS_O_FLUSH, for manually flushing buffers
A recent change, motivated by user feedback, was to delay write buffer
flushes as much as possible. Before, littlefs would always flush the
buffer during lfs_file_seek, but now, buffer flushes can be delayed all
the way to lfsr_file_read, or even skipped entirely thanks to bypassing
reads.

This is all fine and dandy, except it's easy to imagine a use case where
a user might really not want a _write_ error to pop out of a _read_
call.

With this new behavior, avoiding this situation is impossible.

So enters a function common to other filesystems: lfsr_file_flush.

However it's value is quite a bit different here. Unlike flush in other
filesystems, this flush does not necessarily make data accessible on
disk. It only writes to the pending file snapshot, which is not
accessible until lfsr_file_sync.

This makes flush a function with a rather narrow scope in littlefs
(pretty much just preventing write errors in read), but since we had
already implemented this function for internal plumbing, it adds _very_
little cost.

I'm more concerned about potential user confusion around sync vs flush.

Curiously, exposing lfsr_file_flush actually _saved_ code size for some
reason. Not sure what would make that happen:

                   code          stack
  before:         33544           3072
  flush:          33536 (-0.0%)   3072 (+0.0%)
  flush+O_FLUSH:  33548 (+0.0%)   3072 (+0.0%)
2024-02-03 18:14:54 -06:00
Christopher Haster ae2644eb88 Added LFS_O_SYNC, for implicit syncs during file writes
The motivation for this comes from the observation that many users call
sync on every file write. Much more than I expected. I think one reason
is in embedded systems it's common to just write structs to disk, either
the whole file or to a log.

O_SYNC exists in POSIX/Lunix/etc, so it makes sense to provide in
littlefs. In theory it's just one extra function call, and may even save
in total application cost (though we don't measure this) by reducing the
number of function calls at the application-level.

---

Unfortunately in-practice turned out to be quite a bit different than
in-theory... The main culprit being the improved guarantees around error
atomicity...

The ideal guarantee is that if there is an error during a write, the
entire write operation is reverted. Combining this with O_SYNC means we
need to hold a copy of the origin file state all thwe way through our
sync call. This got a bit messy...

The annoying part isn't even the functionality! Our system of tracking
btree/bshrub snapshots is quite robust! The problems were entirely with:

1. Figuring out how the heck to avoid clobbering the old file buffer
   state.

2. Figuring out how the internal APIs should work while passing around a
   bunch of staging state.

For 1., fortunately, thanks to bypassing writes, and some careful
pointer manipulation, we can void buffer clobbing. And for 2. just some
internal API work was needed. Internally all syncs end up in
lfsr_ftree_sync, though this feels a bit clumsy since the functionality
is not really ftree related...

Unfortunately, all of this added up to quite a bit more code cost than
I had hoped. In theory, adding some sort of LFS_CERAMIC/LFS_GLASS modes
that relax error atomicity for code size could help with most of this?
But it needs some thought:

            code          stack
  before:  33324           3072
  after:   33544 (+0.7%)   3072 (+0.0%)
2024-02-03 18:14:53 -06:00
Christopher Haster b15940461d Implemented desynchronized files
Desynchronized files are a new concept intended to capture some useful
quirks of the previous multiple-open-file behavior.

This adds:

- LFS_O_DESYNC     - Mark a file as desync during open
- lfsr_file_desync - Mark a file as desync whenever
- lfsr_file_sync   - Mark a file as NOT desync, and sync the file

Desynced files:

1. Don't recieve updates from writes to other file handles. This makes
   desynced files act as a sort of snapshot of the file at the time it
   was marked desync.

2. Don't call lfsr_file_sync on close. Unless lfsr_file_sync is
   explicitly called, changes to desynced files are not reflected on
   disk and not broadcasted to other file handles.

A side-effect of 2., is that this gives you a quick way to abort a file
write. Marking a file as desync and then closing the file will never
error.

Additionally, if an error occurs during a write operation, the file is
implicitly marked as desync. This provides graceful write aborting in
unlikely error cases. This has actually always been a feature in
littlefs, it was just named differently and didn't have an optional
recovery mode.

Since littlefs actually has to do more work to keep files in sync, the
desync feature is quite cheap:

            code          stack
  before:  33324           3072
  after:   33360 (+0.1%)   3072 (+0.0%)
2024-02-03 18:14:41 -06:00
Christopher Haster 8f2a6a3095 Implemented file sync broadcasting
Now, when files are synced, they broadcast their disk changes to any other
opened file handles. In effect, all open files match disk after a sync
call to any opened file handle pointing to that file.

This was a much requested feature, as the previous behavior (multiple
opened file handles maintain independent snapshots) is pretty different
from other filesystems. It's also quite difficult to implement outside
of the filesystem, since you need to track all opened files, requiring
either unbounded RAM or a known upper limit.

---

A bit unrelated, but this commit also changes bshrub estimate
calculation to include all opened file handles. This adds some annoying
complexity, but is necessary to prevent sporadic ERANGE errors when
the same file is opened multiple times.

The current implementation just refetches on-disk metadata. This adds
some maybe unnecessary metadata lookups, but simplifies things by
avoiding the tracking of on-disk sprout/shrub size, which risks falling
out of date. Keep in mind we only recalculate the estimate every
~inline_size/2 bytes written.

Just like lfsr_mdir_estimate, this scales O(n^2) with the number of
opened files (this are basically the same function... hmmm... can they
be deduplicated?). This is unlikely to be a problem for littlefs's use
case, but just something to be aware of.

Code changes:

            code          stack
  before:  32920           3032
  after:   33192 (+0.8%)   3048 (+0.5%)
2024-02-03 18:14:28 -06:00
Christopher Haster 006d656da2 Fixed unaligned data checksumming in two ways (uncrc32c, flcksum)
Checksumming unaligned data during block compaction is surprisingly
tricky. We don't know if our data will be aligned until after
a potentially unbounded number lookups, we need to write data into our
pcache as we go to avoid unnecessary lookups, but if we end up unaligned
we need to revert our checksum to the checksum of the aligned data.

The way I see it there are 4 options:

1. Calculate the checksum after writing data into the block.

   This is the most expensive option, requiring a full second read of
   the data to calculate the checksum. It is simple though.

2. Do a pass over the btree to figure out alignment before writing.

   This at least only reads metadata twice, so is more efficient than
   the 1st option.

3. Keep track of the aligned checksum on each flush, falling back to the
   last flushed checksum if we need to correct alignment.

   This solution is flexible though requires some extra state to track
   multiple checksums.

4. Leverage the math behind CRCs to run the CRC backwards when we
   truncate for alignment.

   This works, though a bit inefficiently, but is strictly tied to
   CRC-related checksums.

   By inefficient I mean that we would likely be limited to a bit-level
   "uncrc32c". It's possible to create nibble/byte tables for uncrc32c,
   but this adds significant code cost for a relatively uncritical
   function.

   I was hopeful that we could leverage the existing tables in both
   functions, but unfortunately it doesn't work out like that. You could
   scan the crc32c table to find the constant to reverse, but this
   requires ~16*2 or ~256 operations vs "naive" ~8 operations per byte.

This commit implements both 3 and 4, defaulting to 4 unless
LFS_NO_UNCRC32C is defined.

The current lfs_uncrc32c implementation is a simple bit-level
implementation, but does allow for crc32c truncation without any extra
state.

              code          stack
  before:    32044           2880
  uncrc32c:  32108 (+0.2%)   2880 (+0.0%)
  flcksum:   32132 (+0.3%)   2880 (+0.0%)
2023-12-17 15:18:10 -06:00
Christopher Haster 02d2919130 Adopted lfsr_rbyd_lookupwide, dropped wide bit in lookups
This trades a runtime check for a different function call. Enforcing
some minor semantics in the function's type/asserts.

This also makes it so there are no special tag bits used during rbyds
lookup, only rbyd commits.

In theory this saves a bit of code, we don't have a runtime check, but
in practice the extra function apparently outweighs the cost of the
runtime check:

            code          stack
  before:  31956           2880
  after:   32024 (+0.2%)   2880 (+0.0%)
2023-12-14 12:30:21 -06:00
Christopher Haster 3e45fc739d Changed becksum lookup to not re-traverse the whole btree
We already get the leaf rbyd as a part of btree lookup, and since ids
can't be split across rbyd boundaries, we can be sure any bptr attrs
live in the same rbyd.

This can be extended to any future bptr attrs.

Aside from the small performance gain, this also means we can drop the
btree bid+tag lookups. All extra attr lookups to lookup the rbyd first.
This saves a bit of code but also avoids a set of issues with the btree
semantics where lookupnexting an extra attr can return ENOENT
prematurely when on an rbyd boundary.

As I'm typing this I realize this means we have no way to iterate over
all _tags_ in a btree, only over all _bids_. Fortunately I don't think
we will ever need the former.

            code          stack
  before:  32136           2880
  after:   31956 (-0.6%)   2880 (+0.0%)
2023-12-14 12:05:23 -06:00
Christopher Haster f29a4982c4 Added block-level erased-state checksums
Much like the erased-state checksums in our rbyds (ecksums), these
block-level erased-state checksums (becksums) allow us to detect failed
progs to erased parts of a block and are key to achieving efficient
incremental write performance with large blocks and frequent power
cycles/open-close cycles.

These are also key to achieving _reasonable_ write performance for
simple writes (linear, non-overwriting), since littlefs now relies
solely on becksums to efficiently append to blocks.

Though I suppose the previous block staging logic used with the CTZ
skip-list could be brought back to make becksums optional and avoid
btree lookups during simple writes (we do a _lot_ of btree
lookups)... I'll leave this open as a future optimization...

Unlike in-rbyd ecksums, becksums need to be stored out-of-band so our
data blocks only contain raw data. Since they are optional, an
additional tag in the file's btree makes sense.

Becksums are relatively simple, but they bring some challenges:

1. Adding becksums to file btrees is the first case we have for multiple
   struct tags per btree id.

   This isn't too complicated a problem, but requires some new internal
   btree APIs.

   Looking forward, which I probably shouldn't be doing this often,
   multiple struct tags will also be useful for parity and content ids
   as a part of data redundancy and data deduplication, though I think
   it's uncontroversial to consider this both heavier-weight features...

2. Becksums only work if unfilled blocks are aligned to the prog_size.

   This is the whole point of crystal_size -- to provide temporary
   storage for unaligned writes -- but actually aligning the block
   during writes turns out to be a bit tricky without a bunch of
   unecesssary btree lookups (we already do too many btree lookups!).

   The current implementation here discards the pcache to force
   alignment, taking advantage of the requirement that
   cache_size >= prog_size, but this is corrupting our block checksums.

Code cost:

           code          stack
  before: 31248           2792
  after:  32060 (+2.5%)   2864 (+2.5%)

Also lfsr_ftree_flush needs work. I'm usually open to gotos in C when
they improve internal logic, but even for me, the multiple goto jumps
from every left-neighbor lookup into the block writing loop is a bit
much...
2023-12-14 01:05:34 -06:00
Christopher Haster c4d75efa40 Added bptr checksums
Looking forward, bptr checksums provide an easy mechanism to validate
data residing in blocks. This extends the merkle-tree-like nature of the
filesystem all the way down to the data level, and is common in other
COW filesystems.

Two interesting things to note:

1. We don't actually check data-level checksums yet, but we do calculate
   data-level checksums unconditionally.

   Writing checksums is easy, but validating checksums is a bit more
   tricky. This is made a bit harder for littlefs, since we can't hold
   an entire block of data in RAM, so we have to choose between separate
   bus transactions for checksum + data reads, or extremely expensive
   overreads every read.

   Note this already exists at the metadata-level, the separate bus
   transactions for rbyd fetch + rbyd lookup means we _are_ susceptible
   to a very small window where bit errors can get through.

   But anyways, writing checksums is easy. And has basically no cost
   since we are already processing the data for our write. So we might
   as well write the data-level checksums at all times, even if we
   aren't validating at the data-level.

2. To make bptr checksums work cheaply we need an additional cksize
   field to indicate how much data is checksummed.

   This field seems redundant when we already have the bptr's data size,
   but if we didn't have this field, we would be forced to recalculate
   the checksum every time a block is sliced. This would be
   unreasonable.

   The immutable cksize field does mean we may be checksumming more data
   than we need to when validating, but we should be avoiding small
   block slices anyways for storage cost reasons.

This does add some stack cost because our bptr struct is larger now:

            code          stack
  before:  31200           2768
  after:   31272 (+0.2%)   2800 (+1.1%)
2023-12-12 12:07:55 -06:00
Christopher Haster 3a6afaf1c5 Renamed lfs_alloc_ack -> lfs_alloc_ckpoint
This name describes this operation ever so slightly better, I've already
been refering to this as "checkpointing the allocator" places.
2023-12-06 22:24:18 -06:00
Christopher Haster 6261bafed2 Added more file tests with multiple files, fixed bugs
Fortunately these operations are heavily tested in test_dirs. The only
difference with files is the possibility for shrubs to need to be
copied.

Bugs fixed:

- It's counterintuitive, but lfsr_rbyd_appendcompactattr _can_ error
  with LFS_ERR_RANGE when we are copying a shrub. This can happen if the
  underlying mdir needs compaction itself.

- It's possible to null-trunk bshrubs to appear in our filesystem
  traversal. Null-trunk bshrubs don't usually appear in any stable
  state, but they are created by lfsr_bshrub_alloc and lfsr_btree_commit
  to represent new, yet-uncommitted shrubs.

  This gets a bit tricky because we also use null-trunks to indicate if
  lfsr_btree_traversal has traversed the root. We can't rely on
  bid >= weight for this because zero-weight btrees are allowed.

  The solution here, though maybe temporary (famous last words), is to
  treat null-trunk btrees as not having a root. Which isn't really true,
  but null-trunk btree roots only exist between allocator checkpoints,
  so they are allowed to be unreachable.

  We really need more asserts that this is the case though... At least
  added an assert that we never commit/read null trunks on disk.
2023-12-06 22:23:53 -06:00
Christopher Haster 939dd2145a Added some corner-case tests, fixed related bugs/POSIX nuances
POSIX is notoriously full of subtle and confusing nuances. Not through
any fault of POSIX, but as a result of trying to describe a complex
system with simple and easy to use operations.

Corner cases fixed here:

- rename("dir", "file") => ENOTDIR

  This is the main surprise to me, and a mistake on my part. I thought
  EISDIR would be appropriate for any renames with mismatched types,
  since both involve a directory. It would be simpler code-wise, and
  avoid ambiguity around if "file" is not a dir, or some other file
  exists in the file's path. But I guess ENOTDIR makes more sense if you
  think of the destination as the target being operated on.

- remove("/") => EINVAL
- rename("/", "x") => EINVAL
- rename("x", "/") => ENOTEMPTY
- open("/") => EISDIR

  It's a bit difficult to lookup what error codes around root operations
  should be, since they mostly end up as EPERM on modern systems, but
  this doesn't really make sense for littlefs.

  The solution chosen here is to prefer directory-related errors (EISDIR,
  ENOTEMPTY) when possible, and fall back to EINVAL when the only issue
  is that the target is the root directory.

Also I tweaked lfsr_mtree_pathlookup a bit so mid=0 indicates the target
is the root and mid=-1 indicates the target can't be created (because of
a missing directory). I think using mid=0 for the latter is a leftover
from when mid=-1 was a bit of a mess...
2023-12-06 22:23:51 -06:00
Christopher Haster abbd2d6c3f Made it possible to actually rename shrubbed files
This needed a bit of extra handling to copy the shrub, since it exists
outside of the mdir's main tree.

Also added relevant tests.
2023-12-06 22:23:47 -06:00
Christopher Haster b1ce27f733 Reorganized test suites a bit
- Renamed test_dtree -> test_dirs
- Renamed test_dseek -> test_dread
- Split test_files -> test_files, test_fwrite
2023-12-06 22:23:45 -06:00
Christopher Haster a9772d785a Removed removal of root bookmark in test_mtree
It turns out permanent root bookmark creates some rather interesting
constraints on our mtree:

1. We can never delete all mids, since at least one mid needs to exist
   to represent the root's bookmark.

2. We can never revert to an inlined mdir after uninlining, since our
   root bookmark always exists to stop this. This is an unfortunate
   downside as it would be nice to be able to reinline mdirs, but not
   the end of the world.

This restricts what operations are possible, and transitively, what we
can test.

This commit drops the removal of root bookmarks in test_mtree, which was
a workaround to keep tests from early implementation running. This was
preventing some minor optimizations. This required dropping some tests,
but these tests tested operations that aren't really possible in
practice.

Dropping the removal of root bookmarks allowed for a minor optimization
in lfsr_mdir_drop, and may lead to more in the future (or maybe just
stricter asserts):

            code          stack
  before:  31280           2648
  after:   31208 (-0.2%)   2648 (+0.0%)
2023-12-06 22:23:36 -06:00
Christopher Haster eb6c361dfa Adopted lazy orphaned mdir drops
This ended up being much less of a simplification than I hoped it would.

It's still easier/more efficient to revert to a relocation in most cases
when dropping in an mdir split, and the small gain from simplifying how
drops/commits interact is overshadowed by the code duplication necessary
to separate lfsr_mdir_drop out from lfsr_mdir_commit:

            code          stack
  before:  30952           2528
  after:   31280 (+1.1%)   2648 (+4.7%)

Still, this does at least simplify the logical corner cases (we don't
need to abort commits when droppable anymore), and lfsr_mdir_drop is
ultimately necessary for supporting lazy file creation.

Also having a fix-orphans step during mount allows other littlefs
implementations the option to create orphanned mdirs without compat
issues. So this ends up the more flexible approach.

It _might_ be worth having both eager mdir drops and an explicit
lfsr_mdir_drop for lazy file creation in the future, but I doubt this
will end up worth the code duplication...

---

Oh right, I forgot to actually describe this change.

This trades eager mdir drops:

1. Drop mdirs from the mtree immediately as soon as their weight goes
   to zero.

For lazy mdir drops:

1. Drop mdirs from the mtree in a second commit.
2. Scan and drop orphaned mdirs on the first write after mount.

This sounds very similar to the previous "deorphan" scan, which risked
an extreme performance cost during mount, but it should be noted this
orphan scan only needs to touch every mdir once. This makes it no worse
than the overhead of actually mounting the filesystem.

We can also keep an eye out for orphaned mdirs when we mount, so no
extra scan is needed unless there was an unlucky powerloss.

Eager mdir dropping sounds simpler, but thanks to deferred commits
introduces some subtle complexity around aborting commits that would
drop an mdir to zero. Remember commits are viewable on-disk as soon as a
commit completes.

In _theory_, lazy mdir drops simplify the logic around committing to
mdirs.

Though the real kicker is that lazy mdir drops are required for lazy file
creation.

The current idea for lazy file creation involves tracking mid-less
opened-but-not-yet-created files. These files can have bshrubs, so they
need space on an mdir somewhere. But they aren't actually created yet,
so they don't have an mid.

This is fine (though it's probably going to be tricky) as long as we
allocate an mid on file sync, but there is always a risk of losing power
with mdirs that contain only RAM-backed files. Fortunately, no-mids
means no orphaned files, but it does mean orphaned mdirs with no synced
contents.

Long story short, lazy mdir drops are currently a necessary evil, and
logical simplification, that unfortunately comes with some cost.
2023-12-06 22:23:28 -06:00
Christopher Haster 51e39747c0 Reverting alternate redund block layout in lfsr_mdir_t
See the previous commit for the reason. The alternate redund block
layout is just inferior in terms of both code and RAM.
2023-12-06 22:23:16 -06:00
Christopher Haster 9d182c2055 Attempted alternate redund block layout in lfsr_mdir_t
The idea here is to revert moving redund blocks into lfsr_rbyd_t, and
instead just keep a redundant copy of the rbyd blocks in the redund
blocks in lfsr_mdir_t.

Surprisingly, extra overhead in lfsr_mdir_t ended up with worse stack
usage than extra overhead in lfsr_rbyd_t. I guess we end up allocated
more mdirs than rbyds, which makes a bit of sense given how complicated
lfsr_mdir_commit is:

                    code          stack          structs
  redund union:    30976           2496             1072
  redund in rbyd:  30948 (-0.1%)   2528 (+1.3%)     1100 (+2.6%)
  redund in mdir:  31000 (+0.1%)   2536 (+1.6%)     1092 (+1.8%)

The mdir option does seem to improve struct overhead, but this hasn't
been a reliable measurement since it doesn't take into account how many
of each struct is allocated.

Given that the mdir option is inferior in both code and stack cost, and
requires more care to keep the rbyd/redund blocks in sync, I think I'm
going to revert this for now but keep the commit in the commit history
since it's an interesting comparison.
2023-12-06 22:23:13 -06:00
Christopher Haster becbc0c2ad Moved redundant blocks into the lfsr_rbyd_t struct
This simplifies dependent structs with redundancy, mainly lfsr_mdir_t,
at a significant RAM cost:

            code          stack          structs
  before:  30976           2496             1072
  after:   30948 (-0.1%)   2528 (+1.3%)     1100 (+2.6%)

Which, to be honest, is not as bad as I thought it would be. Though it
is still pretty bad for no new features.

The motivation for this change:

1. The organization of the previous lfsr_mdir_t struct was a bit hacky
   and relied on exact padding so the redund block array and rbyd block
   lined up at the right offset.

2. The previous organization prevented theoretical "read-only rbyd
   structs" that could omit write-related fields, e.g. eoff and cksum.

   This idea is currently unused.

3. The current mdir=level-1, btree/data=level-0 redund design makes this
   RAM tradeoff pretty bad, but in theory higher btree redund levels
   would need the extra redund blocks in the rbyd struct anyways.

Still, the RAM impact to the current default configuration means this
should probably be reverted...
2023-12-06 22:23:11 -06:00