aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--Makefile3
-rw-r--r--README.md2
-rw-r--r--docs/design/009-layout.md2
-rw-r--r--docs/design/010-surfaces.md62
-rw-r--r--serve/serve.c174
-rw-r--r--serve/surface.h48
-rw-r--r--tests/check.py87
-rw-r--r--tests/surface-probe.c85
8 files changed, 449 insertions, 14 deletions
diff --git a/Makefile b/Makefile
index ee82d93..8848d7b 100644
--- a/Makefile
+++ b/Makefile
@@ -38,9 +38,10 @@ $(BUILD)/libpure.a: $(OBJECTS)
# The runtime's tickets and budgets, with effects completed off the owner thread.
# It links after the library, last as it always was, so code keeps its place.
+# The app model's functions are exported for a surface it maps (serve/surface.h).
$(BUILD)/pure-serve: $(SERVE) core/runtime.c $(BUILD)/libpure.a $(HEADERS)
$(CC) $(CPPFLAGS) $(CFLAGS) $(INCLUDES) -Iserve -DPURE_ASYNC_EFFECTS $(SERVE) $(BUILD)/libpure.a core/runtime.c \
- $(LDFLAGS) -lm -pthread $(LDLIBS) -o $@
+ $(LDFLAGS) -Wl,--export-dynamic-symbol='pure_*' -lm -pthread $(LDLIBS) -o $@
$(BUILD)/pure-crypto.so: serve/crypto-openssl.c $(HEADERS)
$(CC) $(CPPFLAGS) $(CFLAGS) $(INCLUDES) -Iserve -fPIC -shared serve/crypto-openssl.c $(LDFLAGS) -lssl -lcrypto \
diff --git a/README.md b/README.md
index bb5cecb..bf9c167 100644
--- a/README.md
+++ b/README.md
@@ -23,7 +23,7 @@ The source follows the principle below, a small core with the rest selected arou
|---|---|---|
| `core/` | execution, ownership, limits and completion (`runtime.c`); the app model: modules verified before they run, events as transactions, states as drafts (`module*.c`); what the core asks of storage (`state.h`, `store.h`) | `build/libpure.a` (with the store) |
| `store/` | the tree store: one file, a commit is one contiguous write and one sync (formats `PURETRE3`, `PUREREC1`) | in `libpure.a` |
-| `serve/` | `pure-serve`: the HTTP host, outbound delivery, JSON read in place; OpenSSL's part (TLS to https receivers, sealed sends) apart, mapped only by a server that uses one | `build/pure-serve`, `build/pure-crypto.so` |
+| `serve/` | `pure-serve`: the HTTP host, outbound delivery, JSON read in place, surfaces for people run in the node ([010](docs/design/010-surfaces.md)); OpenSSL's part (TLS to https receivers, sealed sends) apart, mapped only by a server that uses one | `build/pure-serve`, `build/pure-crypto.so` |
| `cli/` | command-line hosts | `build/pure-run`, `build/pure-edit-module` |
| `prototypes/` | the earlier document runtime (000-077), built against `core/` and `store/` | `make -C prototypes/connections` |
| `tests/` | the hosts checked end to end, with apps built by aup (the sibling checkout) | `make check` |
diff --git a/docs/design/009-layout.md b/docs/design/009-layout.md
index d985003..de5563d 100644
--- a/docs/design/009-layout.md
+++ b/docs/design/009-layout.md
@@ -29,7 +29,7 @@
|---|---|---|
| `core/` | 실행·소유권·한도·완료(`runtime.c`, `flow.c`), 앱 모델(`module*.c`: 실행 전에 검증한 모듈, 트랜잭션인 이벤트, draft인 상태), 코어가 저장소에 요구하는 것(`state.h`, `store.h`) | `build/libpure.a`(저장소와 함께) |
| `store/` | 트리 저장소. 한 파일, 커밋은 한 연속 쓰기와 동기화 한 번 | `libpure.a` 안 |
-| `serve/` | `pure-serve`: HTTP 호스트, 나가는 전달, 제자리 JSON. OpenSSL을 쓰는 부분(https 수신자로의 TLS, 봉인한 전달)은 따로 두어 쓰는 서버만 매핑한다 | `build/pure-serve`, `build/pure-crypto.so` |
+| `serve/` | `pure-serve`: HTTP 호스트, 나가는 전달, 제자리 JSON, 노드 안에서 도는 사람을 위한 표면([010](010-surfaces.md)). OpenSSL을 쓰는 부분(https 수신자로의 TLS, 봉인한 전달)은 따로 두어 쓰는 서버만 매핑한다 | `build/pure-serve`, `build/pure-crypto.so` |
| `cli/` | 명령행 호스트 | `build/pure-run`, `build/pure-edit-module` |
| `prototypes/` | 이전 문서 런타임. `core/`와 `store/`를 가져다 빌드한다 | `make -C prototypes/connections` |
diff --git a/docs/design/010-surfaces.md b/docs/design/010-surfaces.md
new file mode 100644
index 0000000..1c5100f
--- /dev/null
+++ b/docs/design/010-surfaces.md
@@ -0,0 +1,62 @@
+# 010 — 표면: 노드 안에서 사람에게 보이는 앱
+
+2026-09-27. 메신저를 만들려면 한 기기에서 앱이 네트워크(봉인한 전달, 088)와 화면(Vega)을 함께 가져야 한다. 그 둘을 어떻게
+잇는지 정한 것.
+
+## 문제
+
+- pure-serve는 앱의 상태를 가진 소유자 스레드 하나와 연결 스레드들로 된 노드다. 보기는 GET, 입력은 POST이고, 전달선은 이
+ 노드의 출력을 다른 노드로 봉인해 보낸다. 사람을 위한 표면은 없다.
+- Vega의 표면(터미널, 픽셀, KMS 디스플레이)은 앱을 제 프로세스 안에서 돌린다(`PureApp`을 직접). 네트워크가 없다.
+- 두 프로세스가 같은 저장소를 쓰면 안 된다. 저장소는 쓰는 이가 하나다.
+
+길은 둘이었다.
+
+1. **원격 표면.** Vega가 노드의 HTTP API를 쓰는 클라이언트가 된다. 화면마다 보기를 GET으로 받고(JSON), 키와 글자를
+ POST하고, 바뀐 것은 긴 폴링으로 안다. 화면이 다른 기기에 있어도 된다. 그러나 상태와 화면 사이에 HTTP와 JSON이 들어서고,
+ Vega의 모든 읽기를 원격 판으로 한 번 더 써야 한다.
+2. **노드 안의 표면.** pure-serve가 표면 라이브러리를 매핑해 제 스레드에서 돌린다. 표면은 연결 스레드가 보기를 읽는 것과
+ 같이 마지막 저장의 스냅숏(080)을 제자리에서 읽고, 이벤트는 요청과 같은 줄로 소유자에게 준다.
+
+로드맵은 Vega가 "뷰 상태 변경 바이트를 화면과 입출력 버퍼로 직통 바인딩"하기를 바란다. 2가 그것이다. 상태의 주인이
+하나이고, 사람과 프로그램이 같은 노드를 쓰며, 새 호스트가 생기지 않는다. JVM이 JavaFX를 싣는 것과 같은 관계다. 런타임이
+라이브러리를 싣고, 런타임은 화면을 모른다(009의 선). 1이 필요해지면(다른 기기의 화면) 그때 HTTP 쪽에 더한다.
+
+## 계약
+
+`serve/surface.h`. 표면 라이브러리는 `pure_surface_run(const PureSurface *, argc, argv)`을 내보낸다. pure-serve는
+`--surface LIBRARY [-- ARGUMENTS]`로 그것을 매핑하고, 소유자를 띄운 뒤 제 스레드에서 부른다. 표가 주는 것은 이렇다.
+
+- `read(host, reader)`: 마지막 저장의 스냅숏을 붙잡아 표면의 리더 앱에 보인다. 보기, 행, 텍스트는 `core/module.h`의 함수
+ 그대로 읽는다. `release`로 돌려준다.
+- `changes`: eventfd. 보기가 바뀌는 저장(스냅숏 게시)마다, 그리고 서버가 멈출 때 읽을 수 있게 된다. 표면은 제 입력
+ 장치와 함께 poll한다. 새 저장을 기다리며 도는 일이 없다.
+- `press(host, input)`, `type(host, input, text)`: 키 입력을 눌렀다 떼고, 텍스트 입력에 글자를 넣는다. 소유자가 요청과
+ 같은 버스트에서 적용하고 저장한 뒤에 돌아온다. 다른 종류의 입력이나 없는 입력은 `PURE_INVALID`다.
+- `stopping(host)`: 멈출 때다. pure-serve는 연결 스레드가 멈춘 뒤, 소유자를 멈추기 전에 표면을 기다린다. 표면이 기다리는
+ 마지막 이벤트를 소유자가 아직 적용할 수 있다.
+
+앱 모델의 코드는 프로세스에 한 벌이다. pure-serve가 제 `pure_*` 함수를 동적 심볼로 내보내고(`--export-dynamic-symbol`),
+표면은 그것을 부른다. 표면이 제 사본을 싣지 않으므로 판이 어긋날 일이 없다. 표에는 `sizeof(PureApp)`,
+`sizeof(PureModule)`도 있어서, 다른 배치로 빌드된 표면은 스스로 거절한다.
+
+표면의 이벤트는 호스트가 없는 요청(`Job.host == NULL`)이다. 소유자의 버스트 규칙(한 라운드에 저장 한 번, 문서 키는
+버스트에 한 번), 게시, 전달선이 모두 요청과 같다. 결과는 `hand_back`이 표면의 기다리는 이에게 준다. 상태가 없는 모듈에는
+소유자가 없으므로 표면을 둘 수 없다.
+
+## 비용
+
+표면을 쓰지 않는 서버에는 게시할 때 분기 하나, `Job`과 `Applied`에 포인터 하나다. 전과 번갈아 쟀다. 64 B GET은 140.9k 대
+139.9k/s, 앱 합계 GET은 여덟 라운드 중앙값 126.3k 대 126.3k, 20행 페이지는 96.1k 대 95.3k로 같다. 내보낸 심볼 157개로
+실행 파일의 읽기 전용 부분이 11 KB, 유휴 RSS가 24 KiB 늘었다.
+
+## 검사
+
+`tests/check.py`의 `server_surface`는 검사용 표면(`tests/surface-probe.c`)을 빌드해 싣는다. 그 표면은 텍스트 세 줄을
+넣고 키를 누르며 저장마다 보기를 적는다. 검사는 이것들을 본다.
+
+- 표면의 이벤트가 HTTP의 보기에 보인다.
+- HTTP로 넣은 입력이 `changes`로 표면에 보인다.
+- 다른 종류의 입력과 없는 입력은 거절된다(8, `PURE_INVALID`).
+- 표면은 서버와 함께 0으로 끝난다.
+- 상태가 없는 모듈은 표면을 거절한다(종료 2).
diff --git a/serve/serve.c b/serve/serve.c
index 0e60e2a..6467683 100644
--- a/serve/serve.c
+++ b/serve/serve.c
@@ -31,12 +31,14 @@
* bursts take the requests of every thread, one save each. */
#include "deliver.h"
#include "seal.h"
+#include "surface.h"
#include "message-json.h"
#include "module-store.h"
#include "module-text.h"
#include "runtime.h"
#include "store.h"
#include <arpa/inet.h>
+#include <dlfcn.h>
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
@@ -139,8 +141,12 @@ typedef struct Connection {
* it. A read answered from the store lends its extents: they point into a
* store mapping, which the resource holds and the runtime charges and
* releases with the ticket. */
+/* A surface's event, waiting for its save: done, and how it went. */
+typedef struct { PureResult status; bool done; } Waiter;
+
typedef struct {
- Host *host;
+ Host *host; /* NULL for a surface's event */
+ Waiter *waiter; /* and then its waiter */
PureTicket ticket;
PureInput input;
PureResult status;
@@ -178,6 +184,7 @@ typedef struct {
pthread_mutex_t published_lock;
PureSnapshot *published;
uint64_t published_changes, publications;
+ struct PureSurfaceHost *surface; /* told of each snapshot published */
/* The owner's: outputs wired to HTTP receivers, and how far each was trimmed. */
PureWire *wires[PURE_MODULE_INPUTS];
unsigned wire_count;
@@ -185,14 +192,15 @@ typedef struct {
bool held[PURE_MODULE_INPUTS]; /* an answer failed to apply: the wire's sends stay until a restart */
} Executor;
-/* A request made ready: its effect, what it names, and the thread that answers it. */
-typedef struct { Host *host; PureEffect effect; Route route; } Job;
+/* A request made ready: its effect, what it names, and the thread that
+ * answers it (or, for a surface's event, the waiter it wakes). */
+typedef struct { Host *host; PureEffect effect; Route route; Waiter *waiter; } Job;
/* The owner of an app with states served by several connection threads:
* their requests wait here, and it runs them in bursts, one save each
* however many threads they came from, handing each thread back its
- * applied requests to answer. */
-typedef struct {
+ * applied requests to answer (and a surface its events). */
+typedef struct Owner {
Executor executor;
pthread_mutex_t lock;
pthread_cond_t waiting;
@@ -1144,7 +1152,8 @@ static PureResult apply_input(PureApp *app, const Route *route, const PureEffect
static void apply_effect(Executor *ex, const Job *job, Applied *out) {
PureApp *app = &ex->app;
const PureEffect *effect = &job->effect;
- *out = (Applied){.host = job->host, .ticket = effect->ticket, .input = effect->input, .route = job->route.kind};
+ *out = (Applied){.host = job->host, .waiter = job->waiter, .ticket = effect->ticket, .input = effect->input,
+ .route = job->route.kind};
memcpy(out->key, effect->key, strlen(effect->key) + 1);
if (job->route.kind == ROUTE_VIEW) {
out->status = show_view(app, &job->route, effect->available, out);
@@ -1185,6 +1194,24 @@ static void apply_effect(Executor *ex, const Job *job, Applied *out) {
.release = release_copy};
}
+/* ---- A surface in the node (serve/surface.h): one, run in its own thread,
+ * reading published snapshots and giving events to the owner. */
+struct PureSurfaceHost {
+ struct Owner *owner;
+ pthread_mutex_t lock;
+ pthread_cond_t applied; /* a waiter's event was applied and saved (or not) */
+ PureSurface table;
+ PureSurfaceRun run;
+ int argc, result;
+ char **argv;
+ pthread_t thread;
+};
+
+static void surface_changed(PureSurfaceHost *s) {
+ uint64_t one = 1;
+ if (write(s->table.changes, &one, sizeof(one)) != sizeof(one)) { /* full: it is readable already */ }
+}
+
/* After a save, before its answers go: from now on views show what it
* saved, so a read made after a write's answer sees the write. A failed save
* returns the app to the last save's states, which the snapshot still shows. */
@@ -1198,6 +1225,7 @@ static void publish(Executor *ex) {
pure_snapshot_release(old);
ex->published_changes = ex->app.changes;
++ex->publications;
+ if (ex->surface) surface_changed(ex->surface);
}
/* The saved sends of wired outputs not queued yet go to their wires, in
@@ -1378,6 +1406,16 @@ static void answer_burst(void *context, Applied *applied, size_t count) {
/* On the owner: give each connection thread its applied requests, and wake it. */
static void hand_back(void *context, Applied *applied, size_t count) {
Owner *owner = context;
+ PureSurfaceHost *surface = owner->executor.surface;
+ bool events = false;
+ for (size_t i = 0; i < count; ++i) events = events || !applied[i].host;
+ if (events) { /* a surface's: its waiter wakes */
+ pthread_mutex_lock(&surface->lock);
+ for (size_t i = 0; i < count; ++i)
+ if (!applied[i].host) applied[i].waiter->status = applied[i].status, applied[i].waiter->done = true;
+ pthread_cond_broadcast(&surface->applied);
+ pthread_mutex_unlock(&surface->lock);
+ }
for (unsigned h = 0; h < owner->host_count; ++h) {
Host *host = &owner->hosts[h];
size_t mine = 0;
@@ -1432,7 +1470,7 @@ static void serve_bursts(Host *host) {
size_t count = 0;
PureEffect effect;
while (pure_runtime_take(&host->runtime, &effect) == PURE_TAKE_EFFECT)
- host->jobs[count++] = (Job){host, effect, host->routes[effect.ticket.slot]};
+ host->jobs[count++] = (Job){host, effect, host->routes[effect.ticket.slot], NULL};
if (!count) return;
if (!host->owner) {
execute(&host->local, host->jobs, count, answer_burst, NULL);
@@ -1560,6 +1598,99 @@ static void *run_owner(void *context) {
return NULL;
}
+/* The surface's table (serve/surface.h). */
+static PureSnapshot *surface_read(PureSurfaceHost *s, PureApp *reader) {
+ Executor *ex = &s->owner->executor;
+ pthread_mutex_lock(&ex->published_lock);
+ PureSnapshot *snapshot = ex->published;
+ if (snapshot) pure_snapshot_hold(snapshot);
+ pthread_mutex_unlock(&ex->published_lock);
+ if (snapshot) pure_app_read(reader, snapshot);
+ return snapshot;
+}
+
+static void surface_release(PureSurfaceHost *s, PureSnapshot *snapshot) {
+ (void)s;
+ pure_snapshot_release(snapshot);
+}
+
+/* An event queued at the owner as a request is, waited for until its save. */
+static PureResult surface_event(PureSurfaceHost *s, uint32_t input, PureSpan text) {
+ Owner *owner = s->owner;
+ const PureModule *m = owner->executor.module;
+ if (input >= m->inputs || (m->input[input].kind != PURE_KIND_KEY && m->input[input].kind != PURE_KIND_TEXT) ||
+ (text.size && !text.data))
+ return PURE_INVALID;
+ if (text.size > PURE_OBJECT_LIMIT) return PURE_LIMIT;
+ unsigned char *value = malloc(4 + text.size); /* as a request's ingress: its size, then its bytes */
+ if (!value) return PURE_ALLOCATION;
+ put_u32(value, (uint32_t)text.size);
+ if (text.size) memcpy(value + 4, text.data, text.size);
+ Waiter waiter = {PURE_INVALID, false};
+ Job job = {.route = {.kind = ROUTE_INPUT, .target = input}, .waiter = &waiter,
+ .effect = {.input = PURE_INPUT_WRITE, .key = "", .expected = -1, .value = {value, 4 + text.size}}};
+ pthread_mutex_lock(&owner->lock);
+ bool queued = !owner->stopping && grow(&owner->jobs, &owner->capacity, owner->count + 1, sizeof(*owner->jobs));
+ if (queued) {
+ owner->jobs[owner->count++] = job;
+ ++owner->arrivals;
+ pthread_cond_signal(&owner->waiting);
+ }
+ pthread_mutex_unlock(&owner->lock);
+ pthread_mutex_lock(&s->lock);
+ while (queued && !waiter.done) pthread_cond_wait(&s->applied, &s->lock);
+ pthread_mutex_unlock(&s->lock);
+ free(value);
+ return queued ? waiter.status : PURE_INVALID;
+}
+
+static PureResult surface_press(PureSurfaceHost *s, uint32_t input) {
+ return input < s->owner->executor.module->inputs &&
+ s->owner->executor.module->input[input].kind == PURE_KIND_KEY
+ ? surface_event(s, input, (PureSpan){0})
+ : PURE_INVALID;
+}
+
+static PureResult surface_type(PureSurfaceHost *s, uint32_t input, PureSpan text) {
+ return input < s->owner->executor.module->inputs &&
+ s->owner->executor.module->input[input].kind == PURE_KIND_TEXT
+ ? surface_event(s, input, text)
+ : PURE_INVALID;
+}
+
+static bool surface_stopping(PureSurfaceHost *s) {
+ (void)s;
+ return atomic_load(&stopping);
+}
+
+static void *run_surface(void *context) {
+ PureSurfaceHost *s = context;
+ s->result = s->run(&s->table, s->argc, s->argv);
+ fprintf(stderr, "{\"surface\":\"%s\",\"returned\":%d}\n", s->argv[0], s->result);
+ return NULL;
+}
+
+/* --surface LIBRARY [-- ARGUMENTS]: mapped now, run in its own thread. */
+static bool start_surface(PureSurfaceHost *s, Owner *owner, int argc, char **argv) {
+ void *library = dlopen(argv[0], RTLD_NOW | RTLD_LOCAL);
+ PureSurfaceRun run = library ? (PureSurfaceRun)(uintptr_t)dlsym(library, "pure_surface_run") : NULL;
+ if (!run) {
+ fprintf(stderr, "--surface %s: %s\n", argv[0], library ? "no pure_surface_run in it" : dlerror());
+ return false;
+ }
+ *s = (PureSurfaceHost){.owner = owner, .run = run, .argc = argc, .argv = argv};
+ s->table = (PureSurface){PURE_SURFACE_VERSION, sizeof(PureApp), sizeof(PureModule), owner->executor.module, s,
+ eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC), surface_read, surface_release, surface_press,
+ surface_type, surface_stopping};
+ if (s->table.changes < 0 || pthread_mutex_init(&s->lock, NULL) || pthread_cond_init(&s->applied, NULL)) return false;
+ owner->executor.surface = s; /* before the thread: its first snapshot may come any time */
+ if (pthread_create(&s->thread, NULL, run_surface, s)) {
+ owner->executor.surface = NULL;
+ return false;
+ }
+ return true;
+}
+
/* A write's reply is borrowed from its request, so the handler of an input
* served by PUT or POST may reply only a field of its message. */
static bool replies_borrow(const PureModule *m, uint32_t input) {
@@ -1909,17 +2040,27 @@ static unsigned auto_threads(bool owner) {
int main(int argc, char **argv) {
static PureModule module;
static Owner owner;
- const char *module_path = NULL, *store = NULL, *name = NULL, *sends[PURE_MODULE_INPUTS], *replies[PURE_MODULE_INPUTS];
+ static PureSurfaceHost surface;
+ /* What follows -- is the surface's: its library first, then its arguments. */
+ int options = argc, surface_argc = 0;
+ char *surface_argv[64];
+ for (int i = 1; i < argc; ++i)
+ if (!strcmp(argv[i], "--")) {
+ options = i;
+ break;
+ }
+ const char *surface_path = NULL, *module_path = NULL, *store = NULL, *name = NULL, *sends[PURE_MODULE_INPUTS], *replies[PURE_MODULE_INPUTS];
const char *seals[PURE_MODULE_INPUTS], *named[PURE_MODULE_INPUTS], *key_path = NULL;
unsigned send_count = 0, reply_count = 0, seal_count = 0, named_count = 0;
unsigned port = 8767, threads = 1;
size_t budget = 64u << 20, capacity = 1024;
uint64_t timeout_ms = 10000;
- for (int i = 1; i < argc; i += 2) {
+ for (int i = 1; i < options; i += 2) {
uint64_t value;
if (!strcmp(argv[i], "--sealed-only")) { sealed_only = true; --i; continue; }
- if (i + 1 == argc) goto usage;
+ if (i + 1 == options) goto usage;
if (!strcmp(argv[i], "--module")) module_path = argv[i + 1];
+ else if (!strcmp(argv[i], "--surface")) surface_path = argv[i + 1];
else if (!strcmp(argv[i], "--key")) key_path = argv[i + 1];
else if (!strcmp(argv[i], "--seal") && seal_count < PURE_MODULE_INPUTS) seals[seal_count++] = argv[i + 1];
else if (!strcmp(argv[i], "--store")) store = argv[i + 1];
@@ -1936,6 +2077,10 @@ int main(int argc, char **argv) {
else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) timeout_ms = value;
else goto usage;
}
+ if (surface_path) surface_argv[surface_argc++] = (char *)surface_path;
+ for (int i = options + 1; i < argc && surface_argc < 63; ++i) surface_argv[surface_argc++] = argv[i];
+ surface_argv[surface_argc] = NULL;
+ if (options < argc && !surface_path) goto usage;
uint32_t show, edit;
bool documents;
if (!module_path || !store || !load_module(&module, module_path, &documents, &show, &edit)) goto usage;
@@ -1998,10 +2143,17 @@ int main(int argc, char **argv) {
if (*public_key) printf("{\"port\":%u,\"threads\":%u,\"public_key\":\"%s\"}\n", port, threads, public_key);
else printf("{\"port\":%u,\"threads\":%u}\n", port, threads);
fflush(stdout);
+ /* A surface reads the owner's snapshots and gives it events. */
+ if (surface_path && !owned) { fputs("--surface needs an app with states\n", stderr); return 2; }
+ if (surface_path && !start_surface(&surface, &owner, surface_argc, surface_argv)) return 1;
for (unsigned i = 1; i < threads; ++i)
if (pthread_create(&thread[i], NULL, run_shard, &hosts[i])) { fputs("Cannot start a thread\n", stderr); return 1; }
run_shard(&hosts[0]);
for (unsigned i = 1; i < threads; ++i) pthread_join(thread[i], NULL);
+ if (surface_path) { /* stopped before the owner, which may still apply its last event */
+ surface_changed(&surface);
+ pthread_join(surface.thread, NULL);
+ }
if (owned) {
pthread_mutex_lock(&owner.lock);
owner.stopping = true;
@@ -2052,6 +2204,6 @@ usage:
fputs("pure-serve --module FILE.pmod --store DIRECTORY [--port 8767] [--threads 1|N|auto] [--budget BYTES] "
"[--connections 1024] [--timeout-ms 10000] [--send OUTPUT=http[s]://host:port/path ...] [--name NAME] "
"[--send-ca FILE] [--reply OUTPUT=INPUT ...] [--seal OUTPUT=KEY ...] [--key FILE] [--sealed-only] "
- "[--sender NAME=KEY ...]\n", stderr);
+ "[--sender NAME=KEY ...] [--surface LIBRARY [-- ARGUMENTS]]\n", stderr);
return 2;
}
diff --git a/serve/surface.h b/serve/surface.h
new file mode 100644
index 0000000..a79bf05
--- /dev/null
+++ b/serve/surface.h
@@ -0,0 +1,48 @@
+#ifndef PURE_SURFACE_H
+#define PURE_SURFACE_H
+#include "module.h"
+
+/* A surface shows an app to people (Vega's display, say): a library that
+ * pure-serve maps and runs in a thread of its own beside its connection
+ * threads (--surface LIBRARY [-- ARGUMENTS]). It reads the app as of the
+ * last save, as a connection thread reads views, and gives it events that
+ * the owner applies with the requests, one save a round. So a node is one
+ * process with one owner of its states, whether people or programs use it,
+ * and a surface reads states where they are, with no HTTP or JSON between.
+ *
+ * The library exports
+ *
+ * int pure_surface_run(const PureSurface *, int argc, char **argv);
+ *
+ * and calls the app model (core/module.h) as pure-serve exports it: one
+ * copy of that code in the process, the host's. argv[0] is the library.
+ * pure_surface_run returns when stopping says so (the changes descriptor
+ * becomes readable then too), or when it is done; the server serves on. */
+enum { PURE_SURFACE_VERSION = 1 };
+
+typedef struct PureSurfaceHost PureSurfaceHost;
+typedef struct {
+ unsigned version;
+ size_t app_size, module_size; /* sizeof(PureApp), sizeof(PureModule): a surface built for another layout refuses */
+ const PureModule *module;
+ PureSurfaceHost *host;
+ /* An eventfd, readable after each save that changed what views show and
+ * when the server stops: poll it with the surface's own descriptors, and
+ * read its 8 bytes to clear it. */
+ int changes;
+ /* The app as of the last save, shown in reader (a zeroed app the first
+ * time, the same one after): its views, rows and texts read as
+ * core/module.h says while the snapshot is held. Give it back with
+ * release. NULL before the first save. */
+ PureSnapshot *(*read)(PureSurfaceHost *, PureApp *reader);
+ void (*release)(PureSurfaceHost *, PureSnapshot *);
+ /* An event, applied by the owner with the next save: a key input pressed
+ * and released, or text typed into a text input. Returns once it was
+ * applied and saved (PURE_OK), or why it was not. */
+ PureResult (*press)(PureSurfaceHost *, uint32_t input);
+ PureResult (*type)(PureSurfaceHost *, uint32_t input, PureSpan text);
+ bool (*stopping)(PureSurfaceHost *);
+} PureSurface;
+
+typedef int (*PureSurfaceRun)(const PureSurface *, int argc, char **argv);
+#endif
diff --git a/tests/check.py b/tests/check.py
index 76a23e3..9ff771a 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -937,6 +937,92 @@ def server_sealed(aup, pure, work):
relay.shutdown()
+SURFACE_SOURCE = """record Task {
+ title: text
+ done = 0
+}
+state tasks: list of Task
+state at: index of tasks
+input typed = text
+input toggle = key Space
+on typed {
+ tasks.append(Task { title: typed })
+ at = tasks.last
+}
+on toggle.down { at.done = !at.done }
+view count = tasks.count
+view current = if tasks.count > 0 { at.title } else { "(empty)" }
+view finished = at.done
+"""
+
+
+def server_surface(aup, pure, work):
+ """A surface in the node (--surface, serve/surface.h): a library that
+ pure-serve maps and runs beside its connection threads types text and
+ presses a key as a person at the node's display would. The owner applies
+ its events with the requests, one save a round, and it reads each save's
+ views where they are: its events show in HTTP views, and an HTTP input
+ shows in it through the changes descriptor. An event for an input of
+ another kind, or for none, is refused; it stops with the server; and a
+ module without states cannot have one."""
+ import select
+ import signal
+ import time
+ probe = work / 'surface-probe.so'
+ subprocess.run(['cc', '-O2', '-std=c17', '-Wall', '-Wextra', '-Werror', '-shared', '-fPIC', f'-I{pure / "core"}',
+ f'-I{pure / "serve"}', str(pure / 'tests' / 'surface-probe.c'), '-o', str(probe)], check=True)
+ source, module = work / 'surface.aup', work / 'surface.pmod'
+ source.write_text(SURFACE_SOURCE)
+ compile_module(aup, source, module)
+ out = work / 'surface.jsonl'
+ process = subprocess.Popen([str(a) for a in (pure / 'build/pure-serve', '--module', module, '--store',
+ work / 'surface-store', '--port', '0', '--surface', probe, '--', out,
+ 'typed', 'toggle', 'one,two,three', 'count', 'current', 'finished')],
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+
+ def lines():
+ return [json.loads(line) for line in out.read_text().splitlines()] if out.exists() else []
+
+ def wait_for(condition):
+ deadline = time.time() + 20
+ while not condition() and time.time() < deadline:
+ time.sleep(0.05)
+ return condition()
+
+ try:
+ ready, _, _ = select.select([process.stdout], [], [], 10)
+ assert ready, 'host did not start'
+ port = json.loads(process.stdout.readline())['port']
+ assert wait_for(lambda: any(line['event'].startswith('wrong kind') for line in lines())), lines()
+ assert http(port, 'GET', '/views/count')[3] == b'3' and http(port, 'GET', '/views/current')[3] == b'three'
+ assert http(port, 'POST', '/inputs/typed', b'four')[0] == 200
+ assert wait_for(lambda: any(line['event'] == 'changed' and line['views']['count'] == 4 for line in lines()))
+ finally:
+ process.send_signal(signal.SIGTERM)
+ _, error = process.communicate(timeout=30)
+ assert process.returncode == 0 and b'"returned":0' in error, error[-500:]
+ got = lines()
+ views = [line['views'] for line in got]
+ assert [line['event'] for line in got[:6]] == ['start', 'typed one: 0', 'typed two: 0', 'typed three: 0', 'pressed: 0',
+ 'wrong kind: 8, no input: 8'], got[:6]
+ assert views[:6] == [{'count': 0, 'current': '(empty)', 'finished': None},
+ {'count': 1, 'current': 'one', 'finished': 0}, {'count': 2, 'current': 'two', 'finished': 0},
+ {'count': 3, 'current': 'three', 'finished': 0}, {'count': 3, 'current': 'three', 'finished': 1},
+ {'count': 3, 'current': 'three', 'finished': 1}], views[:6]
+ assert views[-1] == {'count': 4, 'current': 'four', 'finished': 0}, views[-1]
+ changes = [line['changes'] for line in got]
+ assert changes[1:5] == sorted(set(changes[1:5])) and changes[-1] > changes[4], changes
+ # A module without states has no owner to apply a surface's events.
+ stateless = work / 'surface-echo.aup'
+ stateless.write_text('input shout = text\non shout { }\n')
+ compile_module(aup, stateless, stateless.with_suffix('.pmod'))
+ refused = subprocess.run([str(a) for a in (pure / 'build/pure-serve', '--module', stateless.with_suffix('.pmod'),
+ '--store', work / 'surface-echo', '--port', '0', '--surface', probe, '--',
+ out, 'shout', 'shout', 'x')], capture_output=True, timeout=30)
+ assert refused.returncode == 2 and b'needs an app with states' in refused.stderr, refused.stderr
+ return {'events': 5, 'lines': len(got)}
+
+
def server_webhook(aup, pure, work):
"""An output wired to a third-party HTTPS endpoint (a Python server with
a certificate of its own): five orders arrive in order as JSON with the
@@ -1396,6 +1482,7 @@ def main():
report['server_links'] = server_links(aup, pure, work)
report['server_webhook'] = server_webhook(aup, pure, work)
report['server_sealed'] = server_sealed(aup, pure, work)
+ report['server_surface'] = server_surface(aup, pure, work)
report['server_answers'] = server_answers(aup, pure, work)
print(json.dumps(report))
diff --git a/tests/surface-probe.c b/tests/surface-probe.c
new file mode 100644
index 0000000..51feb95
--- /dev/null
+++ b/tests/surface-probe.c
@@ -0,0 +1,85 @@
+#define _GNU_SOURCE
+/* A surface for tests/check.py (serve/surface.h): as a person at the node's
+ * display would, it types each line of TEXTS into the text input and then
+ * presses the key input; after each it writes what the views show, and then,
+ * until the server stops, what they show after each save the node made for
+ * others (HTTP requests). One JSON line each into OUTPUT.
+ *
+ * pure-serve ... --surface surface-probe.so -- OUTPUT TEXT_INPUT KEY_INPUT TEXTS VIEW... */
+#include "surface.h"
+#include <poll.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+static int named(const char (*names)[PURE_MODULE_NAME], unsigned count, const char *name) {
+ for (unsigned i = 0; i < count; ++i)
+ if (!strcmp(names[i], name)) return (int)i;
+ return -1;
+}
+
+static bool put_text(void *context, PureSpan span) {
+ fwrite(span.data, 1, span.size, context);
+ return true;
+}
+
+/* {"event":...,"changes":N,"views":{"name":value,...}} as the last save shows them. */
+static void show(const PureSurface *s, PureApp *reader, FILE *out, const char *event, char **views, int count) {
+ PureSnapshot *snapshot = s->read(s->host, reader);
+ fprintf(out, "{\"event\":\"%s\",\"changes\":%llu,\"views\":{", event,
+ snapshot ? (unsigned long long)reader->changes : 0ull);
+ for (int i = 0; i < count; ++i) {
+ int view = named(s->module->view_names, s->module->views, views[i]);
+ fprintf(out, "%s\"%s\":", i ? "," : "", views[i]);
+ double value;
+ if (!snapshot || view < 0) fputs("null", out);
+ else if (s->module->view_type[view] == PURE_VIEW_TEXT) {
+ fputc('"', out);
+ pure_app_view_text(reader, (uint32_t)view, put_text, out);
+ fputc('"', out);
+ } else if (pure_app_view(reader, (uint32_t)view, &value)) fprintf(out, "%g", value);
+ else fputs("null", out);
+ }
+ fputs("}}\n", out);
+ fflush(out);
+ if (snapshot) s->release(s->host, snapshot);
+}
+
+int pure_surface_run(const PureSurface *s, int argc, char **argv) {
+ if (s->version != PURE_SURFACE_VERSION || s->app_size != sizeof(PureApp) || s->module_size != sizeof(PureModule))
+ return 2;
+ if (argc < 5) return 2;
+ FILE *out = fopen(argv[1], "w");
+ int text = named(s->module->input_names, s->module->inputs, argv[2]);
+ int key = named(s->module->input_names, s->module->inputs, argv[3]);
+ if (!out || text < 0 || key < 0) return 2;
+ static PureApp reader; /* zeroed the first time, as read wants */
+ char **views = argv + 5;
+ int view_count = argc - 5;
+ show(s, &reader, out, "start", views, view_count);
+ char texts[256];
+ snprintf(texts, sizeof(texts), "%s", argv[4]);
+ for (char *line = strtok(texts, ","); line; line = strtok(NULL, ",")) {
+ PureResult typed = s->type(s->host, (uint32_t)text, (PureSpan){(const unsigned char *)line, strlen(line)});
+ char event[300];
+ snprintf(event, sizeof(event), "typed %s: %d", line, (int)typed);
+ show(s, &reader, out, event, views, view_count);
+ }
+ char event[64];
+ snprintf(event, sizeof(event), "pressed: %d", (int)s->press(s->host, (uint32_t)key));
+ show(s, &reader, out, event, views, view_count);
+ /* Refusals: an input of another kind, and one past the module's. */
+ snprintf(event, sizeof(event), "wrong kind: %d, no input: %d", (int)s->press(s->host, (uint32_t)text),
+ (int)s->type(s->host, s->module->inputs, (PureSpan){0}));
+ show(s, &reader, out, event, views, view_count);
+ while (!s->stopping(s->host)) {
+ struct pollfd wait = {s->changes, POLLIN, 0};
+ if (poll(&wait, 1, 1000) <= 0) continue;
+ uint64_t times;
+ if (read(s->changes, &times, sizeof(times)) != sizeof(times) || s->stopping(s->host)) continue;
+ show(s, &reader, out, "changed", views, view_count);
+ }
+ pure_app_stop(&reader);
+ fclose(out);
+ return 0;
+}