From 18675f3e45fa0896efd777bf66ad0ec4c8157d36 Mon Sep 17 00:00:00 2001 From: eunhhu Date: Fri, 25 Sep 2026 15:49:48 +0100 Subject: Keep app states in the tree store When the data host keeps states (PureData.state and .save, implemented by pure_tree_data), every state of an app lives in the store: a text state's draft is its stored value, a list mirrors each change into a stored draft (record header, then items), and numbers, cursors and indices are stored together by name. An app starts from what is stored, moves list fields by name when a record changed, resets cursors and indices that no longer fit, and refuses damaged stored values. Each key, text and message event publishes the states it changed together with the message's document in one store commit, or fails and leaves memory and store as they were. Frame changes go with the next event or pure_app_save. pure-run takes --store PATH. Tests run the same random events on an app in memory and on a stored app that restarts every few events and require equal states after every step; kill a process at every commit stage of an event that changes a number, a text and a list; and check frames, message atomicity and migration by restart and by replacement. A stored event costs about 10.6 ms more on the Pi's SD card, mostly two fsyncs. --- prototypes/connections/test-module.c | 317 ++++++++++++++++++++++++++++++++++- 1 file changed, 315 insertions(+), 2 deletions(-) (limited to 'prototypes/connections/test-module.c') diff --git a/prototypes/connections/test-module.c b/prototypes/connections/test-module.c index a4e746c..64409b2 100644 --- a/prototypes/connections/test-module.c +++ b/prototypes/connections/test-module.c @@ -1,12 +1,27 @@ +#define _POSIX_C_SOURCE 200809L #include "module.h" +#include "module-store.h" #include "module-text.h" +#include "store.h" +#include #include #include #include #include +#include +#include #define CHECK(x) do { if (!(x)) { fprintf(stderr, "%s:%d: %s\n", __FILE__, __LINE__, #x); abort(); } } while (0) +/* Store faults: a stage fails (or the process dies there when crash is set). */ +static int fail_stage = -1; +static int crash; +int pure_store_test_fault(PureStoreStage stage) { + if ((int)stage != fail_stage) return 0; + if (crash) _exit(77); + return 1; +} + typedef struct { const char *name; double initial; } S; typedef struct { const char *name; uint32_t type; } F; /* kind 0 is a key input; otherwise PURE_KIND_MESSAGE with fields. */ @@ -477,7 +492,7 @@ static void fake_close(void *context, void *document) { } static PureData fake_data(Memory *m) { - return (PureData){m, fake_open, fake_version, fake_read, fake_splice, fake_commit, fake_close}; + return (PureData){m, fake_open, fake_version, fake_read, fake_splice, fake_commit, fake_close, NULL, NULL}; } typedef struct { unsigned char bytes[256]; size_t size, spans; } Collected; @@ -1262,6 +1277,303 @@ static void table_mutations(void) { printf("{\"table_module_mutations\":20000,\"accepted_after_damage\":%u}\n", accepted); } +/* ---- Stored states ---------------------------------------------------- */ + +static char *new_store(void) { + static char path[64]; + snprintf(path, sizeof(path), "/tmp/pure-module-store-XXXXXX"); + CHECK(mkdtemp(path) && pure_store_init(path) == PURE_OK); + return path; +} + +static void remove_store(const char *path) { + DIR *entries = opendir(path); + CHECK(entries); + struct dirent *entry; + while ((entry = readdir(entries))) + if (strcmp(entry->d_name, ".") && strcmp(entry->d_name, "..")) CHECK(unlinkat(dirfd(entries), entry->d_name, 0) == 0); + CHECK(closedir(entries) == 0 && rmdir(path) == 0); +} + +typedef struct { unsigned char bytes[4096]; size_t size; } Bytes; + +static bool bytes_span(void *context, PureSpan span) { + Bytes *b = context; + CHECK(b->size + span.size <= sizeof(b->bytes)); + memcpy(b->bytes + b->size, span.data, span.size); + b->size += span.size; + return true; +} + +/* Two apps of one module hold equal states: numbers bit for bit, texts and + * list items byte for byte. */ +static void same_states(const PureApp *a, const PureApp *b) { + const PureModule *m = a->module; + for (uint32_t i = 0; i < m->states; ++i) { + if (m->state_type[i] == PURE_STATE_TEXT) { + static Bytes x, y; + x.size = y.size = 0; + CHECK(pure_draft_read(a->texts[i], 0, pure_draft_size(a->texts[i]), bytes_span, &x) == PURE_OK); + CHECK(pure_draft_read(b->texts[i], 0, pure_draft_size(b->texts[i]), bytes_span, &y) == PURE_OK); + CHECK(x.size == y.size && !memcmp(x.bytes, y.bytes, x.size)); + } else if (m->state_type[i] == PURE_STATE_LIST) { + const PureRecord *r = &m->record[m->state_ref[i]]; + CHECK(pure_list_count(a->lists[i]) == pure_list_count(b->lists[i])); + for (size_t at = 0; at < pure_list_count(a->lists[i]); ++at) + for (unsigned f = 0; f < r->count; ++f) { + PureCell x = pure_list_get(a->lists[i], at, f), y = pure_list_get(b->lists[i], at, f); + CHECK(!memcmp(&x.num, &y.num, sizeof(x.num)) && x.size == y.size && (!x.size || !memcmp(x.text, y.text, x.size))); + } + } else CHECK(!memcmp(&a->values[i], &b->values[i], sizeof(double))); + } +} + +/* The same random events on an app in memory and on one whose states are + * stored and which restarts from them every few events: equal after every + * step, failed events included. */ +static void stored_equivalence(Spec spec, const char *const *words, size_t word_count, uint32_t seed) { + char *path = new_store(); + CHECK(load(spec, &module_a) == PURE_OK); + PureApp memory, stored; + CHECK(pure_app_start(&memory, &module_a, (PureData){0}) == PURE_OK); + CHECK(pure_app_start(&stored, &module_a, pure_tree_data(path)) == PURE_OK); + unsigned restarts = 0; + for (unsigned step = 0; step < 400; ++step) { + uint32_t input = next_random(&seed) % module_a.inputs; + PureResult a, b; + if (module_a.input[input].kind == PURE_KIND_TEXT) { + const char *word = words[next_random(&seed) % word_count]; + PureSpan text = {(const unsigned char *)word, strlen(word)}; + a = pure_app_text(&memory, input, text); + b = pure_app_text(&stored, input, text); + } else { + a = pure_app_input(&memory, input, PURE_EDGE_DOWN); + b = pure_app_input(&stored, input, PURE_EDGE_DOWN); + } + CHECK(a == b); + same_states(&memory, &stored); + if (next_random(&seed) % 7 == 0) { + pure_app_stop(&stored); + CHECK(pure_app_start(&stored, &module_a, pure_tree_data(path)) == PURE_OK); + same_states(&memory, &stored); + ++restarts; + } + } + CHECK(restarts > 20); + pure_app_stop(&memory); + pure_app_stop(&stored); + remove_store(path); +} + +/* One event changes a number, a text and a list; the process dies at each + * commit stage. Starting again finds all three as before or all as after. */ +enum { NOTE = 1, TASKS = 3 }; +static const S crash_nums[] = {{"n", 0}}; +static const T crash_states[] = {{"note", 1, 0}, {"at", 2, 1}, {"tasks", 3, 0}, {"i", 4, 3}}; +static const RF crash_fields[] = {{"title", 1, 0}, {"done", 0, 0}}; +static const R crash_records[] = {{"task", crash_fields, 2}}; +static const char *const crash_constants[] = {"", "x"}; +static const I crash_inputs[] = {KEY("go", "KeyG")}; +static const O crash_code[] = { + {L, 0, 0}, {C, 0, 1}, {ADD, 0, 0}, {ST, 0, 0}, /* n = n + 1 */ + {CUL, 2, 0}, {TK, 1, 0}, {TINS, 1, 0}, /* note.insert(at, "x") */ + {TK, 1, 0}, {L, 0, 0}, {LA, 3, 0}, /* tasks.append(task { "x", n }) */ + {XE, 3, 0}, {XS, 4, 0}, /* i = tasks.last */ +}; +static const H crash_handlers[] = {{1, 0, 12}}; + +static unsigned consistent(const PureApp *app) { + unsigned n = (unsigned)app->values[0]; + CHECK(app->values[0] == n && pure_draft_size(app->texts[NOTE]) == n && app->values[2] == n); + CHECK(pure_list_count(app->lists[TASKS]) == n && app->values[4] == (n ? n - 1 : 0)); + for (unsigned k = 0; k < n; ++k) CHECK(pure_list_get(app->lists[TASKS], k, 1).num == k + 1); + return n; +} + +static void stored_crashes(void) { + char *path = new_store(); + Spec spec = {crash_nums, 1, crash_inputs, 1, NULL, 0, crash_handlers, 1, crash_code, LEN(crash_code), NULL, 0, + crash_states, 4, crash_constants, 2, NULL, 0, crash_records, 1, NULL, 0}; + CHECK(load(spec, &module_a) == PURE_OK); + PureApp app; + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK); + CHECK(pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_OK && consistent(&app) == 1); + pure_app_stop(&app); + unsigned expected = 1; + for (int stage = PURE_AFTER_HEADER; stage <= PURE_BEFORE_DIR_SYNC; ++stage) { + pid_t child = fork(); + CHECK(child >= 0); + if (!child) { + PureApp doomed; + if (pure_app_start(&doomed, &module_a, pure_tree_data(path)) != PURE_OK) _exit(1); + crash = 1; + fail_stage = stage; + pure_app_input(&doomed, 0, PURE_EDGE_DOWN); + _exit(0); + } + int status; + CHECK(waitpid(child, &status, 0) == child && WIFEXITED(status) && WEXITSTATUS(status) == 77); + if (stage == PURE_BEFORE_DIR_SYNC) ++expected; /* the head was written */ + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK && consistent(&app) == expected); + pure_app_stop(&app); + } + /* A failed commit leaves the app and the store as they were; after an + * uncertain one the app no longer matches the store and its events fail + * until it starts again, from what the store has. */ + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK); + for (fail_stage = PURE_AFTER_HEADER; fail_stage <= PURE_BEFORE_RENAME; ++fail_stage) { + CHECK(pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_STORE_ERROR && consistent(&app) == expected); + } + fail_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_UNCERTAIN && consistent(&app) == expected); + fail_stage = -1; + CHECK(pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_CONFLICT && consistent(&app) == expected); + pure_app_stop(&app); + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK && consistent(&app) == expected + 1); + CHECK(pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_OK && consistent(&app) == expected + 2); + pure_app_stop(&app); + remove_store(path); +} + +/* Frame changes are stored by the next event or by a save; a stop alone + * leaves them out. */ +static void stored_frames(void) { + char *path = new_store(); + static const O code[] = {{L, 0, 0}, {C, 0, 1}, {ADD, 0, 0}, {ST, 0, 0}}; + static const H handlers[] = {{0, 0, 4}, {1, 0, 0}}; + Spec spec = {crash_nums, 1, crash_inputs, 1, NULL, 0, handlers, 2, code, 4, NULL, 0, NULL, 0, NULL, 0, NULL, 0, + NULL, 0, NULL, 0}; + CHECK(load(spec, &module_a) == PURE_OK); + PureApp app; + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK); + for (int i = 0; i < 5; ++i) CHECK(pure_app_frame(&app) == PURE_OK); + pure_app_stop(&app); + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK && app.values[0] == 0); + for (int i = 0; i < 3; ++i) CHECK(pure_app_frame(&app) == PURE_OK); + CHECK(pure_app_save(&app) == PURE_OK); + pure_app_stop(&app); + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK && app.values[0] == 3); + for (int i = 0; i < 2; ++i) CHECK(pure_app_frame(&app) == PURE_OK); + CHECK(pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_OK); + pure_app_stop(&app); + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK && app.values[0] == 5); + pure_app_stop(&app); + remove_store(path); +} + +static bool list_keys(void *context, const char *key) { + strcat(context, key); + strcat(context, ","); + return true; +} + +/* A message's document and the states its handlers change commit together. */ +static void stored_messages(void) { + char *path = new_store(); + CHECK(load(editor_spec, &module_a) == PURE_OK); + PureApp app; + unsigned char buffer[64]; + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK); + CHECK(pure_app_message(&app, 1, "doc", 0, edit_payload(buffer, 0, 0, "hello")) == PURE_OK && app.revision == 1); + CHECK(app.values[0] == 1); + CHECK(pure_app_message(&app, 1, "doc", 0, edit_payload(buffer, 0, 0, "x")) == PURE_CONFLICT && app.values[0] == 1); + /* An edit past the size guard fails: neither the document nor the count moves. */ + CHECK(pure_app_message(&app, 3, "doc", 1, edit_payload(buffer, 5, 0, "-world")) == PURE_LIMIT && app.values[0] == 1); + CHECK(pure_app_message(&app, 3, "doc", 1, edit_payload(buffer, 5, 0, "!")) == PURE_OK && app.values[0] == 2); + pure_app_stop(&app); + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK && app.values[0] == 2); + CHECK(pure_app_message(&app, 0, "doc", -1, (PureSpan){0}) == PURE_OK && app.revision == 2 && replied(&app, "hello!")); + pure_app_stop(&app); + /* Documents stay documents: the app's states are not among the keys. */ + char seen[256] = ""; + CHECK(pure_store_keys(path, list_keys, seen) == PURE_OK && !strcmp(seen, "doc,")); + remove_store(path); +} + +/* A stored list whose record changed keeps each field by name, when the app + * starts from it and when its module is replaced; stored values that no + * longer fit fall back, and damaged stored values stop the start. */ +static void stored_migration(void) { + char *path = new_store(); + static const RF changed_fields[] = {{"done", 0, 0}, {"note", 1, 1}, {"title", 1, 0}, {"rank", 0, 5}}; + static const R changed[] = {{"task", changed_fields, 4}}; + Spec before = {crash_nums, 1, crash_inputs, 1, NULL, 0, crash_handlers, 1, crash_code, LEN(crash_code), NULL, 0, + crash_states, 4, crash_constants, 2, NULL, 0, crash_records, 1, NULL, 0}; + Spec after = before; + after.r = changed; + after.h = NULL; + after.nh = 0; + CHECK(load(before, &module_a) == PURE_OK && load(after, &module_b) == PURE_OK); + PureApp app; + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK); + CHECK(pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_OK && pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_OK); + pure_app_stop(&app); + for (int round = 0; round < 2; ++round) { /* migrated, then stored as migrated */ + CHECK(pure_app_start(&app, &module_b, pure_tree_data(path)) == PURE_OK && pure_list_count(app.lists[TASKS]) == 2); + PureCell note = pure_list_get(app.lists[TASKS], 1, 1), title = pure_list_get(app.lists[TASKS], 1, 2); + CHECK(pure_list_get(app.lists[TASKS], 1, 0).num == 2 && pure_list_get(app.lists[TASKS], 1, 3).num == 5); + CHECK(note.size == 1 && note.text[0] == 'x' && title.size == 1 && title.text[0] == 'x'); + CHECK(app.values[0] == 2 && app.values[4] == 1 && pure_draft_size(app.texts[NOTE]) == 2); + pure_app_stop(&app); + } + /* Back to the first record by replacing the module; the next save stores it. */ + CHECK(pure_app_start(&app, &module_b, pure_tree_data(path)) == PURE_OK); + CHECK(pure_app_replace(&app, &module_a) == PURE_OK && pure_app_input(&app, 0, PURE_EDGE_DOWN) == PURE_OK); + pure_app_stop(&app); + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK && consistent(&app) == 3); + pure_app_stop(&app); + /* Stored numbers that no longer fit their state start again. */ + PureDraft *values; + int64_t revision; + CHECK(pure_state_open(path, "values", &values) == PURE_OK); + unsigned char record[64] = "PVS1"; + size_t at = 4; + const char *names[] = {"at", "i", "n"}; + double stored[] = {100, 7.5, 9}; + uint8_t types[] = {PURE_STATE_CURSOR, PURE_STATE_INDEX, PURE_STATE_NUM}; + for (unsigned k = 0; k < 3; ++k) { + record[at++] = types[k]; + record[at++] = (unsigned char)strlen(names[k]); + memcpy(record + at, names[k], strlen(names[k])); + at += strlen(names[k]); + uint64_t bits; + memcpy(&bits, &stored[k], sizeof(bits)); + for (unsigned b = 0; b < 8; ++b) record[at++] = (unsigned char)(bits >> (8 * b)); + } + CHECK(pure_draft_splice(values, 0, pure_draft_size(values), (PureSpan){record, at}) == PURE_OK); + CHECK(pure_draft_commit(values, &revision) == PURE_OK); + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK); + CHECK(app.values[0] == 9 && app.values[2] == 0 && app.values[4] == 0); + pure_app_stop(&app); + /* Damaged stored lists and texts are refused, not replaced. */ + const char *damaged[] = {"list-tasks", "text-note"}; + for (unsigned k = 0; k < 2; ++k) { + PureDraft *draft; + CHECK(pure_state_open(path, damaged[k], &draft) == PURE_OK); + CHECK(pure_draft_splice(draft, 0, 0, (PureSpan){(const unsigned char *)"\xff", 1}) == PURE_OK); + CHECK(pure_draft_commit(draft, &revision) == PURE_OK); + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_STORE_ERROR); + CHECK(pure_draft_splice(draft, 0, 1, (PureSpan){0}) == PURE_OK && pure_draft_commit(draft, &revision) == PURE_OK); + pure_draft_close(draft); + } + CHECK(pure_app_start(&app, &module_a, pure_tree_data(path)) == PURE_OK); + CHECK(pure_list_count(app.lists[TASKS]) == 3 && pure_draft_size(app.texts[NOTE]) == 3 && app.values[0] == 9); + pure_app_stop(&app); + pure_draft_close(values); + remove_store(path); +} + +static void stored_states(void) { + static const char *const list_words[] = {"a", "\xea\xb0\x80", "", "\n", "\xf0\x9f\x98\x80"}; + static const char *const text_words[] = {"a", "\xea\xb0\x80", "\n", "\xf0\x9f\x98\x80", "xy\nz", ""}; + stored_equivalence(list_spec, list_words, LEN(list_words), 0x9e3779b9u); + stored_equivalence(text_spec, text_words, LEN(text_words), 0x7f4a7c15u); + stored_crashes(); + stored_frames(); + stored_messages(); + stored_migration(); +} + int main(void) { behaviour(); faults(); @@ -1275,7 +1587,8 @@ int main(void) { list_mutations(); tables(); table_mutations(); + stored_states(); printf("{\"sizeof_module\":%zu,\"sizeof_app\":%zu}\n", sizeof(PureModule), sizeof(PureApp)); - puts("{\"module_tests\":\"verified-load-atomic-events-faults-signed-zero-if-else-replace-by-name-rejections-mutations-messages-documents-typed-verification-text-cursors-utf8-views-rollback-replace-lists-indices-undo-migration-joins-tables-columns-pick-slide\"}"); + puts("{\"module_tests\":\"verified-load-atomic-events-faults-signed-zero-if-else-replace-by-name-rejections-mutations-messages-documents-typed-verification-text-cursors-utf8-views-rollback-replace-lists-indices-undo-migration-joins-tables-columns-pick-slide-stored-equivalence-crashes-frames-messages-migration\"}"); return 0; } -- cgit v1.2.3