audit: no dead end survives a corrupt record, no lie survives a filesystem error

- a blank summary record bricked wake forever: it refused with 'Do the 0
  compressions below' + a literal None, while nap said nothing was left.
  Both sites (wake's cover, nap's half) now name the one exit: forget.
- tree_get/count/repair swallowed every OSError as 'not built yet', so an
  unreadable level was reported as pending work that could not be done.
  Only FileNotFoundError means absent; real failures surface with the path.
- import accepted 2026-99-99, poisoning the store's date order forever.
- the wake T error now says 'the log holds N memories', not 'entries'.
This commit is contained in:
victortaelin 2026-07-26 17:01:41 -03:00
parent cf26dbeb44
commit 8c444ad991
2 changed files with 84 additions and 17 deletions

50
memo
View file

@ -205,7 +205,7 @@ def tree_path(d, size):
def count(path, rec): def count(path, rec):
try: try:
return os.path.getsize(path) // rec return os.path.getsize(path) // rec
except OSError: except FileNotFoundError: # any other failure is real and must surface
return 0 return 0
@ -219,7 +219,7 @@ def repair(path, rec):
every later record is misaligned. Callers hold the lock.""" every later record is misaligned. Callers hold the lock."""
try: try:
n = os.path.getsize(path) n = os.path.getsize(path)
except OSError: except FileNotFoundError:
return return
if n % rec: if n % rec:
with open(path, "r+b") as f: with open(path, "r+b") as f:
@ -271,7 +271,7 @@ def tree_get(d, lo, hi):
with open(tree_path(d, size), "rb") as f: with open(tree_path(d, size), "rb") as f:
f.seek((lo // size) * TREE_REC) f.seek((lo // size) * TREE_REC)
rec = f.read(TREE_REC) rec = f.read(TREE_REC)
except OSError: except FileNotFoundError: # not built yet; any other failure is real
return None return None
return rec.decode().rstrip() or None return rec.decode().rstrip() or None
@ -425,7 +425,11 @@ def nap_prompt(d, lo, hi, left):
for a, b in ((lo, mid), (mid, hi)): for a, b in ((lo, mid), (mid, hi)):
s = tree_get(d, a, b) s = tree_get(d, a, b)
if s is None: if s is None:
die("Summary %d-%d is missing. Run: %s nap" % (a, b - 1, ME)) # pending() lists a block only after its halves settled, so
# a missing half is a blank record -- a corrupt write. Drop
# it and the next nap rebuilds it.
die("The summary of #%d-%d is blank. Run: %s forget %d-%d"
% (a, b - 1, ME, a, b - 1))
halves.append(" #%d-%d %s" % (a, b - 1, s)) halves.append(" #%d-%d %s" % (a, b - 1, s))
body = "\n".join(halves) body = "\n".join(halves)
tail = "" if not left else "\n%s after this one." % ( tail = "" if not left else "\n%s after this one." % (
@ -537,8 +541,8 @@ def cmd_wake(d, args):
if len(args) == 2: if len(args) == 2:
T = int(args[1]) T = int(args[1])
if T > now: if T > now:
die("T=%d, but the memory holds %s. Run: %s wake" die("T=%d, but the log holds %s. Run: %s wake"
% (T, plural(now, "entry"), ME)) % (T, plural(now, "memory"), ME))
# A part is rendered as of T, so a note landing between two parts cannot # A part is rendered as of T, so a note landing between two parts cannot
# shift a boundary and drop a line. # shift a boundary and drop a line.
if not T: if not T:
@ -553,17 +557,25 @@ def cmd_wake(d, args):
else: else:
s = tree_get(d, lo, hi) s = tree_get(d, lo, hi)
if s is None: if s is None:
# The ONLY reason to refuse: this document cannot be written nap = next_nap(d, T)
# without that summary. Work that the document does not need if nap:
# is handed over after the read instead, costing no round # The ONLY reason to refuse: this document cannot be
# trip. # written without that summary. Work that the document
print("Cannot wake: the memory context needs #%d-%d, which is " # does not need is handed over after the read instead,
"not compressed yet.\nDo the %s below, then run %s " # costing no round trip.
"wake again.\n" print("Cannot wake: the memory context needs #%d-%d, "
% (lo, hi - 1, "which is not compressed yet.\nDo the %s below, "
plural(pending_count(d, T), "compression"), ME)) "then run %s wake again.\n"
print(next_nap(d, T)) % (lo, hi - 1,
sys.exit(1) plural(pending_count(d, T), "compression"), ME))
print(nap)
sys.exit(1)
s = tree_get(d, lo, hi) # a parallel session may have paid it
if s is None:
# Nothing is pending, so the record exists but is blank -- a
# corrupt write. Drop it and the next nap rebuilds it.
die("The summary of #%d-%d is blank. Run: %s forget %d-%d"
% (lo, hi - 1, ME, lo, hi - 1))
lines.append("#%d-%d %s" % (lo, hi - 1, s)) lines.append("#%d-%d %s" % (lo, hi - 1, s))
parts = paginate(lines) parts = paginate(lines)
if not 1 <= k <= len(parts): if not 1 <= k <= len(parts):
@ -712,6 +724,10 @@ def cmd_import(d, args):
date, _, text = line.partition(" ") date, _, text = line.partition(" ")
if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date): if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", date):
die("line %d: expected 'YYYY-MM-DD <text>', got: %s" % (i, line)) die("line %d: expected 'YYYY-MM-DD <text>', got: %s" % (i, line))
try:
datetime.datetime.strptime(date, "%Y-%m-%d")
except ValueError:
die("line %d: %s is not a real date." % (i, date))
if date < last: if date < last:
die("line %d: date %s precedes the previous memory (%s)." die("line %d: date %s precedes the previous memory (%s)."
% (i, date, last)) % (i, date, last))

51
test.py
View file

@ -431,6 +431,57 @@ r = run("wake", store=d2)
check(r.stdout.rstrip().endswith("You are awake."), check(r.stdout.rstrip().endswith("You are awake."),
"a one-part wake never says `You are awake.`:\n" + r.stdout) "a one-part wake never says `You are awake.`:\n" + r.stdout)
# a blank summary record (a corrupt write) is work nap cannot see: wake must
# name the one exit, `forget`, instead of refusing forever
d3 = tempfile.mkdtemp(prefix="optmem-blank-")
for i in range(4):
run("note", "corrupt store memory %d" % i, store=d3)
for bid, s in (("0-1", "one"), ("2-3", "two"), ("0-3", "all")):
run("nap", bid, s, store=d3)
with open(os.path.join(d3, "config"), "w") as f:
f.write("WAKE_LINES = 2\n")
with open(os.path.join(d3, "TREE", "2"), "r+b") as f:
f.write(b" " * 287 + b"\n")
r = run("wake", store=d3)
check(r.returncode == 1 and "forget 0-1" in r.stderr
and "None" not in r.stdout,
"a blank summary must point at forget:\n" + r.stdout + r.stderr)
# an unreadable level is a filesystem failure and must surface as one --
# reading it as "not compressed yet" offers work that cannot be done
os.chmod(os.path.join(d3, "TREE", "2"), 0)
r_ = subprocess.run(memo + ["wake"], capture_output=True, text=True,
env=dict(os.environ, MEMORY_DIR=d3))
check(r_.returncode == 1 and "Permission denied" in r_.stderr
and "not compressed" not in r_.stdout,
"an unreadable level was read as pending work: " + r_.stdout + r_.stderr)
os.chmod(os.path.join(d3, "TREE", "2"), 0o644)
# an impossible calendar date would poison every later import: the store's
# order check compares against it forever
with open(os.path.join(d3, "bad.txt"), "w") as f:
f.write("2027-99-99 an impossible date\n")
r = run("import", os.path.join(d3, "bad.txt"), store=d3)
check(r.returncode == 1 and "not a real date" in r.stderr,
"import accepted an impossible date: " + r.stdout + r.stderr)
shutil.rmtree(d3)
# the same blank-record dead end at the other site: a big block's half
d4 = tempfile.mkdtemp(prefix="optmem-half-")
for i in range(32):
run("note", "half probe memory %d" % i, store=d4)
while True:
r = run("nap", store=d4)
if "Compress memories #0-31 " in r.stdout:
break
run("nap", nap_id(r.stdout), "settled", store=d4)
with open(os.path.join(d4, "TREE", "16"), "r+b") as f:
f.write(b" " * 287 + b"\n")
r = run("nap", store=d4)
check(r.returncode == 1 and "forget 0-15" in r.stderr,
"a blank half summary must point at forget: " + r.stdout + r.stderr)
shutil.rmtree(d4)
# re-running init on a lived-in store must not touch one byte of it: the # re-running init on a lived-in store must not touch one byte of it: the
# whole setup is idempotent, so it is safe on every provision. `init` only # whole setup is idempotent, so it is safe on every provision. `init` only
# ever makedirs(exist_ok), opens LOG.txt for APPEND, and writes `config` # ever makedirs(exist_ok), opens LOG.txt for APPEND, and writes `config`