diff options
| -rw-r--r-- | README.md | 9 | ||||
| -rw-r--r-- | docs/research/073-single-file-persistent-tree.md | 10 | ||||
| -rw-r--r-- | docs/research/074-runtime-owned-drafts.md | 97 | ||||
| -rw-r--r-- | prototypes/connections/Makefile | 2 | ||||
| -rw-r--r-- | prototypes/connections/README.md | 2 | ||||
| -rw-r--r-- | prototypes/connections/state.h | 31 | ||||
| -rw-r--r-- | prototypes/connections/store-tree.c | 193 | ||||
| -rw-r--r-- | prototypes/connections/test-tree-store.c | 218 | ||||
| -rw-r--r-- | research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json | 69 | ||||
| -rw-r--r-- | research/experiments/009-runtime-drafts/bench.c | 120 | ||||
| -rwxr-xr-x | research/experiments/009-runtime-drafts/run.py | 45 |
11 files changed, 782 insertions, 14 deletions
@@ -79,6 +79,15 @@ unchanged runtime, splice, async and real-PTY TUI checks pass on it larger, contiguous reads of multi-leaf documents copy, and space is reclaimed only by compaction, so it remains an opt-in profile. +On that store the runtime can own an app's working version +([drafts](docs/research/074-runtime-owned-drafts.md), `state.h`). A draft +shares unchanged extents with the stored version, lends ordered extents +without copying, and commits only its new nodes. Typing into the middle of a +1 MiB document and saving each character peaked at 1,648–1,664 KiB RSS with +drafts. An app copy that is compared with storage on save peaked at +4,464–4,496 KiB. Saves were only about 1–2 ms faster; drafts are not yet +exposed to `.pure` definitions or charged to the runtime budget. + The current execution profile has separate `core`, `fixed-editor`, `editor`, and `server` build targets. `make all` produces the core and the parser-free CLI from the build-time prepared `editor.pure`; it also builds a compiler tool that diff --git a/docs/research/073-single-file-persistent-tree.md b/docs/research/073-single-file-persistent-tree.md index e2a2ee9..092b6ad 100644 --- a/docs/research/073-single-file-persistent-tree.md +++ b/docs/research/073-single-file-persistent-tree.md @@ -116,11 +116,11 @@ TUI의 실제 PTY 검사(1 MiB, UTF-8, 충돌, 1,024개 문서 목록)다. 기� ## 다음 결정 -TUI의 17 ms 중 저장소 자체는 약 8.5 ms다. 나머지는 저장본 전체를 연속 뷰로 다시 읽어 -초안과 비교하는 "초안 대 저장본 비교" 관례의 비용이다. 편집기는 무엇을 바꿨는지 이미 알고 있다. +TUI의 17 ms 중 저장소 자체는 약 8.5 ms다. 처음에는 나머지 전부를 저장본을 다시 읽어 초안과 +비교하는 비용으로 추정했으나 틀렸다. [074](074-runtime-owned-drafts.md)에서 저장소 API 수준으로 +재 보니 그 비용은 약 1–2 ms였다. 나머지는 화면 갱신·PTY·런타임 경로이며 아직 분해하지 않았다. -1. 편집 연산을 그대로 전달하고, 초안을 같은 트리의 커밋되지 않은 루트로 둔다. 그러면 - 전체 비교, 전체 복사, 별도 gap buffer가 사라지는지 확인한다. -2. 연속 뷰 대신 익스텐트 열을 소비자에게 준다(HTTP `writev`, 피어 전송, TUI 렌더). +1. 작업 중인 값을 앱이 따로 복사해 두지 않고 런타임이 소유한다 — [074](074-runtime-owned-drafts.md)에서 구현했다. +2. 연속 뷰 대신 익스텐트 열을 소비자에게 준다(HTTP `writev`, 피어 전송, 렌더). 3. Pure 간 동기화에서 상대에게 없는 노드만 보낸다. 4. 코드 크기를 줄인다. 기본 저장소 승격 여부는 1–4의 결과와 macOS·전원 차단 검증 뒤에 판단한다. diff --git a/docs/research/074-runtime-owned-drafts.md b/docs/research/074-runtime-owned-drafts.md new file mode 100644 index 0000000..71ac8c9 --- /dev/null +++ b/docs/research/074-runtime-owned-drafts.md @@ -0,0 +1,97 @@ +# 074 — 작업 중인 값을 런타임이 소유한다 + +2026-09-25. [073](073-single-file-persistent-tree.md)의 선택형 트리 저장소 위에 런타임 API +[`state.h`](../../prototypes/connections/state.h)를 추가했다. 기본 빌드와 기본 저장 형식은 바뀌지 않았다. + +Pure는 JVM과 같은 위치의 런타임이다. 앱 기능은 별도 저장소에서 만들고, 이 저장소의 CLI·TUI· +HTTP 호스트는 런타임을 검사하는 호스트다. 따라서 이번 단계는 편집기 기능이 아니라 모든 앱이 쓰는 +런타임 능력으로 만들었다. + +착수 기준: + +- 바꿀 결정: 앱이 작업 중인 값을 자기 메모리에 복사해 두고 저장할 때 저장본과 비교하는 관례를 + 유지할지, 런타임이 작업 버전(draft)을 소유하고 앱은 연산만 보낼지. +- 남은 질문: 초안이 저장본과 익스텐트를 공유하면 메모리가 실제로 줄어드는가? 긴 편집 세션에서 + 경로 복사로 생긴 쓰레기가 쌓이지 않는가? 충돌·고장·압축에서도 계약이 유지되는가? +- 최소 확인: 같은 저장소에서 "앱 복사 + 비교" 방식과 draft 방식을 같은 편집으로 비교하고, + 무작위 오라클·충돌·고장·파일 교체 검사를 통과시킨다. +- 중단 기준: 메모리나 저장 시간이 줄지 않거나, 실패한 커밋이 draft나 저장본을 바꾸면 닫는다. + +## 의심한 관례 + +| 관례 | 바꾼 것 | +|---|---| +| 앱이 값의 사본(버퍼·모델)을 가진다 | 런타임이 작업 버전을 소유한다. 바뀌지 않은 부분은 저장본의 불변 익스텐트를 그대로 가리킨다 | +| 저장할 때 사본과 저장본을 비교해 바뀐 곳을 찾는다 | 편집 연산이 곧 변경이다. 커밋은 draft가 새로 만든 노드만 쓴다 | +| 읽기 = 연속 버퍼로 복사 | 읽기 = 순서대로 빌려주는 익스텐트 열(무복사) | +| "저장 안 된 편집"은 앱의 상태 | 커밋되지 않은 루트는 런타임 상태이며 커밋 전까지 파일에 쓰지 않는다 | + +## API + +- `pure_draft_open(path, key)`: 커밋된 버전에서 시작한다. 키가 없으면 revision 0의 빈 draft다. +- `pure_draft_splice(offset, remove, insert)`: 메모리에서만 바뀐 leaf와 경로를 복사한다. +- `pure_draft_read(offset, length, visit)`: 저장본 매핑 또는 draft 메모리의 익스텐트를 복사 없이 순서대로 빌려준다. +- `pure_draft_commit()`: 키가 draft의 기준 revision이면 새 노드만 추가하고 공개한다. 성공하면 + draft는 새 revision에서 이어가고, 실패하면 draft와 저장본 모두 그대로다. + +커밋 쓰기는 draft의 노드 메모리를 바꾸지 않는다. 그래서 동기화 전에 실패해도 draft를 다시 커밋할 수 있다. +다른 키의 쓰기로 압축이 일어나 파일이 교체되면 draft의 저장 참조는 옛 파일을 가리킨다. 이때 커밋은 +draft가 가진 옛 매핑에서 필요한 노드를 새 파일로 복사한다(한 번 전체 쓰기). + +편집할 때마다 경로를 새로 만들므로 도달할 수 없는 노드가 생긴다. 이 쓰레기가 +`max(도달 가능한 바이트, 32 KiB)`를 넘으면 표시·회수한다. + +## 측정 + +같은 Raspberry Pi 5, ext4 `/dev/mmcblk0p2`, 저장소는 둘 다 `store-tree.c`다. 1 MiB 문서 가운데에 +한 글자를 입력하고 저장하기를 24회 반복했고, 방식마다 3회 실행했다. 문서 생성은 별도 +프로세스에서 하여 편집 세션의 피크만 쟀다(Linux `VmHWM`). +[측정 코드](../../research/experiments/009-runtime-drafts/bench.c), +[원시 결과](../../research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json). + +| 1 MiB 문서 | 앱 사본 + 비교 | 런타임 draft | +|---|---:|---:| +| 저장 중앙값 (실행별) | 10.08 · 10.53 · 13.57 ms | 9.25 · 8.64 · 9.13 ms | +| 편집 세션 피크 RSS | 4,464–4,496 KiB | **1,648–1,664 KiB** | +| 글자 하나 반영 | 0.04–0.06 µs (gap buffer) | 3.07–3.59 µs | + +주된 이득은 메모리다. draft는 문서 전체를 앱 메모리에 올리지 않고, 저장할 때 1 MiB를 다시 +복사하지도 않는다. 저장 시간은 약 1–2 ms 줄었을 뿐이다. 남은 대부분은 두 번의 `fsync`다. +글자 하나 반영은 gap buffer보다 느리지만(leaf 하나와 경로 복사), 수 µs라 입력 한 번의 비용으로는 작다. + +## 검증 + +[`test-tree-store.c`](../../prototypes/connections/test-tree-store.c) 추가 검사는 ASan·UBSan을 포함해 통과했다. + +- 20,000회 무작위 편집을 draft에서 평면 오라클과 비교하고 1,000회마다 커밋했다(최종 419,324 B, 커밋 20회). + 커밋 뒤에는 저장본 일치, revision 증가, 빈 arena를 확인했다. +- 1 MiB 문서 가운데에서 30,000번 입력·삭제(순증 10,000 B): arena 피크 47,339 B. + 커밋은 14,952 B만 추가했다. +- 두 draft가 같은 키를 수정하면 두 번째 커밋은 충돌을 내고 자기 편집을 유지한다. +- 다른 키의 쓰기로 파일이 압축·교체된 뒤에도 draft 커밋이 올바른 내용을 공개한다. +- 다섯 고장 단계: 동기화 전 실패는 저장본·파일 크기·draft를 바꾸지 않는다. 결과 불명 뒤의 재커밋은 + 충돌로 드러나고, 다시 열면 공개된 내용을 본다. +- 범위 밖 편집·읽기는 draft를 바꾸지 않는다. 읽기는 방문자가 멈추면 중단된다. + +`make check-tree` 전체(기존 런타임·부분 수정·비동기·실제 PTY 검사 포함)도 다시 통과했다. + +## 비용과 한계 + +- `store-tree.c` 오브젝트 `text`가 15,899 B에서 19,321 B로 늘었다. draft를 쓰지 않는 트리 CLI도 같은 + 번역 단위라 함께 링크된다(34,913 B). 분리하거나 줄여야 한다. +- 아직 런타임 코어(`runtime.c`)의 작업·예산·완료 모델이나 `.pure` 정의에서 draft를 다룰 수 없다. + 지금은 호스트 수준 C API다. draft 메모리는 런타임 작업 예산에 계상되지 않는다. +- 기존 TUI는 그대로 자기 gap buffer와 비교 방식을 쓴다. 앱 쪽 전환은 앱 저장소의 일이다. +- 커밋은 기준 revision이 다르면 충돌로 끝난다. 두 draft의 연산을 자동으로 합치지 않는다. +- draft는 기준 파일의 매핑을 끝까지 유지한다. 압축으로 교체된 옛 파일의 공간은 draft를 닫을 때까지 + 회수되지 않는다. 최신 head가 전원 차단 등으로 손상돼 이전 커밋으로 되돌아가면, 그 뒤를 매핑한 draft의 + 읽기는 잘린 파일 영역에 닿을 수 있다(기존 저장소의 외부 절단과 같은 신뢰 경계). +- 한 draft는 한 스레드가 소유한다. 물리 전원 차단, macOS, x86_64는 검증하지 않았다. + +## 다음 결정 + +1. draft를 런타임 코어의 자원으로 만든다. 작업 예산에 계상하고, `.pure` 정의가 draft 연산과 + 커밋을 효과로 선언하게 한다. 앱은 C API가 아니라 런타임 계약으로 작업 버전을 다룬다. +2. 호스트에 익스텐트 열을 그대로 넘긴다(HTTP `writev`, 피어 전송). 연속 복사는 호환 경계에만 남긴다. +3. 같은 키의 동시 draft에서 서로 겹치지 않는 편집을 합칠지(연산 재기준화) 판단한다. +4. 코드 크기를 줄이고 draft를 쓰지 않는 호스트에서 빠지게 한다. diff --git a/prototypes/connections/Makefile b/prototypes/connections/Makefile index 661fbcd..a416bee 100644 --- a/prototypes/connections/Makefile +++ b/prototypes/connections/Makefile @@ -11,7 +11,7 @@ HOST = $(STORE) store-port.c evaluate.c # Document browsers list keys through the store. The tree store keeps its own # catalog; per-object file stores share a directory listing. KEYS = $(if $(filter store-tree.c,$(STORE)),,store-keys.c) -HEADERS = runtime.h program.h store.h store-port.h flow.h result.h +HEADERS = runtime.h program.h store.h store-port.h state.h flow.h result.h all: core fixed-editor core: $(BUILD)/libpure-execution.a async-core: $(BUILD)/libpure-execution-async.a diff --git a/prototypes/connections/README.md b/prototypes/connections/README.md index f0b041e..94d05f0 100644 --- a/prototypes/connections/README.md +++ b/prototypes/connections/README.md @@ -148,6 +148,8 @@ python3 prototypes/connections/export_flat_store.py \ 트리로 둔다. 편집은 바뀐 leaf와 경로만 기록하고, 읽기는 잠그지 않는다. 기존 런타임·부분 수정· 비동기·실제 PTY 검사와 자체 고장·오라클 검사를 함께 실행한다. 형식이 달라 별도 `--store` 디렉터리를 쓴다. [설계·측정·한계](../../docs/research/073-single-file-persistent-tree.md). +같은 저장소에서 런타임이 앱의 작업 버전을 소유하는 draft API는 [`state.h`](state.h)이며, +설계와 측정은 [074](../../docs/research/074-runtime-owned-drafts.md)에 있다. ```sh make -C prototypes/connections check-tree diff --git a/prototypes/connections/state.h b/prototypes/connections/state.h new file mode 100644 index 0000000..d1bc0fa --- /dev/null +++ b/prototypes/connections/state.h @@ -0,0 +1,31 @@ +#ifndef PURE_STATE_H +#define PURE_STATE_H +#include "flow.h" +#include "result.h" +#include <stdint.h> + +/* Runtime-owned working versions of stored values (selected tree store). + * A draft starts at the committed version of a key and changes only in + * memory: an edit copies the leaf and path it touches and shares every other + * extent with the stored version. An app therefore keeps no copy of the value + * and never compares a copy with storage to find what it changed. Commit + * appends only the draft's new extents. One owner thread per draft. */ +typedef struct PureDraft PureDraft; + +/* A missing key opens as an empty draft at revision 0; commit creates it. */ +PureResult pure_draft_open(const char *path, const char *key, PureDraft **out); +/* Memory only. Fails without change on a bad range or when memory runs out. */ +PureResult pure_draft_splice(PureDraft *, size_t offset, size_t remove, PureSpan insert); +size_t pure_draft_size(const PureDraft *); +/* The committed revision this draft is based on; 0 if the key was absent. */ +int64_t pure_draft_revision(const PureDraft *); +/* Visit [offset, offset + length) in order as borrowed extents, without + * copying. Returning false stops early. Spans stay valid until the next + * splice, commit or close of this draft. */ +PureResult pure_draft_read(const PureDraft *, size_t offset, size_t length, + bool (*visit)(void *, PureSpan), void *context); +/* Publish if the key is still at the draft's base revision. On success the + * draft continues from the new revision; on any failure it is unchanged. */ +PureResult pure_draft_commit(PureDraft *, int64_t *revision); +void pure_draft_close(PureDraft *); +#endif diff --git a/prototypes/connections/store-tree.c b/prototypes/connections/store-tree.c index 618028a..1c0ad91 100644 --- a/prototypes/connections/store-tree.c +++ b/prototypes/connections/store-tree.c @@ -10,6 +10,7 @@ #define _DARWIN_C_SOURCE #endif #include "store.h" +#include "state.h" #include <errno.h> #include <fcntl.h> #include <inttypes.h> @@ -20,6 +21,7 @@ #include <sys/stat.h> #include <unistd.h> +enum { FREE = 0xffff }; enum { SLOT = 64, DATA = 2 * SLOT, LEAF_MAX = 4096, LEAF_MIN = 1024, FAN_MAX = 32, FAN_MIN = 8, RECORD = 96, KEY = 64, HEIGHT_MAX = 12, BUFFER = 65536 }; @@ -43,14 +45,15 @@ typedef struct { Entry *items; size_t count, capacity; } List; typedef struct { unsigned char *bytes; /* leaf only */ Entry *children; /* branch only */ - size_t size; /* leaf bytes or child count */ - unsigned height; + size_t size; /* leaf bytes, child count, or next free slot + 1 */ + unsigned height; /* FREE for a reclaimed slot */ } Node; typedef struct { const unsigned char *map; uint64_t end; Node *nodes; - size_t count, capacity; + size_t count, capacity, free_head, free_count; /* free_head is slot + 1 */ + size_t bytes; /* payload held by arena nodes, including unreachable ones */ } Tree; typedef struct { uint64_t seq, end, catalog, catalog_bytes, live; } Head; typedef struct { @@ -309,11 +312,23 @@ static bool push(List *list, Entry entry) { return true; } +static size_t node_bytes(const Node *node) { + return node->bytes ? node->size : node->size * sizeof(Entry); +} + static bool add_node(Tree *tree, Node node, uint64_t *index) { + tree->bytes += node_bytes(&node); + if (tree->free_head) { + *index = tree->free_head - 1; + tree->free_head = tree->nodes[*index].size; + --tree->free_count; + tree->nodes[*index] = node; + return true; + } if (tree->count == tree->capacity) { size_t capacity = tree->capacity ? 2 * tree->capacity : 64; Node *nodes = realloc(tree->nodes, capacity * sizeof(*nodes)); - if (!nodes) return false; + if (!nodes) { tree->bytes -= node_bytes(&node); return false; } tree->nodes = nodes; tree->capacity = capacity; } @@ -537,8 +552,9 @@ static uint64_t put_branch(Writer *writer, const Entry *kids, size_t count, unsi } /* Append a subtree's arena nodes, children first, and set entry to the - * stored reference without changing the arena. With relocate, stored nodes - * are copied too; compaction uses this to rewrite reachable nodes. */ + * stored reference. The arena is not changed, so a failed commit leaves a + * draft intact. With relocate, stored nodes are copied too (compaction, or a + * draft whose base file was replaced by compaction). */ static bool write_subtree(const Tree *tree, Writer *writer, Entry *entry, unsigned height, bool relocate) { if (!entry->bytes || (!(entry->ref & MEMORY_BIT) && !relocate)) return true; @@ -918,6 +934,160 @@ PureResult pure_store_keys(const char *path, bool (*visit)(void *, const char *k return result; } +struct PureDraft { + char *path; + char key[KEY + 1]; + Tree tree; /* the base file's mapping plus this draft's node arena */ + dev_t device; /* identity of the base file; compaction replaces it */ + ino_t inode; + Entry root; + int64_t revision; + size_t live; /* arena bytes reachable at the last collection */ +}; + +/* Rebase the draft on the committed version of its key. */ +static PureResult load_draft(PureDraft *draft) { + int directory = open(draft->path, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC); + if (directory < 0) return PURE_STORE_ERROR; + int fd = openat(directory, tree_name, O_RDONLY | O_NOFOLLOW | O_CLOEXEC); + close(directory); + if (fd < 0) return PURE_STORE_ERROR; + Tree tree = {0}; + Head head; + struct stat info; + Entry document; + uint64_t index; + int64_t revision; + bool found; + bool ok = !fstat(fd, &info) && read_head(fd, &head) && map_tree(fd, &head, &tree) && + find(&tree, (Entry){head.catalog, head.catalog_bytes}, draft->key, &index, &found, + &document, &revision); + close(fd); + if (!ok) { release_tree(&tree); return PURE_STORE_ERROR; } + release_tree(&draft->tree); + draft->tree = tree; + draft->device = info.st_dev; + draft->inode = info.st_ino; + draft->root = document; + draft->revision = revision; + draft->live = 0; + return PURE_OK; +} + +PureResult pure_draft_open(const char *path, const char *key, PureDraft **out) { + *out = NULL; + if (!path || !valid_key(key)) return PURE_INVALID; + PureDraft *draft = calloc(1, sizeof(*draft)); + if (!draft || !(draft->path = strdup(path))) { free(draft); return PURE_ALLOCATION; } + memcpy(draft->key, key, strlen(key) + 1); + PureResult result = load_draft(draft); + if (result != PURE_OK) { pure_draft_close(draft); return result; } + *out = draft; + return PURE_OK; +} + +void pure_draft_close(PureDraft *draft) { + if (!draft) return; + release_tree(&draft->tree); + free(draft->path); + free(draft); +} + +size_t pure_draft_size(const PureDraft *draft) { return (size_t)draft->root.bytes; } + +int64_t pure_draft_revision(const PureDraft *draft) { return draft->revision; } + +static size_t mark(const Tree *tree, Entry entry, unsigned char *marks) { + if (!(entry.ref & MEMORY_BIT)) return 0; + size_t at = (size_t)(entry.ref & LOCATION); + if (marks[at]) return 0; + marks[at] = 1; + const Node *node = &tree->nodes[at]; + size_t bytes = node_bytes(node); + if (!node->bytes) + for (size_t i = 0; i < node->size; ++i) bytes += mark(tree, node->children[i], marks); + return bytes; +} + +/* Every edit copies a leaf and path, so reclaim arena nodes the root no + * longer reaches once they outweigh the reachable ones (and 32 KiB). */ +static void collect(PureDraft *draft) { + Tree *tree = &draft->tree; + size_t allowance = draft->live > 32768 ? draft->live : 32768; + if (tree->bytes <= draft->live || tree->bytes - draft->live <= allowance) return; + unsigned char *marks = calloc(tree->count, 1); + if (!marks) return; + draft->live = mark(tree, draft->root, marks); + for (size_t i = 0; i < tree->count; ++i) { + Node *node = &tree->nodes[i]; + if (marks[i] || node->height == FREE) continue; + tree->bytes -= node_bytes(node); + free(node->bytes); + free(node->children); + *node = (Node){.size = tree->free_head, .height = FREE}; + tree->free_head = i + 1; + ++tree->free_count; + } + free(marks); +} + +PureResult pure_draft_splice(PureDraft *draft, size_t offset, size_t remove, PureSpan insert) { + uint64_t size = draft->root.bytes; + if (insert.size && !insert.data) return PURE_INVALID; + if (offset > size || remove > size - offset || insert.size > PURE_OBJECT_LIMIT - (size - remove)) + return PURE_LIMIT; + if (!remove && !insert.size) return PURE_OK; + Entry next; + bool ok = rebuild(&draft->tree, draft->root, offset, remove, insert, &next); + if (ok) draft->root = next; + collect(draft); + return ok ? PURE_OK : PURE_STORE_ERROR; +} + +PureResult pure_draft_read(const PureDraft *draft, size_t offset, size_t length, + bool (*visit)(void *, PureSpan), void *context) { + unsigned height; + if (offset > draft->root.bytes || length > draft->root.bytes - offset) return PURE_LIMIT; + if (!length) return PURE_OK; + if (!root_height(&draft->tree, draft->root, &height)) return PURE_STORE_ERROR; + return visit_range(&draft->tree, draft->root, height, offset, length, visit, context) ? + PURE_OK : PURE_STORE_ERROR; +} + +PureResult pure_draft_commit(PureDraft *draft, int64_t *revision) { + *revision = draft->revision; + Session session; + Writer *writer = NULL; + Entry current, root = draft->root; + uint64_t index; + int64_t stored; + bool found; + PureResult result = open_session(draft->path, &session); + if (result != PURE_OK) goto finish; + result = PURE_STORE_ERROR; + if (!find(&session.tree, (Entry){session.head.catalog, session.head.catalog_bytes}, draft->key, + &index, &found, ¤t, &stored)) goto finish; + if (stored != draft->revision) { *revision = stored; result = PURE_CONFLICT; goto finish; } + if (stored == INT64_MAX) { result = PURE_LIMIT; goto finish; } + /* After compaction the draft's stored references name the old file. */ + bool relocate = session.file.st_dev != draft->device || session.file.st_ino != draft->inode; + writer = malloc(sizeof(*writer)); + if (!writer) goto finish; + *writer = (Writer){.fd = session.fd, .at = session.head.end}; + if (!write_root(&draft->tree, writer, &root, relocate)) { + if (ftruncate(session.fd, (off_t)session.head.end) == 0) sync_fd(session.fd); + goto finish; + } + result = publish(&session, writer, draft->key, index, found, root, revision); + /* Rebase while still holding the writer lock; the arena is no longer needed. + * If remapping fails the draft keeps equal content at the new revision. */ + if (result == PURE_OK && load_draft(draft) != PURE_OK) draft->revision = *revision; +finish: + free(writer); + close_session(&session); + return result; +} + PureResult pure_store_put(const char *path, const char *key, int64_t expected, PureSpan body, int64_t *revision) { return mutate(path, key, expected, body, false, 0, 0, PURE_OBJECT_LIMIT, revision); @@ -946,6 +1116,17 @@ static bool shape(const Tree *tree, Entry entry, unsigned height, bool root, return true; } +/* Test-only: live arena nodes and their payload bytes. */ +void pure_draft_test_arena(const PureDraft *draft, size_t *nodes, size_t *bytes) { + *nodes = *bytes = 0; + for (size_t i = 0; i < draft->tree.count; ++i) { + const Node *node = &draft->tree.nodes[i]; + if (node->height == FREE) continue; + ++*nodes; + *bytes += node->bytes ? node->size : node->size * sizeof(Entry); + } +} + /* Test-only: the stored height, leaf count and number of underfull nodes. */ bool pure_tree_test_shape(const char *path, const char *key, unsigned *height, uint64_t *leaves, uint64_t *small, uint64_t *file_end) { diff --git a/prototypes/connections/test-tree-store.c b/prototypes/connections/test-tree-store.c index 3a1e8f0..8cf1f1e 100644 --- a/prototypes/connections/test-tree-store.c +++ b/prototypes/connections/test-tree-store.c @@ -1,5 +1,6 @@ #define _POSIX_C_SOURCE 200809L #include "store.h" +#include "state.h" #include <assert.h> #include <dirent.h> #include <fcntl.h> @@ -22,6 +23,8 @@ int pure_store_test_fault(PureStoreStage stage) { bool pure_tree_test_shape(const char *path, const char *key, unsigned *height, uint64_t *leaves, uint64_t *small, uint64_t *file_end); +void pure_draft_test_arena(const PureDraft *draft, size_t *nodes, size_t *bytes); + enum { KEYS = 4, CAPACITY = 1 << 20 }; typedef struct { unsigned char *bytes; size_t size; int64_t revision; } Oracle; @@ -366,6 +369,215 @@ static void keys(const char *path) { CHECK(pure_store_keys(path, count_key, &count) == PURE_OK && count == 5); } +typedef struct { unsigned char *out; size_t spans, largest, limit; } Gather; + +static bool gather(void *context, PureSpan span) { + Gather *g = context; + memcpy(g->out, span.data, span.size); + g->out += span.size; + if (span.size > g->largest) g->largest = span.size; + return ++g->spans != g->limit; +} + +static void expect_draft(const PureDraft *draft, const Oracle *o, unsigned char *scratch) { + Gather g = {scratch, 0, 0, 0}; + CHECK(pure_draft_size(draft) == o->size && pure_draft_revision(draft) == o->revision); + CHECK(pure_draft_read(draft, 0, o->size, gather, &g) == PURE_OK); + CHECK((size_t)(g.out - scratch) == o->size && (!o->size || !memcmp(scratch, o->bytes, o->size))); + CHECK(g.largest <= 4096); +} + +static void apply(Oracle *o, size_t offset, size_t remove, const unsigned char *insert, size_t count) { + memmove(o->bytes + offset + count, o->bytes + offset + remove, o->size - offset - remove); + if (count) memcpy(o->bytes + offset, insert, count); + o->size = o->size - remove + count; +} + +/* Edits live only in the draft until commit; each commit continues from the + * published version with an empty arena. */ +static void drafts(const char *path) { + Oracle o = {malloc(CAPACITY), 0, 0}; + unsigned char *scratch = malloc(CAPACITY), *insert = malloc(65536); + CHECK(o.bytes && scratch && insert); + PureDraft *draft; + PureStoredView view; + CHECK(pure_draft_open(path, "draft", &draft) == PURE_OK); + expect_draft(draft, &o, scratch); + uint32_t state = 0x1234567u; + for (unsigned step = 0; step < 20000; ++step) { + uint32_t kind = random_value(&state) % 100; + size_t count = kind < 80 ? random_value(&state) % 8 : + kind < 98 ? random_value(&state) % 4000 : random_value(&state) % 65536; + size_t offset = o.size ? random_value(&state) % (o.size + 1) : 0; + size_t remove = o.size - offset ? random_value(&state) % (o.size - offset + 1) : 0; + if (random_value(&state) % 64) remove %= 16; + if (o.size - remove + count > CAPACITY) count = 0; + fill(insert, count, &state); + CHECK(pure_draft_splice(draft, offset, remove, (PureSpan){insert, count}) == PURE_OK); + apply(&o, offset, remove, insert, count); + if (step % 97 == 0) expect_draft(draft, &o, scratch); + if (step % 1000 == 999) { + if (o.revision) CHECK(pure_store_get(path, "draft", PURE_OBJECT_LIMIT, &view) == PURE_OK && + view.revision == o.revision); + if (o.revision) pure_store_release(&view); + int64_t revision; + CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == o.revision + 1); + o.revision = revision; + expect(path, "draft", &o); + expect_draft(draft, &o, scratch); + size_t nodes, bytes; + pure_draft_test_arena(draft, &nodes, &bytes); + CHECK(nodes == 0); + } + } + CHECK(o.size > 100000); + Gather g = {scratch, 0, 0, 3}; + CHECK(pure_draft_read(draft, 0, o.size, gather, &g) == PURE_OK && g.spans == 3); + CHECK(pure_draft_splice(draft, o.size + 1, 0, (PureSpan){0}) == PURE_LIMIT); + CHECK(pure_draft_splice(draft, 0, o.size + 1, (PureSpan){0}) == PURE_LIMIT); + CHECK(pure_draft_read(draft, o.size, 1, gather, &g) == PURE_LIMIT); + expect_draft(draft, &o, scratch); + pure_draft_close(draft); + printf("{\"draft_steps\":20000,\"draft_commits\":%lld,\"draft_bytes\":%zu}\n", + (long long)o.revision, o.size); + free(o.bytes); + free(scratch); + free(insert); +} + +/* Long typing and deleting at one place keeps only the live path in memory, + * and its commit appends the typed bytes and path, not the document. */ +static void typing(const char *path) { + size_t size = 1048576; + Oracle o = {malloc(size + 40000), size, 0}; + unsigned char *scratch = malloc(size + 40000); + uint32_t state = 5; + CHECK(o.bytes && scratch); + fill(o.bytes, size, &state); + CHECK(pure_store_put(path, "typed", 0, (PureSpan){o.bytes, size}, &o.revision) == PURE_OK); + PureDraft *draft; + CHECK(pure_draft_open(path, "typed", &draft) == PURE_OK); + size_t cursor = size / 2, nodes, bytes, peak = 0; + for (unsigned i = 0; i < 30000; ++i) { + unsigned char byte = (unsigned char)('a' + i % 26); + if (i % 3 == 2) { + CHECK(pure_draft_splice(draft, cursor - 1, 1, (PureSpan){0}) == PURE_OK); + apply(&o, --cursor, 1, NULL, 0); + } else { + CHECK(pure_draft_splice(draft, cursor, 0, (PureSpan){&byte, 1}) == PURE_OK); + apply(&o, cursor++, 0, &byte, 1); + } + pure_draft_test_arena(draft, &nodes, &bytes); + if (bytes > peak) peak = bytes; + } + expect_draft(draft, &o, scratch); + /* Reachable: 10,000 net typed bytes, the leaves they landed in and one + * path. Unreachable copies are reclaimed past max(reachable, 32 KiB). */ + CHECK(peak < 2 * (10000 + 4 * 4096 + 2048) + 32768 + 8192); + uint64_t before = file_size(path); + int64_t revision; + CHECK(pure_draft_commit(draft, &revision) == PURE_OK); + o.revision = revision; + CHECK(file_size(path) - before < 10000 + 16384); + expect(path, "typed", &o); + pure_draft_close(draft); + printf("{\"typing_keystrokes\":30000,\"peak_arena_bytes\":%zu,\"commit_appended_bytes\":%llu}\n", + peak, (unsigned long long)(file_size(path) - before)); + free(o.bytes); + free(scratch); +} + +/* Two drafts of one key: the second commit conflicts and keeps its edits. */ +static void draft_conflict(const char *path) { + unsigned char scratch[64]; + int64_t revision; + CHECK(pure_store_put(path, "shared", 0, (PureSpan){(const unsigned char *)"base", 4}, &revision) == PURE_OK); + PureDraft *first, *second; + CHECK(pure_draft_open(path, "shared", &first) == PURE_OK && pure_draft_open(path, "shared", &second) == PURE_OK); + CHECK(pure_draft_splice(first, 4, 0, (PureSpan){(const unsigned char *)"-one", 4}) == PURE_OK); + CHECK(pure_draft_splice(second, 0, 0, (PureSpan){(const unsigned char *)"two-", 4}) == PURE_OK); + CHECK(pure_draft_commit(first, &revision) == PURE_OK && revision == 2); + CHECK(pure_draft_commit(second, &revision) == PURE_CONFLICT && revision == 2); + Oracle kept = {(unsigned char *)"two-base", 8, 1}; + expect_draft(second, &kept, scratch); + Oracle stored = {(unsigned char *)"base-one", 8, 2}; + expect(path, "shared", &stored); + pure_draft_close(second); + CHECK(pure_draft_open(path, "shared", &second) == PURE_OK); + expect_draft(second, &stored, scratch); + CHECK(pure_store_put(path, "shared", 2, (PureSpan){(const unsigned char *)"x", 1}, &revision) == PURE_OK); + CHECK(pure_draft_commit(first, &revision) == PURE_CONFLICT && revision == 3); + pure_draft_close(first); + pure_draft_close(second); +} + +/* Another key's writes compact the file under an open draft; the draft's + * stored extents are copied from its old mapping on commit. */ +static void draft_relocation(const char *path) { + unsigned char *big = malloc(300000), scratch[20000]; + uint32_t state = 11; + CHECK(big); + fill(big, 300000, &state); + Oracle o = {malloc(20000), 12000, 0}; + CHECK(o.bytes); + fill(o.bytes, o.size, &state); + CHECK(pure_store_put(path, "kept", 0, (PureSpan){o.bytes, o.size}, &o.revision) == PURE_OK); + PureDraft *draft; + CHECK(pure_draft_open(path, "kept", &draft) == PURE_OK); + CHECK(pure_draft_splice(draft, 6000, 10, (PureSpan){(const unsigned char *)"relocated", 9}) == PURE_OK); + apply(&o, 6000, 10, (const unsigned char *)"relocated", 9); + char name[512]; + snprintf(name, sizeof(name), "%s/pure.tree", path); + struct stat before, after; + CHECK(stat(name, &before) == 0); + int64_t revision = 0; + for (unsigned i = 0; i < 8; ++i) + CHECK(pure_store_put(path, "other", revision, (PureSpan){big, 300000 - i}, &revision) == PURE_OK); + CHECK(stat(name, &after) == 0 && after.st_ino != before.st_ino); + CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == o.revision + 1); + o.revision = revision; + expect(path, "kept", &o); + expect_draft(draft, &o, scratch); + Oracle other = {big, 300000 - 7, 8}; + expect(path, "other", &other); + pure_draft_close(draft); + free(o.bytes); + free(big); +} + +/* A failed commit changes neither the store nor the draft. */ +static void draft_faults(const char *path) { + unsigned char scratch[64]; + int64_t revision; + CHECK(pure_store_put(path, "faulty", 0, (PureSpan){(const unsigned char *)"stable", 6}, &revision) == PURE_OK); + PureDraft *draft; + CHECK(pure_draft_open(path, "faulty", &draft) == PURE_OK); + CHECK(pure_draft_splice(draft, 6, 0, (PureSpan){(const unsigned char *)"+edit", 5}) == PURE_OK); + Oracle stored = {(unsigned char *)"stable", 6, 1}, edited = {(unsigned char *)"stable+edit", 11, 1}; + uint64_t size = file_size(path); + for (fail_stage = PURE_AFTER_HEADER; fail_stage <= PURE_BEFORE_RENAME; ++fail_stage) { + CHECK(pure_draft_commit(draft, &revision) == PURE_STORE_ERROR); + expect(path, "faulty", &stored); + expect_draft(draft, &edited, scratch); + CHECK(file_size(path) == size); + } + fail_stage = PURE_BEFORE_DIR_SYNC; + CHECK(pure_draft_commit(draft, &revision) == PURE_UNCERTAIN && revision == 2); + fail_stage = -1; + expect_draft(draft, &edited, scratch); + CHECK(pure_draft_commit(draft, &revision) == PURE_CONFLICT && revision == 2); + pure_draft_close(draft); + CHECK(pure_draft_open(path, "faulty", &draft) == PURE_OK); + edited.revision = 2; + expect_draft(draft, &edited, scratch); + CHECK(pure_draft_splice(draft, 0, 6, (PureSpan){0}) == PURE_OK); + CHECK(pure_draft_commit(draft, &revision) == PURE_OK && revision == 3); + Oracle final = {(unsigned char *)"+edit", 5, 3}; + expect(path, "faulty", &final); + pure_draft_close(draft); + CHECK(pure_draft_open(path, "bad/key", &draft) == PURE_INVALID && !draft); +} + static void clean(const char *path) { DIR *entries = opendir(path); CHECK(entries); @@ -380,7 +592,8 @@ static void clean(const char *path) { int main(void) { void (*checks[])(const char *) = {oracle, amplification, faults, crashes, torn_head, - isolation, corruption, many_keys, revision_limit, keys}; + isolation, corruption, many_keys, revision_limit, keys, + drafts, typing, draft_conflict, draft_relocation, draft_faults}; for (size_t i = 0; i < sizeof(checks) / sizeof(*checks); ++i) { char path[] = "/tmp/pure-tree-check-XXXXXX"; CHECK(mkdtemp(path) && pure_store_init(path) == PURE_OK); @@ -389,6 +602,7 @@ int main(void) { clean(path); } puts("{\"tree_store\":\"random-oracle-balance-held-views-one-byte-amplification-faults-" - "process-crash-torn-head-lockless-isolation-compaction-corruption-1024-keys-revision-limit-key-listing\"}"); + "process-crash-torn-head-lockless-isolation-compaction-corruption-1024-keys-revision-limit-key-listing-" + "draft-oracle-typing-arena-conflict-relocation-commit-faults\"}"); return 0; } diff --git a/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json b/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json new file mode 100644 index 0000000..e85ec38 --- /dev/null +++ b/research/experiments/009-runtime-drafts/2026-09-25-ext4-linux-aarch64.json @@ -0,0 +1,69 @@ +{ + "device": "/dev/mmcblk0p2", + "filesystem": "ext4", + "machine": "aarch64", + "kernel": "6.18.39+rpt-rpi-2712", + "store": "store-tree.c", + "runs": [ + { + "mode": "copy", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 0.06, + "save_median_ms": 10.083, + "save_p90_ms": 12.536, + "peak_rss_kib": 4496, + "final_revision": 25 + }, + { + "mode": "draft", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 3.18, + "save_median_ms": 9.245, + "save_p90_ms": 27.849, + "peak_rss_kib": 1664, + "final_revision": 25 + }, + { + "mode": "draft", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 3.07, + "save_median_ms": 8.643, + "save_p90_ms": 18.492, + "peak_rss_kib": 1648, + "final_revision": 25 + }, + { + "mode": "copy", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 0.04, + "save_median_ms": 10.528, + "save_p90_ms": 12.78, + "peak_rss_kib": 4480, + "final_revision": 25 + }, + { + "mode": "copy", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 0.04, + "save_median_ms": 13.571, + "save_p90_ms": 21.87, + "peak_rss_kib": 4464, + "final_revision": 25 + }, + { + "mode": "draft", + "document_bytes": 1048576, + "saves": 24, + "keystroke_median_us": 3.59, + "save_median_ms": 9.128, + "save_p90_ms": 50.847, + "peak_rss_kib": 1664, + "final_revision": 25 + } + ] +} diff --git a/research/experiments/009-runtime-drafts/bench.c b/research/experiments/009-runtime-drafts/bench.c new file mode 100644 index 0000000..e486d68 --- /dev/null +++ b/research/experiments/009-runtime-drafts/bench.c @@ -0,0 +1,120 @@ +/* Where an app's working value lives, measured on one store (store-tree.c). + * + * copy: the app loads its own copy into a gap buffer, types into it, and on + * save reads the stored value back, finds the changed range by + * comparing prefix and suffix, then submits that splice (the pattern + * the current terminal editor uses). + * draft: the runtime owns the working version; typing is a draft splice and + * save is a commit of the draft's new extents. + * + * Each save follows one typed character in the middle of the document. + * "create" makes the stored document in a separate process, so the peak RSS + * of copy and draft covers only the editing session. */ +#define _POSIX_C_SOURCE 200809L +#include "state.h" +#include "store.h" +#include <stdio.h> +#include <stdlib.h> +#include <string.h> +#include <time.h> + +static double now_us(void) { + struct timespec t; + clock_gettime(CLOCK_MONOTONIC, &t); + return (double)t.tv_sec * 1e6 + (double)t.tv_nsec / 1e3; +} + +static int compare(const void *a, const void *b) { + double x = *(const double *)a, y = *(const double *)b; + return (x > y) - (x < y); +} + +/* Linux VmHWM belongs to this exec's address space; ru_maxrss would carry + * the launching interpreter's peak across exec. */ +static long peak_kib(void) { + FILE *file = fopen("/proc/self/status", "r"); + char line[256]; + long value = -1; + while (file && fgets(line, sizeof(line), file)) + if (!strncmp(line, "VmHWM:", 6)) value = strtol(line + 6, NULL, 10); + if (file) fclose(file); + return value; +} + +typedef struct { unsigned char *text; size_t capacity, gap_start, gap_end; } Gap; + +static unsigned char at(const Gap *g, size_t i) { + return i < g->gap_start ? g->text[i] : g->text[i + (g->gap_end - g->gap_start)]; +} + +int main(int argc, char **argv) { + if (argc != 5 || (strcmp(argv[1], "copy") && strcmp(argv[1], "draft") && strcmp(argv[1], "create"))) { + fputs("bench create|copy|draft DIRECTORY SIZE SAVES\n", stderr); + return 2; + } + const char *mode = argv[1], *path = argv[2]; + size_t size = strtoull(argv[3], NULL, 10), saves = strtoull(argv[4], NULL, 10); + int64_t revision = 1; + if (!strcmp(mode, "create")) { + unsigned char *body = malloc(size); + if (!body || pure_store_init(path) != PURE_OK) return 1; + for (size_t i = 0; i < size; ++i) body[i] = (unsigned char)('a' + i % 26); + return pure_store_put(path, "doc", 0, (PureSpan){body, size}, &revision) != PURE_OK; + } + double *save = malloc(saves * sizeof(*save)), *key = malloc(saves * sizeof(*key)); + if (!save || !key) return 1; + size_t cursor = size / 2, length = size; + if (!strcmp(mode, "copy")) { + PureStoredView view; + if (pure_store_get(path, "doc", PURE_OBJECT_LIMIT, &view) != PURE_OK) return 1; + Gap g = {malloc(size + 4096), size + 4096, cursor, cursor + 4096}; + if (!g.text) return 1; + memcpy(g.text, view.body.data, cursor); + memcpy(g.text + g.gap_end, view.body.data + cursor, size - cursor); + pure_store_release(&view); + for (size_t i = 0; i < saves; ++i) { + double start = now_us(); + g.text[g.gap_start++] = (unsigned char)('A' + i % 26); + ++length; + key[i] = now_us() - start; + start = now_us(); + if (pure_store_get(path, "doc", PURE_OBJECT_LIMIT, &view) != PURE_OK) return 1; + size_t old = view.body.size, prefix = 0, suffix = 0; + while (prefix < old && prefix < length && view.body.data[prefix] == at(&g, prefix)) ++prefix; + while (suffix < old - prefix && suffix < length - prefix && + view.body.data[old - suffix - 1] == at(&g, length - suffix - 1)) ++suffix; + size_t remove = old - prefix - suffix, insert = length - prefix - suffix; + unsigned char *bytes = malloc(insert ? insert : 1); + for (size_t k = 0; k < insert; ++k) bytes[k] = at(&g, prefix + k); + pure_store_release(&view); + PureResult result = pure_store_splice(path, "doc", revision, prefix, remove, + (PureSpan){bytes, insert}, PURE_OBJECT_LIMIT, &revision); + free(bytes); + save[i] = now_us() - start; + if (result != PURE_OK) return 1; + } + free(g.text); + } else { + PureDraft *draft; + if (pure_draft_open(path, "doc", &draft) != PURE_OK) return 1; + for (size_t i = 0; i < saves; ++i) { + unsigned char byte = (unsigned char)('A' + i % 26); + double start = now_us(); + if (pure_draft_splice(draft, cursor++, 0, (PureSpan){&byte, 1}) != PURE_OK) return 1; + key[i] = now_us() - start; + start = now_us(); + if (pure_draft_commit(draft, &revision) != PURE_OK) return 1; + save[i] = now_us() - start; + } + pure_draft_close(draft); + } + qsort(save, saves, sizeof(*save), compare); + qsort(key, saves, sizeof(*key), compare); + printf("{\"mode\":\"%s\",\"document_bytes\":%zu,\"saves\":%zu,\"keystroke_median_us\":%.2f," + "\"save_median_ms\":%.3f,\"save_p90_ms\":%.3f,\"peak_rss_kib\":%ld,\"final_revision\":%lld}\n", + mode, size, saves, key[saves / 2], save[saves / 2] / 1e3, save[saves * 9 / 10] / 1e3, + peak_kib(), (long long)revision); + free(save); + free(key); + return 0; +} diff --git a/research/experiments/009-runtime-drafts/run.py b/research/experiments/009-runtime-drafts/run.py new file mode 100755 index 0000000..08ed55d --- /dev/null +++ b/research/experiments/009-runtime-drafts/run.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Run bench.c in copy and draft modes on a real device, alternating order.""" +import argparse +import json +import platform +import shutil +import subprocess +from pathlib import Path + +HERE = Path(__file__).resolve().parent +CONNECTIONS = HERE.parents[2] / 'prototypes' / 'connections' + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('--work', type=Path, default=CONNECTIONS / 'build' / 'bench-drafts') + parser.add_argument('--size', type=int, default=1048576) + parser.add_argument('--saves', type=int, default=24) + parser.add_argument('--rounds', type=int, default=3) + parser.add_argument('--output', type=Path) + args = parser.parse_args() + shutil.rmtree(args.work, ignore_errors=True) + args.work.mkdir(parents=True) + device = subprocess.check_output(['findmnt', '-n', '-T', str(args.work), '-o', 'SOURCE,FSTYPE'], text=True).split() + assert device[1] != 'tmpfs', 'fsync is free on tmpfs' + binary = args.work / 'bench' + subprocess.run(['cc', '-std=c17', '-O2', '-Wall', '-Wextra', '-Werror', f'-I{CONNECTIONS}', + str(HERE / 'bench.c'), str(CONNECTIONS / 'store-tree.c'), '-o', str(binary)], check=True) + runs = [] + for round_number in range(args.rounds): + for mode in (('copy', 'draft') if round_number % 2 == 0 else ('draft', 'copy')): + directory = args.work / f'{mode}-{round_number}' + subprocess.run([str(binary), 'create', str(directory), str(args.size), '0'], check=True) + line = subprocess.check_output([str(binary), mode, str(directory), str(args.size), str(args.saves)], text=True) + runs.append(json.loads(line)) + print(line.strip(), flush=True) + shutil.rmtree(directory) + report = {'device': device[0], 'filesystem': device[1], 'machine': platform.machine(), + 'kernel': platform.release(), 'store': 'store-tree.c', 'runs': runs} + if args.output: + args.output.write_text(json.dumps(report, indent=2) + '\n') + + +if __name__ == '__main__': + main() |
