Files
pyxis/patch_filestore.py
T
torlando-techandClaude Opus 4.7 7d158a7595 fix(microStore): close active_file before unlinking segments (FD leak)
Local patch via patch_filestore.py for upstream attermann/microStore
issue: \`finalize_compaction()\` and \`clear()\` both call
\`_filesystem.remove()\` on segment files without first closing
\`active_file\`. On filesystems that don't auto-close FDs on unlink
(LittleFS / FAT) the descriptor leaks. Over enough compaction cycles
on pyxis the path-store eventually can't open new files.

Both \`open_segment()\` and \`rotate_segment_if_needed()\` already
close \`active_file\` before reopening — the unlink paths just
forgot. Added \`if (active_file) active_file.close();\` at the top
of each.

Validated: 2-round LXMF soak post-patch, 8 pass / 2 fail, identical
to pre-patch baseline (the 2 fails are the known propagation timing
flake unrelated to FD handling). Patch applies cleanly and re-applies
on every build via the pre-build hook.

Will be reverted once the upstream lands the same fix; tracked in
the vault TODO at "80 Assistant/Memory/pyxis/upstream_patches.md".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 03:16:12 -04:00

154 lines
6.5 KiB
Python

"""
PlatformIO pre-build script: patches the libdeps copy of microStore
FileStore.h before each build.
Three purposes:
1. Adds diagnostic printfs to `exists()` and `put()` for tracking down
the path-table-store bug where exists() returns false right after a
successful put for the same key. Temporary; remove once that
investigation is closed. Gated behind PYXIS_FILESTORE_DIAG=1.
2. Silences the spammy "[ustore] get: key not found in index" print
that fires on every path-table miss. RNS hits path-store lookups
constantly on every incoming packet — during an active LXST call
that print floods USB CDC at hundreds of lines/sec, saturating the
serial buffer and starving T:CALL_QOS responses (#75). The print
is unconditional in upstream microStore, so we patch it out here.
3. Closes `active_file` before unlinking segment files in
`finalize_compaction()` and `clear()`. Without this, on LittleFS
/ FAT (any FS that doesn't auto-close on unlink) the FD leaks —
over enough compactions the device can't open new files. Local
patch pending the upstream fix landing.
"""
Import("env")
import os
FILESTORE_H = os.path.join(
env.get("PROJECT_DIR", "."),
".pio", "libdeps", "tdeck", "microStore", "include", "microStore", "FileStore.h",
)
OLD = """\tbool exists(const uint8_t* key, uint8_t key_len)
\t{
if (!isValid()) return false;
\t\tif(key_len > USTORE_MAX_KEY_LEN) return false;
\t\tIndexValue* e = index_find(key, key_len);
\t\tif (!e) return false;
\t\tif (is_ttl_expired_(e->timestamp, e->ttl)) { index_remove(key, key_len); return false; }
\t\treturn true;
\t}"""
NEW = """\tbool exists(const uint8_t* key, uint8_t key_len)
\t{
\t\tif (!isValid()) { printf("[ustore] exists: !isValid len=%u idx_size=%zu store=%p\\n", (unsigned)key_len, _index.size(), (void*)this); return false; }
\t\tif(key_len > USTORE_MAX_KEY_LEN) { printf("[ustore] exists: key too long\\n"); return false; }
\t\tIndexValue* e = index_find(key, key_len);
\t\tif (!e) { printf("[ustore] exists: not_in_index len=%u key=%s idx_size=%zu store=%p\\n", (unsigned)key_len, bin_str(key, key_len), _index.size(), (void*)this); return false; }
\t\tif (is_ttl_expired_(e->timestamp, e->ttl)) { printf("[ustore] exists: ttl_expired ts=%u ttl=%u now=%u\\n", e->timestamp, e->ttl, microStore::time()); index_remove(key, key_len); return false; }
\t\tprintf("[ustore] exists: found key=%s store=%p\\n", bin_str(key, key_len), (void*)this);
\t\treturn true;
\t}"""
PUT_OLD = """\t\tindex_insert(key, key_len, current_segment, offset, ts, ttl);
\t\t// Enforce max_recs:"""
PUT_NEW = """\t\tindex_insert(key, key_len, current_segment, offset, ts, ttl);
\t\tprintf("[ustore] put: index_insert done key=%s idx_size=%zu store=%p\\n", bin_str(key, key_len), _index.size(), (void*)this);
\t\t// Enforce max_recs:"""
SPAMMY_OLD = '\t\t\tprintf("[ustore] get: key not found in index\\n");'
SPAMMY_NEW = '\t\t\t/* silenced — fires on every path-store miss, floods USB CDC */'
# FD-leak fix: close active_file before unlinking segments. Two sites.
FDLEAK_FINALIZE_OLD = """\tvoid finalize_compaction()
\t{
\t\tchar tmp_name[USTORE_MAX_FILENAME_LEN]; snprintf(tmp_name, sizeof(tmp_name), \"%s_compact.tmp\", base_prefix);
\t\tchar seg0[USTORE_MAX_FILENAME_LEN]; segment_name(0, seg0);
\t\t// Remove all existing segments, then rename tmp → seg0.
\t\tfor (uint32_t i = 0; i < _segment_count; i++) {
\t\t\tchar sname[USTORE_MAX_FILENAME_LEN]; segment_name(i, sname);
\t\t\t_filesystem.remove(sname);
\t\t}"""
FDLEAK_FINALIZE_NEW = """\tvoid finalize_compaction()
\t{
\t\tchar tmp_name[USTORE_MAX_FILENAME_LEN]; snprintf(tmp_name, sizeof(tmp_name), \"%s_compact.tmp\", base_prefix);
\t\tchar seg0[USTORE_MAX_FILENAME_LEN]; segment_name(0, seg0);
\t\t// pyxis-local: close active_file before unlinking, otherwise the FD
\t\t// leaks on LittleFS / FAT.
\t\tif (active_file) active_file.close();
\t\t// Remove all existing segments, then rename tmp → seg0.
\t\tfor (uint32_t i = 0; i < _segment_count; i++) {
\t\t\tchar sname[USTORE_MAX_FILENAME_LEN]; segment_name(i, sname);
\t\t\t_filesystem.remove(sname);
\t\t}"""
FDLEAK_CLEAR_OLD = """\t\tif (index_file) index_file.close();
\t\tfor(uint32_t i = 0; i < _segment_count; i++)
\t\t{
\t\t\tsegment_name(i,name);
\t\t\t_filesystem.remove(name);
\t\t}"""
FDLEAK_CLEAR_NEW = """\t\tif (index_file) index_file.close();
\t\t// pyxis-local: same active_file FD-leak fix as finalize_compaction().
\t\tif (active_file) active_file.close();
\t\tfor(uint32_t i = 0; i < _segment_count; i++)
\t\t{
\t\t\tsegment_name(i,name);
\t\t\t_filesystem.remove(name);
\t\t}"""
DIAG_ENABLED = os.environ.get("PYXIS_FILESTORE_DIAG", "0") == "1"
def patch(content):
out = content
# Silence patch always runs.
if SPAMMY_OLD in out:
out = out.replace(SPAMMY_OLD, SPAMMY_NEW)
print("PATCH: FileStore.h: silenced 'key not found in index' spam")
elif "silenced — fires on every path-store miss" in content:
print("PATCH: FileStore.h: 'key not found' spam already silenced")
# FD-leak fix always runs.
if FDLEAK_FINALIZE_OLD in out:
out = out.replace(FDLEAK_FINALIZE_OLD, FDLEAK_FINALIZE_NEW)
print("PATCH: FileStore.h: closed active_file in finalize_compaction()")
elif "pyxis-local: close active_file before unlinking" in content:
print("PATCH: FileStore.h: finalize_compaction() FD-leak fix already applied")
if FDLEAK_CLEAR_OLD in out:
out = out.replace(FDLEAK_CLEAR_OLD, FDLEAK_CLEAR_NEW)
print("PATCH: FileStore.h: closed active_file in clear()")
elif "pyxis-local: same active_file FD-leak fix" in content:
print("PATCH: FileStore.h: clear() FD-leak fix already applied")
# Diagnostic patches only when explicitly requested.
if not DIAG_ENABLED:
return out
if OLD in out:
out = out.replace(OLD, NEW)
print("PATCH: FileStore.h: exists() diagnostics added")
elif "store=%p" in content and "exists:" in content:
print("PATCH: FileStore.h: exists() already patched")
if PUT_OLD in out:
out = out.replace(PUT_OLD, PUT_NEW)
print("PATCH: FileStore.h: put-after-insert diagnostics added")
elif "put: index_insert done" in content:
print("PATCH: FileStore.h: put-after-insert already patched")
return out
if os.path.exists(FILESTORE_H):
with open(FILESTORE_H) as f:
content = f.read()
new = patch(content)
if new != content:
with open(FILESTORE_H, "w") as f:
f.write(new)
else:
print("PATCH: FileStore.h not found, skipping")