diff --git a/examples/companion_radio/DataStore.cpp b/examples/companion_radio/DataStore.cpp index 62025a24..8e43bdb2 100644 --- a/examples/companion_radio/DataStore.cpp +++ b/examples/companion_radio/DataStore.cpp @@ -2508,10 +2508,8 @@ bool DataStore::serviceContactWrite(DataStoreHost* host, } #endif _contact_write = new (std::nothrow) mesh::ContactFileTransaction( - _getContactsChannelsFS(), "/contacts3", companionPathPresence); - if (_contact_write == nullptr || !static_cast(*_contact_write)) { - delete _contact_write; - _contact_write = nullptr; + _getContactsChannelsFS(), "/contacts3", companionPathPresence, true); + if (_contact_write == nullptr) { #if MESH_CONTACT_CACHE paths.endCommit(false); #endif @@ -2524,6 +2522,17 @@ bool DataStore::serviceContactWrite(DataStoreHost* host, _contact_write_verifying = false; return true; } + if (!static_cast(*_contact_write)) { + // The revision/session guard above also applies to every setup step. + // Keep initialization separate from serialization, including its final + // buffer configuration, so transports are serviced between operations. + const auto progress = _contact_write->serviceBegin(); + if (progress == mesh::ContactFileTransaction::BeginProgress::Failed) { + cancelContactWrite(); + return false; + } + return true; + } if (!_contact_write_verifying) { #if MESH_CONTACT_CACHE ContactInfo* contact; diff --git a/src/helpers/ContactFileTransaction.h b/src/helpers/ContactFileTransaction.h index 6ddec2ca..b0b050a3 100644 --- a/src/helpers/ContactFileTransaction.h +++ b/src/helpers/ContactFileTransaction.h @@ -28,6 +28,16 @@ private: uint32_t _verify_crc = 0xffffffff; size_t _verify_remaining = 0; PresenceProbe _presence; +#if defined(ESP32_PLATFORM) + enum class BeginStage : uint8_t { + Target, Backup, Recover, Temp, Remove, Open, Configure, Ready, Failed + }; + BeginStage _begin_stage = BeginStage::Target; + bool _begin_target_exists = false; + bool _begin_backup_exists = false; + bool _begin_temp_exists = false; + bool _owns_temp = false; +#endif static bool probe(FILESYSTEM* fs, const char* path, bool& present, PresenceProbe presence) { if (presence) return presence(fs, path, present); @@ -52,10 +62,21 @@ public: return !backup_exists || fs->rename(backup, target); } ContactFileTransaction(FILESYSTEM* fs, const char* target, - PresenceProbe presence = nullptr) + PresenceProbe presence = nullptr +#if defined(ESP32_PLATFORM) + , bool defer_begin = false +#endif + ) : _fs(fs), _target(target), _presence(presence) { snprintf(_temp, sizeof(_temp), "%s.tmp", target); snprintf(_backup, sizeof(_backup), "%s.bak", target); +#if defined(ESP32_PLATFORM) + // Explicit durability callers retain synchronous construction. The lazy + // contact writer requests one setup operation per transport-serviced pass. + if (!defer_begin) { + while (serviceBegin() == BeginProgress::Pending) {} + } +#else if (!recover(fs, target, presence)) return; if (!removeIfPresent(fs, _temp, presence)) return; #if defined(RP2040_PLATFORM) @@ -64,19 +85,75 @@ public: _file = fs->open(_temp, "w", true); #endif _ok = static_cast(_file); -#if defined(ESP32_PLATFORM) - // Arduino's default 4 KiB stdio buffer hides large SPIFFS writes inside a - // single small write() pass. Configure this newly opened stream before - // its first I/O, keeping one contact record as the largest buffered batch. - _ok = _ok && _file.setBufferSize(storage::CONTACT_RECORD_SIZE); #endif } ~ContactFileTransaction() { if (_file) _file.close(); if (_verify) _verify.close(); - if (!_finished) removeIfPresent(_fs, _temp, _presence); + if (!_finished +#if defined(ESP32_PLATFORM) + && _owns_temp +#endif + ) removeIfPresent(_fs, _temp, _presence); } operator bool() const { return _ok; } +#if defined(ESP32_PLATFORM) + enum class BeginProgress : uint8_t { Pending, Ready, Failed }; + BeginProgress serviceBegin() { + bool ok = true; + switch (_begin_stage) { + case BeginStage::Target: + ok = probe(_fs, _target, _begin_target_exists, _presence); + if (ok) _begin_stage = BeginStage::Backup; + break; + case BeginStage::Backup: + ok = probe(_fs, _backup, _begin_backup_exists, _presence); + if (ok) _begin_stage = BeginStage::Recover; + break; + case BeginStage::Recover: + if (!_begin_target_exists && _begin_backup_exists) + ok = _fs->rename(_backup, _target); + if (ok) _begin_stage = BeginStage::Temp; + break; + case BeginStage::Temp: + ok = probe(_fs, _temp, _begin_temp_exists, _presence); + if (ok) _begin_stage = BeginStage::Remove; + break; + case BeginStage::Remove: + if (_begin_temp_exists) ok = _fs->remove(_temp); + if (ok) _begin_stage = BeginStage::Open; + break; + case BeginStage::Open: + // The previous stages proved this name absent. Even an unsuccessful + // create/open can leave a partial inode belonging to this attempt. + // Before this point cancellation must leave an unowned stale temp. + _owns_temp = true; + _file = _fs->open(_temp, "w", true); + ok = static_cast(_file); + if (ok) _begin_stage = BeginStage::Configure; + break; + case BeginStage::Configure: + // Arduino otherwise batches small writes into a 4 KiB stdio buffer. + // Configure the newly opened stream before its first I/O. + ok = _file.setBufferSize(storage::CONTACT_RECORD_SIZE); + if (ok) { + _ok = true; + _begin_stage = BeginStage::Ready; + return BeginProgress::Ready; + } + break; + case BeginStage::Ready: + return BeginProgress::Ready; + case BeginStage::Failed: + return BeginProgress::Failed; + } + if (!ok) { + _begin_stage = BeginStage::Failed; + return BeginProgress::Failed; + } + return BeginProgress::Pending; + } +#endif bool readyToPublish() const { return !_finished && _commit_stage == CommitStage::Publish; } @@ -92,6 +169,11 @@ public: // service its transports between passes without exposing the rename gap. // A synchronous durability operation uses commit() to drain the same steps. CommitProgress serviceCommit(bool valid = true) { +#if defined(ESP32_PLATFORM) + // A premature commit must not publish, clean up, or implicitly aggregate + // the deferred setup operations inside this call. + if (_begin_stage != BeginStage::Ready) return CommitProgress::Failed; +#endif if (_finished) return CommitProgress::Failed; bool ok = _ok && valid; if (ok && _commit_stage == CommitStage::Flush) { diff --git a/test/fixtures/contact_cache/FakeFilesystem.h b/test/fixtures/contact_cache/FakeFilesystem.h index a17385d5..d5508ef6 100644 --- a/test/fixtures/contact_cache/FakeFilesystem.h +++ b/test/fixtures/contact_cache/FakeFilesystem.h @@ -57,6 +57,8 @@ public: unsigned buffer_config_calls = 0, fail_buffer_config = 0; std::string fail_read; bool metadata_error = false, fail_create = false, fail_remove = false; + bool partial_create_failure = false; + unsigned metadata_probes = 0, fail_metadata_probe = 0; unsigned removes = 0, missing_remove_logs = 0; unsigned fail_rename = 0, renames = 0, writes = 0, reads = 0, opens = 0; std::vector rename_snapshots; @@ -73,13 +75,19 @@ public: File open(const char* path, const char* mode = "r", bool = false) { ++opens; if (*mode != 'r') { - if (fail_create) return File(); + if (fail_create) { + if (partial_create_failure) files[path].clear(); + return File(); + } files[path].clear(); return File(this, path, true); } if (fail_read == path || !exists(path)) return File(); return File(this, path, false); } + unsigned startupOperations() const { + return metadata_probes + opens + removes + renames + buffer_config_calls; + } bool rename(const char* from, const char* to) { ++renames; if (renames == fail_rename || !exists(from)) return false; diff --git a/test/fixtures/contact_cache/test_contact_cache.cpp b/test/fixtures/contact_cache/test_contact_cache.cpp index ef993c93..c346c875 100644 --- a/test/fixtures/contact_cache/test_contact_cache.cpp +++ b/test/fixtures/contact_cache/test_contact_cache.cpp @@ -103,6 +103,13 @@ public: bool releaseContact(const ContactInfo&); bool hasPendingContactWrites() const; bool serviceContactWrites(DataStoreHost*, bool (*filter)(const ContactInfo&)); + bool starting() const { + return _contact_write != nullptr && !static_cast(*_contact_write); + } + bool activeJob() const { return _contact_write != nullptr; } + bool readyToPublish() const { + return _contact_write && _contact_write->readyToPublish(); + } #endif #if defined(NRF52_PLATFORM) bool loadContactPages(DataStoreHost*, uint16_t, uint32_t); @@ -460,11 +467,13 @@ static void cooperative_save_services_commands_and_preserves_snapshots() { } static void cooperative_mutations_never_publish_obsolete_indices() { - for (unsigned mutation_step : {4u, 46u, 110u, 140u}) { + for (unsigned mutation_step : {4u, 46u, 110u, 0u}) { Fixture f(40); const auto old = SPIFFS.files.at("/contacts3"); assert(f.store.markContactDirty(f.host.contacts[0])); - for (unsigned step = 0; step < mutation_step; ++step) { + unsigned step = 0; + while (mutation_step == 0 ? !f.store.readyToPublish() : step < mutation_step) { + assert(++step < 1000); assert(f.store.serviceContactWrites(&f.host, cachedContactFilter)); assert(SPIFFS.files.at("/contacts3") == old); } @@ -507,6 +516,7 @@ static void cooperative_failures_keep_the_old_file_and_pending_mutations() { failed = !f.store.serviceContactWrites(&f.host, cachedContactFilter); } assert(failed && f.store.hasPendingContactWrites()); + assert(!f.store.activeJob()); assert(SPIFFS.files.at("/contacts3") == old); assert(!SPIFFS.exists("/contacts3.tmp")); f.check(0, route(993)); @@ -555,6 +565,31 @@ static void interleaved_path_pressure_preserves_guarded_eviction() { f.check(i, route(i < 26 ? 1200 + i : i)); } +static void pending_startup_pressure_preserves_guarded_eviction() { + for (unsigned completed : {0u, 3u, 6u}) { + Fixture f(40); + for (unsigned i = 0; i < 16; ++i) { + assert(f.host.contacts[i].setRawPath(route(1600 + i).data())); + assert(f.store.markContactDirty(f.host.contacts[i])); + } + assert(f.host.cache_flushes == 0); + SPIFFS.files["/contacts3.tmp"] = {8, 9}; + assert(f.store.serviceContactWrites(&f.host, cachedContactFilter)); // allocate + for (unsigned pass = 0; pass < completed; ++pass) + assert(f.store.serviceContactWrites(&f.host, cachedContactFilter)); + assert(f.store.starting()); + // The seventeenth resident update must cancel a pending begin, release + // _committing, then run the existing guarded synchronous eviction flush. + assert(f.host.contacts[16].setRawPath(route(1616).data())); + assert(f.host.cache_flushes == 1); + assert(!f.store.activeJob()); + assert(f.store.markContactDirty(f.host.contacts[16])); + drain_cooperative_save(f); + for (unsigned i = 0; i < 40; ++i) + f.check(i, route(i < 17 ? 1600 + i : i)); + } +} + static void lifecycle_cancels_jobs_and_closes_prior_filesystem_readers() { { Fixture f(30); @@ -755,6 +790,7 @@ int main() { cooperative_failures_keep_the_old_file_and_pending_mutations(); synchronous_flush_cancels_pending_jobs_and_stays_durable(); interleaved_path_pressure_preserves_guarded_eviction(); + pending_startup_pressure_preserves_guarded_eviction(); lifecycle_cancels_jobs_and_closes_prior_filesystem_readers(); routes_survive_eviction_and_full_sync(); snapshot_rollback_and_dirty_eviction(); diff --git a/test/fixtures/contact_persistence/test_esp32_uncached.cpp b/test/fixtures/contact_persistence/test_esp32_uncached.cpp index a5e40664..42c55eb1 100644 --- a/test/fixtures/contact_persistence/test_esp32_uncached.cpp +++ b/test/fixtures/contact_persistence/test_esp32_uncached.cpp @@ -12,7 +12,10 @@ static_assert(MESH_CONTACT_CACHE == 0, // Replace only the SDK metadata syscall; the production presence/error logic // and transaction implementation are compiled unchanged. int fixtureStat(FakeFilesystem* fs, const char* vfs_path, struct stat*) { - if (fs->metadata_error) { errno = EIO; return -1; } + ++fs->metadata_probes; + if (fs->metadata_error || fs->metadata_probes == fs->fail_metadata_probe) { + errno = EIO; return -1; + } assert(strncmp(vfs_path, "/spiffs", 7) == 0); if (!fs->exists(vfs_path + 7)) { errno = ENOENT; return -1; } return 0; @@ -63,6 +66,10 @@ public: bool hasPendingContactWrites() const; bool deleteBlobByKey(const uint8_t[], int); bool verifying() const { return _contact_write_verifying; } + bool starting() const { + return _contact_write == nullptr || !static_cast(*_contact_write); + } + bool activeJob() const { return _contact_write != nullptr; } bool readyToPublish() const { return _contact_write && _contact_write->readyToPublish(); } @@ -116,8 +123,12 @@ unsigned drain(DataStore& store, Host& h, Filter filter = nullptr) { while (store.hasPendingContactWrites()) { const unsigned writes = SPIFFS.writes, reads = SPIFFS.reads; const unsigned backend_writes = SPIFFS.backend_writes, backend_reads = SPIFFS.backend_reads; + const bool starting = store.starting(); + const unsigned startup_before = SPIFFS.startupOperations(); assert(++passes < 5000); assert(store.serviceContactWrites(&h, filter)); + const unsigned startup_operations = SPIFFS.startupOperations() - startup_before; + if (starting) assert(startup_operations <= 1); assert(SPIFFS.writes - writes <= 1); assert(SPIFFS.reads - reads <= 1); assert(SPIFFS.backend_writes - backend_writes <= 1); @@ -163,6 +174,152 @@ void boundedFullTableAndFirstSave() { bootCheck(h, keepContact); } +void deferredBeginCancellationAndCrashRecovery() { + using Writer = mesh::ContactFileTransaction; + // Observe/destruct at every startup boundary, including before any recovery + // and after open but before setBufferSize. A stale temp is never authoritative. + for (unsigned completed = 0; completed <= 7; ++completed) { + SPIFFS = FakeFilesystem(); + SPIFFS.emulate_stdio = true; + const Host h = table(1); + const auto old = image(h); + const std::vector stale = {9, 8, 7}; + SPIFFS.files["/contacts3"] = old; + SPIFFS.files["/contacts3.tmp"] = stale; + unsigned before_destructor = 0; + { + Writer writer(&SPIFFS, "/contacts3", companionPathPresence, true); + assert(SPIFFS.startupOperations() == 0 && !writer); + for (unsigned pass = 0; pass < completed; ++pass) { + const unsigned before = SPIFFS.startupOperations(); + const auto progress = writer.serviceBegin(); + assert(progress != Writer::BeginProgress::Failed); + const unsigned startup_operations = SPIFFS.startupOperations() - before; + assert(startup_operations <= 1); + assert(SPIFFS.files.at("/contacts3") == old); + // A physical reset at this boundary sees the prior complete image. + FakeFilesystem rebooted; + rebooted.files = SPIFFS.files; + assert(Writer::recover(&rebooted, "/contacts3", companionPathPresence)); + assert(rebooted.files.at("/contacts3") == old); + } + if (completed < 7) { + const unsigned before = SPIFFS.startupOperations(); + assert(!writer); + assert(writer.write(old.data(), old.size()) == 0); + assert(writer.serviceCommit() == Writer::CommitProgress::Failed); + assert(!writer.commit()); + assert(SPIFFS.startupOperations() == before); // no implicit startup/cleanup + } else { + assert(writer); + } + before_destructor = SPIFFS.startupOperations(); + } + if (completed < 6) { + assert(SPIFFS.startupOperations() == before_destructor); // never owned a temp + } + if (completed < 5) assert(SPIFFS.files.at("/contacts3.tmp") == stale); + else assert(!SPIFFS.exists("/contacts3.tmp")); + assert(SPIFFS.files.at("/contacts3") == old && SPIFFS.missing_remove_logs == 0); + bootCheck(h); + } + // An interrupted prior publication recovers identically with deferred setup. + SPIFFS = FakeFilesystem(); + const Host h = table(1); + const auto old = image(h); + SPIFFS.files["/contacts3.bak"] = old; + { + Writer writer(&SPIFFS, "/contacts3", companionPathPresence, true); + for (unsigned pass = 0; pass < 7; ++pass) { + assert(writer.serviceBegin() != Writer::BeginProgress::Failed); + FakeFilesystem rebooted; + rebooted.files = SPIFFS.files; + assert(Writer::recover(&rebooted, "/contacts3", companionPathPresence)); + assert(rebooted.files.at("/contacts3") == old); + } + assert(writer && SPIFFS.files.at("/contacts3") == old); + } + bootCheck(h); +} + +void deferredBeginFaultsAndSynchronousDefaults() { + using Writer = mesh::ContactFileTransaction; + for (unsigned fault = 0; fault < 8; ++fault) { + SPIFFS = FakeFilesystem(); + SPIFFS.emulate_stdio = true; + const Host h = table(1); + const auto old = image(h); + const std::vector stale = {1, 2, 3}; + SPIFFS.files["/contacts3"] = old; + SPIFFS.files["/contacts3.tmp"] = stale; + if (fault == 0) SPIFFS.fail_metadata_probe = 1; // target + if (fault == 1) SPIFFS.fail_metadata_probe = 2; // backup + if (fault == 2) { + SPIFFS.files.erase("/contacts3"); + SPIFFS.files["/contacts3.bak"] = old; + SPIFFS.fail_rename = 1; // failed interrupted-publication recovery + } + if (fault == 3) SPIFFS.fail_metadata_probe = 3; // stale temp + if (fault == 4) SPIFFS.fail_remove = true; + if (fault == 5 || fault == 6) SPIFFS.fail_create = true; + if (fault == 6) SPIFFS.partial_create_failure = true; + if (fault == 7) SPIFFS.fail_buffer_config = 1; + { + Writer writer(&SPIFFS, "/contacts3", companionPathPresence, true); + auto progress = Writer::BeginProgress::Pending; + for (unsigned pass = 0; pass < 8 && progress == Writer::BeginProgress::Pending; ++pass) + progress = writer.serviceBegin(); + assert(progress == Writer::BeginProgress::Failed && !writer); + assert(writer.write(old.data(), old.size()) == 0 && !writer.commit()); + } + if (fault <= 4) assert(SPIFFS.files.at("/contacts3.tmp") == stale); + else assert(!SPIFFS.exists("/contacts3.tmp")); // includes a failed partial create + if (fault == 2) assert(SPIFFS.files.at("/contacts3.bak") == old); + else assert(SPIFFS.files.at("/contacts3") == old); + assert(SPIFFS.missing_remove_logs == 0); + SPIFFS.fail_metadata_probe = SPIFFS.fail_rename = SPIFFS.fail_buffer_config = 0; + SPIFFS.fail_create = SPIFFS.fail_remove = false; + // All pre-existing callers still finish initialization inside construction. + Writer synchronous(&SPIFFS, "/contacts3", companionPathPresence); + assert(synchronous && SPIFFS.files.at("/contacts3") == old); + assert(synchronous.write(old.data(), old.size()) == old.size()); + assert(synchronous.commit()); + assert(SPIFFS.files.at("/contacts3") == old); + bootCheck(h); + } +} + +void mutationDuringEveryBeginStage() { + for (unsigned completed = 0; completed <= 7; ++completed) { + SPIFFS = FakeFilesystem(); + SPIFFS.emulate_stdio = true; + Host h = table(); + const auto old = image(h); + const std::vector stale = {4, 5, 6}; + SPIFFS.files["/contacts3"] = old; + SPIFFS.files["/contacts3.tmp"] = stale; + DataStore store(SPIFFS); + assert(store.markContactDirty(h.contacts[0])); + assert(store.serviceContactWrites(&h, nullptr)); // allocate only + assert(SPIFFS.startupOperations() == 0); + for (unsigned pass = 0; pass < completed; ++pass) + assert(store.serviceContactWrites(&h, nullptr)); + const unsigned opens = SPIFFS.opens, configurations = SPIFFS.buffer_config_calls; + const unsigned before_cancel = SPIFFS.startupOperations(); + h.contacts[2] = contact(909); + assert(store.markContactDirty(h.contacts[2])); + assert(store.serviceContactWrites(&h, nullptr)); // guard precedes every begin step + assert(store.hasPendingContactWrites()); + assert(SPIFFS.opens == opens && SPIFFS.buffer_config_calls == configurations); + if (completed < 6) assert(SPIFFS.startupOperations() == before_cancel); + if (completed < 5) assert(SPIFFS.files.at("/contacts3.tmp") == stale); + else assert(!SPIFFS.exists("/contacts3.tmp")); + assert(SPIFFS.files.at("/contacts3") == old); + drain(store, h); + bootCheck(h); + } +} + void mutationRestart(unsigned phase) { SPIFFS = FakeFilesystem(); SPIFFS.emulate_stdio = true; @@ -172,7 +329,7 @@ void mutationRestart(unsigned phase) { DataStore store(SPIFFS); assert(store.markContactDirty(h.contacts[0])); unsigned passes = 0; - while ((phase == 0 && passes < 5) + while ((phase == 0 && SPIFFS.writes < 5) || (phase == 1 && (!store.verifying() || SPIFFS.reads == 0)) || (phase == 2 && !store.readyToPublish())) { assert(++passes < 1000); @@ -222,6 +379,7 @@ void faultAndRetry(unsigned fault) { success = store.serviceContactWrites(&h, nullptr); } assert(injected && !success); + assert(!store.activeJob()); // no Ready/_ok=false job can spin in initialization assert(store.hasPendingContactWrites()); assert(SPIFFS.files.at("/contacts3") == old); assert(!SPIFFS.exists("/contacts3.tmp")); @@ -359,6 +517,9 @@ void expectedAbsenceAndRealDeleteFailures() { int main() { boundedFullTableAndFirstSave(); + deferredBeginCancellationAndCrashRecovery(); + deferredBeginFaultsAndSynchronousDefaults(); + mutationDuringEveryBeginStage(); for (unsigned phase = 0; phase < 3; ++phase) mutationRestart(phase); for (unsigned fault = 0; fault < 10; ++fault) faultAndRetry(fault); syncFlushAndFilterChange(); diff --git a/test/test_esp32_cooperative_contacts.py b/test/test_esp32_cooperative_contacts.py index 6acb7bdb..81890adf 100644 --- a/test/test_esp32_cooperative_contacts.py +++ b/test/test_esp32_cooperative_contacts.py @@ -22,6 +22,9 @@ class UncachedESPContactsTest(unittest.TestCase): def test_default_stdio_reader_readahead_is_detected(self): self.run_fixture(["-DBOARD_HAS_PSRAM=1"], disable="reader") + def test_synchronous_startup_batch_is_detected(self): + self.run_fixture(["-DBOARD_HAS_PSRAM=1"], disable="begin") + def run_fixture(self, policy, disable=None): with tempfile.TemporaryDirectory(prefix="mesh-esp-inline-contacts-") as directory: temp = Path(directory) @@ -47,6 +50,11 @@ class UncachedESPContactsTest(unittest.TestCase): "inline void makeBlobPath(", ) implementation = "\n".join(method(source, signature) for signature in signatures) + if disable == "begin": + self.assertIn('"/contacts3", companionPathPresence, true)', implementation) + implementation = implementation.replace( + '"/contacts3", companionPathPresence, true)', + '"/contacts3", companionPathPresence, false)') # Only the native SDK stat seam is substituted. The production # presence logic still distinguishes ENOENT from metadata failure. presence = method(source, "static bool companionPathPresence(") @@ -64,10 +72,10 @@ class UncachedESPContactsTest(unittest.TestCase): (temp / "store_under_test.h").write_text(implementation) transaction = (ROOT / "src/helpers/ContactFileTransaction.h").read_text() if disable == "writer": - self.assertIn("_ok = _ok && _file.setBufferSize(storage::CONTACT_RECORD_SIZE);", transaction) + self.assertIn("ok = _file.setBufferSize(storage::CONTACT_RECORD_SIZE);", transaction) transaction = transaction.replace( - "_ok = _ok && _file.setBufferSize(storage::CONTACT_RECORD_SIZE);", - "// Negative control: retain the SDK's default writer buffer.") + "ok = _file.setBufferSize(storage::CONTACT_RECORD_SIZE);", + "ok = true; // Negative control: retain default writer buffer.") elif disable == "reader": self.assertIn("ok = ok && _verify.setBufferSize(64);", transaction) transaction = transaction.replace("ok = ok && _verify.setBufferSize(64);", @@ -105,8 +113,9 @@ class UncachedESPContactsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stdout + result.stderr) else: self.assertNotEqual(result.returncode, 0) - self.assertIn("largest_backend_" + ("write" if disable == "writer" else "read"), - result.stderr) + marker = "startup_operations" if disable == "begin" else ( + "largest_backend_" + ("write" if disable == "writer" else "read")) + self.assertIn(marker, result.stderr) if __name__ == "__main__":