From 236eaa27718209f25ab98ef316d6d0fceeeba2f9 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 15 Sep 2026 23:54:11 -0500 Subject: [PATCH] Answer with an error when notmuch will not build an item `ThreadsIterator.next` and `MessagesIterator.next` asked libnotmuch for the item at the cursor and wrote `orelse unreachable`, reasoning that `notmuch_threads_valid` had just said there was one there. libnotmuch makes no such promise. The item does not exist until it is asked for -- it is built out of the database on the spot -- and the header says so plainly: Get the current thread from 'threads' as a notmuch_thread_t. ... If an out-of-memory situation occurs, this function will return NULL. So NULL is an outcome, and `unreachable` turned it into a panic that takes the whole program down. It is not a rare or theoretical one either: any archive read while it is written can produce it, because a writer committing underneath a running search invalidates the reader and the next item then fails to build. A daemon serving searches alongside deliveries died on it about once a minute. Both now return `error.XapianException`, which `NextError` gains -- and that is a breaking change to two public error sets, since an exhaustive `switch` over either no longer compiles. A caller that propagates with `try`, or switches with an `else`, is unaffected. Hence 0.3.0 rather than 0.2.1, and a CHANGELOG.md to say so. **Xapian exception rather than `OutOfMemory`**, which is what the header's wording would suggest, for two reasons. The allocator is rarely the real cause -- what is actually being reported is that the database could not answer -- and a caller is entitled to treat running out of memory as fatal, which would mean giving up on something it should have retried. `Directory` already maps a NULL return from libnotmuch this way, so this is the binding agreeing with itself. The string iterators are left alone deliberately. `notmuch_tags_get` and the others hand back a pointer into an object the caller already holds rather than building anything, so a NULL there really would mean the cursor was invalid -- which is checked immediately above. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++ build.zig.zon | 3 ++- src/MessagesIterator.zig | 17 +++++++++++++++- src/ThreadsIterator.zig | 17 +++++++++++++++- 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..708475c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ + + + +# Changelog + +Notable changes to notmuch.zig. The format is loosely +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the versions are +[semantic](https://semver.org/) — while the major version is 0, a breaking +change raises the minor. + +## 0.3.0 + +### Fixed + +- **An iterator no longer panics when notmuch declines to build an item.** + `ThreadsIterator.next` and `MessagesIterator.next` asked `notmuch_threads_get` + and `notmuch_messages_get` for the item at the cursor and wrote + `orelse unreachable`, on the reasoning that `notmuch_*_valid` had just said + there was one. That is not a guarantee libnotmuch makes: the item is built + out of the database when it is asked for, and the header says plainly that + NULL comes back if the build fails. Both now return + `error.XapianException`. + + It is reachable on any archive that is read while it is written. A writer + committing underneath a running search invalidates the reader, the next item + fails to build, and a daemon serving that search died on the spot — about + once a minute under a search load with deliveries running alongside it. + +### Changed + +- `ThreadsIterator.NextError` and `MessagesIterator.NextError` gained + `XapianException`. **This is why the minor version moved**: an exhaustive + `switch` over either set no longer compiles. A caller that propagates with + `try`, or switches with an `else`, is unaffected. + + It is reported as a Xapian exception rather than as `OutOfMemory`, which is + the only cause libnotmuch's header names, because the allocator is rarely + the real one — and a caller that treats running out of memory as fatal would + give up on something it should have retried. `Directory` already maps a NULL + return the same way. + +## 0.2.0 and earlier + +Not recorded here; see the commit history. diff --git a/build.zig.zon b/build.zig.zon index b25b623..c106e73 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,7 +3,7 @@ .{ .name = .notmuch, - .version = "0.2.0", + .version = "0.3.0", .fingerprint = 0x25da472dbcafbfc8, .minimum_zig_version = "0.16.0-dev.2682+02142a54d", @@ -20,5 +20,6 @@ "src", "LICENSES", "README.md", + "CHANGELOG.md", }, } diff --git a/src/MessagesIterator.zig b/src/MessagesIterator.zig index 955be9b..bc6416f 100644 --- a/src/MessagesIterator.zig +++ b/src/MessagesIterator.zig @@ -28,6 +28,21 @@ pub const NextError = error{ /// Iteration was invalidated by the database. Re-open the database and /// try again. OperationInvalidated, + /// The item could not be built out of the database. + /// + /// `notmuch_messages_get` is documented to return NULL only for an + /// out-of-memory situation, but the message is constructed on demand out + /// of the database when it is asked for, so *any* failure to construct + /// one arrives the same way -- a read invalidated by a writer committing + /// underneath the search is how this was first seen, on a live archive + /// taking mail while somebody read it. + /// + /// Reported as a Xapian exception rather than as `OutOfMemory`, which is + /// what the header's wording would suggest, because the allocator is + /// rarely the real cause and a caller that treats running out of memory + /// as fatal would give up on something it should have retried. It is the + /// same mapping `Directory` uses for a NULL return. + XapianException, /// libnotmuch returned an undocumented status code. Unexpected, }; @@ -90,7 +105,7 @@ pub fn next(self: *const MessagesIterator) NextError!?Message { if (c.notmuch_messages_valid(messages) == 0) break :message null; defer c.notmuch_messages_move_to_next(messages); break :message .{ - .message = c.notmuch_messages_get(messages) orelse unreachable, + .message = c.notmuch_messages_get(messages) orelse return error.XapianException, }; }, .iterator_exhausted => return null, diff --git a/src/ThreadsIterator.zig b/src/ThreadsIterator.zig index 011efc9..e0e2d75 100644 --- a/src/ThreadsIterator.zig +++ b/src/ThreadsIterator.zig @@ -22,6 +22,21 @@ pub const NextError = error{ /// Iteration was invalidated by the database. Re-open the database and /// try again. OperationInvalidated, + /// The item could not be built out of the database. + /// + /// `notmuch_threads_get` is documented to return NULL only for an + /// out-of-memory situation, but the thread is constructed on demand out + /// of the database when it is asked for, so *any* failure to construct + /// one arrives the same way -- a read invalidated by a writer committing + /// underneath the search is how this was first seen, on a live archive + /// taking mail while somebody read it. + /// + /// Reported as a Xapian exception rather than as `OutOfMemory`, which is + /// what the header's wording would suggest, because the allocator is + /// rarely the real cause and a caller that treats running out of memory + /// as fatal would give up on something it should have retried. It is the + /// same mapping `Directory` uses for a NULL return. + XapianException, /// libnotmuch returned an undocumented status code. Unexpected, }; @@ -33,7 +48,7 @@ pub fn next(self: *ThreadsIterator) NextError!?Thread { if (c.notmuch_threads_valid(threads) == 0) break :thread null; defer c.notmuch_threads_move_to_next(threads); break :thread .{ - .thread = c.notmuch_threads_get(threads) orelse unreachable, + .thread = c.notmuch_threads_get(threads) orelse return error.XapianException, }; }, .iterator_exhausted => return null, -- 2.51.2