Files
wadamesh/test/test_buffered_file_commit.cpp
Kaj SchittecatandClaude Opus 5.5 82226f21ac touch: Save update bin to SD checks the written file after closing it (#734)
beta_100 downloaded the bin into PSRAM and wrote it to the card fine, then
judged each attempt with f.size() on the still-open File. On arduino-esp32
File::size() of a written file is stat(path), and FAT writes the size into
the directory entry only on sync/close, so a fresh file reads 0 bytes. The
check failed all three attempts on every card and board, right after the
progress reached 100%, and each "failed" copy was deleted.

BufferedFileCommit::FsSink now closes the file and reopens it to compare
the size (closeAndCheckSize), which also catches a tail lost in close()'s
final flush. The streamed fallback (not enough PSRAM for the whole image)
uses the same check instead of trusting close(), and its write error now
says how far it got ("SD write failed at N KB") so the two paths can be
told apart in a report.

Host test: test_buffered_file_commit drives FsSink over a fake FS that
follows VFSFileImpl's size()/stdio-buffer rules. The same sequence was also
run against real FatFs (ESP-IDF ff.c) behind a transcribed VFSFileImpl on
16 MB FAT12/FAT16 and 4/32 GB FAT32 images: the old check sees 0 0 0, the
new one commits and reads back identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 08:18:17 +01:00

229 lines
9.5 KiB
C++

// Host test for helpers/BufferedFileCommit.h (#734: "Save update bin to SD").
// Build: g++ -std=c++17 -I src -o /tmp/t test/test_buffered_file_commit.cpp && /tmp/t
//
// The fake file behaves like a FAT file on a flaky card: a write can be short, and
// once a write fails the file stays failed (FatFs keeps the error on the file object)
// until it is closed and opened again.
//
// The second half drives FsSink (the production Sink) over a fake fs::FS/fs::File that
// copies arduino-esp32 2.0.17's VFSFileImpl rules: a 4 KB stdio buffer in front of
// FatFs, size() of a written file = stat(path) = the DIRECTORY ENTRY, which FAT only
// updates on close. beta_100 asked the open file for its size and read 0 every time.
#include <assert.h>
#include <stdio.h>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include "helpers/BufferedFileCommit.h"
struct FakeCard {
std::vector<uint8_t> file; // what is on the card
bool exists = false;
bool is_open = false;
bool sticky_error = false;
int opens = 0, discards = 0, pauses = 0;
size_t max_take = 0; // >0: each write takes at most this many bytes
std::vector<long> fail_at; // per open: byte offset whose write fails (-1 = never)
std::vector<int> open_fails; // per open: 1 = open() fails
size_t last_progress = 0;
bool progress_monotonic = true;
bool open() {
const int k = opens++;
if (k < (int)open_fails.size() && open_fails[k]) return false;
file.clear(); exists = true; is_open = true; sticky_error = false;
return true;
}
size_t write(const uint8_t* p, size_t n) {
assert(is_open);
if (sticky_error) return 0;
const int k = opens - 1;
const long fa = k < (int)fail_at.size() ? fail_at[k] : -1;
size_t take = n;
if (max_take && take > max_take) take = max_take;
if (fa >= 0 && (long)file.size() <= fa && (long)(file.size() + take) > fa) {
sticky_error = true;
return 0;
}
file.insert(file.end(), p, p + take);
return take;
}
bool close() { is_open = false; return !sticky_error; }
void discard() { assert(!is_open); file.clear(); exists = false; ++discards; }
void pause(int) { ++pauses; }
void progress(size_t done, size_t total) {
assert(total > 0 && done <= total);
if (done < last_progress) progress_monotonic = false;
last_progress = done;
}
};
static std::vector<uint8_t> payload(size_t n) {
std::vector<uint8_t> v(n);
for (size_t i = 0; i < n; ++i) v[i] = (uint8_t)(i * 31u + 7u);
return v;
}
// ---- fake arduino-esp32 FS (VFSFileImpl over FatFs, see the header comment) ----
struct FakeVfs {
struct Entry { std::vector<uint8_t> data; size_t dir_size = 0; }; // data = FIL contents
std::map<std::string, Entry> files;
size_t fail_tail_at_close = 0; // >0: the final flush in close() loses this many bytes
int opens_w = 0, removes = 0;
};
struct FakeImpl {
FakeVfs* vfs; std::string path; bool writing; std::vector<uint8_t> stdio_buf;
bool written = false; size_t stat_size = 0; bool open = true;
size_t write(const uint8_t* p, size_t n) { // fwrite into a 4096-byte FILE buffer
written = true;
auto& e = vfs->files[path];
for (size_t i = 0; i < n; ++i) {
stdio_buf.push_back(p[i]);
if (stdio_buf.size() == 4096) { e.data.insert(e.data.end(), stdio_buf.begin(), stdio_buf.end()); stdio_buf.clear(); }
}
return n;
}
size_t size() { // VFSFileImpl::size(): stat(path) once written
if (written) { stat_size = vfs->files[path].dir_size; written = false; }
return stat_size;
}
void close() { // fclose: flush, f_close writes the dir entry
if (!open) return;
open = false;
if (!writing) return;
auto& e = vfs->files[path];
size_t keep = stdio_buf.size();
if (vfs->fail_tail_at_close) { keep = keep > vfs->fail_tail_at_close ? keep - vfs->fail_tail_at_close : 0; vfs->fail_tail_at_close = 0; }
e.data.insert(e.data.end(), stdio_buf.begin(), stdio_buf.begin() + keep);
stdio_buf.clear();
e.dir_size = e.data.size();
}
};
struct FakeFile {
std::shared_ptr<FakeImpl> p;
explicit operator bool() const { return p && p->open; }
size_t write(const uint8_t* b, size_t n) { return p ? p->write(b, n) : 0; }
size_t size() { return p ? p->size() : 0; }
bool isDirectory() const { return false; }
void close() { if (p) p->close(); }
};
struct FakeFs {
FakeVfs& v;
FakeFile open(const char* path, const char* mode) {
FakeFile f;
const bool w = mode[0] == 'w';
if (w) { ++v.opens_w; v.files[path] = FakeVfs::Entry(); } // FA_CREATE_ALWAYS: size 0
else if (!v.files.count(path)) return f;
f.p = std::make_shared<FakeImpl>();
f.p->vfs = &v; f.p->path = path; f.p->writing = w;
f.p->stat_size = v.files[path].dir_size; // stat at open
return f;
}
bool remove(const char* path) { ++v.removes; return v.files.erase(path) > 0; }
};
struct Hooks {
int pauses = 0; size_t last = 0;
void pause(int) { ++pauses; }
void progress(size_t d, size_t) { last = d; }
};
static void arduinoFsCases() {
const std::vector<uint8_t> img = payload(3212345); // about a wadamesh app bin
uint8_t bounce[4096];
const char* path = "/BINS/wadamesh-beta_100-beta.bin";
{ // the beta_100 check, reproduced: the open file reports 0 bytes, not the image size
FakeVfs v; FakeFs fs{v};
FakeFile f = fs.open(path, "w");
assert(f.write(img.data(), img.size()) == img.size());
assert(f.size() == 0); // directory entry not written yet
f.close();
FakeFile r = fs.open(path, "r");
assert(r && r.size() == img.size()); // after close it is
}
{ // FsSink: one attempt, the card holds the exact image
FakeVfs v; FakeFs fs{v}; Hooks h;
BufferedFileCommit::FsSink<FakeFs, FakeFile, Hooks> sink{fs, path, img.size(), h, FakeFile()};
assert(BufferedFileCommit::commit(sink, img.data(), img.size(), bounce, sizeof bounce, 3));
assert(v.opens_w == 1 && h.pauses == 0 && h.last == img.size());
assert(v.files[path].data == img);
}
{ // the tail is lost in close()'s final flush: caught, retried from byte 0
FakeVfs v; FakeFs fs{v}; Hooks h; v.fail_tail_at_close = 100;
BufferedFileCommit::FsSink<FakeFs, FakeFile, Hooks> sink{fs, path, img.size(), h, FakeFile()};
assert(BufferedFileCommit::commit(sink, img.data(), img.size(), bounce, sizeof bounce, 3));
assert(v.opens_w == 2 && v.removes == 1 && h.pauses == 1);
assert(v.files[path].data == img);
}
{ // closeAndCheckSize on a short file says so (the streamed path uses it too)
FakeVfs v; FakeFs fs{v};
FakeFile f = fs.open(path, "w");
f.write(img.data(), 5000);
assert(!BufferedFileCommit::closeAndCheckSize(fs, f, path, img.size()));
FakeFile g = fs.open(path, "w");
g.write(img.data(), 5000);
assert(BufferedFileCommit::closeAndCheckSize(fs, g, path, 5000));
}
}
int main() {
const std::vector<uint8_t> img = payload(10000);
uint8_t bounce[1024];
{ // clean card: one open, exact bytes, no retries
FakeCard c;
assert(BufferedFileCommit::commit(c, img.data(), img.size(), bounce, sizeof bounce, 3));
assert(c.opens == 1 && c.discards == 0 && c.pauses == 0);
assert(c.file == img);
assert(c.progress_monotonic && c.last_progress == img.size());
}
{ // short writes are pushed through, not treated as failure
FakeCard c; c.max_take = 100;
assert(BufferedFileCommit::commit(c, img.data(), img.size(), bounce, sizeof bounce, 3));
assert(c.opens == 1 && c.file == img);
}
{ // the #734 case: a write fails mid-file and the file stays failed; the retry
// starts over from byte 0 and the card ends up with the whole image
FakeCard c; c.fail_at = {4500, -1};
assert(BufferedFileCommit::commit(c, img.data(), img.size(), bounce, sizeof bounce, 3));
assert(c.opens == 2 && c.discards == 1 && c.pauses == 1);
assert(c.exists && c.file == img);
}
{ // fails twice, third attempt succeeds
FakeCard c; c.fail_at = {0, 9999, -1};
assert(BufferedFileCommit::commit(c, img.data(), img.size(), bounce, sizeof bounce, 3));
assert(c.opens == 3 && c.discards == 2 && c.file == img);
}
{ // fails every time: false, and no partial file is left for the Launcher
FakeCard c; c.fail_at = {100, 200, 300};
assert(!BufferedFileCommit::commit(c, img.data(), img.size(), bounce, sizeof bounce, 3));
assert(c.opens == 3 && c.discards == 3 && !c.exists && c.file.empty());
}
{ // open fails once, then works
FakeCard c; c.open_fails = {1, 0};
assert(BufferedFileCommit::commit(c, img.data(), img.size(), bounce, sizeof bounce, 3));
assert(c.opens == 2 && c.discards == 0 && c.file == img);
}
{ // a payload that is an exact multiple of the bounce size, and an empty one
const std::vector<uint8_t> even = payload(4096);
FakeCard c;
assert(BufferedFileCommit::commit(c, even.data(), even.size(), bounce, sizeof bounce, 1));
assert(c.file == even);
FakeCard e;
assert(BufferedFileCommit::commit(e, even.data(), 0, bounce, sizeof bounce, 1));
assert(e.exists && e.file.empty());
}
{ // bad arguments never touch the card
FakeCard c;
assert(!BufferedFileCommit::commit(c, nullptr, 10, bounce, sizeof bounce, 3));
assert(!BufferedFileCommit::commit(c, img.data(), img.size(), nullptr, 16, 3));
assert(!BufferedFileCommit::commit(c, img.data(), img.size(), bounce, 0, 3));
assert(!BufferedFileCommit::commit(c, img.data(), img.size(), bounce, sizeof bounce, 0));
assert(c.opens == 0);
}
arduinoFsCases();
printf("buffered_file_commit: all passed\n");
return 0;
}