aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--docs/design/008-module-format.md9
-rw-r--r--docs/design/019-pushing-views.md2
-rw-r--r--serve/serve.c357
-rw-r--r--tests/check.py251
4 files changed, 613 insertions, 6 deletions
diff --git a/docs/design/008-module-format.md b/docs/design/008-module-format.md
index 63d260a..da9abd9 100644
--- a/docs/design/008-module-format.md
+++ b/docs/design/008-module-format.md
@@ -494,6 +494,15 @@ text·리스트가 남을 때만 위치를 유지한다.
아직 모듈 앱을 런타임 코어의 효과 단위로 세분하지는 않았다(`runtime.c`의 `definition`은 앱 전체다). 호스트가 직접 호출한다.
+**보기를 미는 길**([019](019-pushing-views.md)). 보기의 `GET`에 `Accept: text/event-stream`이면 그 연결은 구독자가 된다: 답의
+머리(`200`, `text/event-stream`)와 `retry: 1000`을 보내고, 요청을 읽던 연결 기록(약 6 KB)은 돌려주고 소켓과 구독(앱, 보기, 인자)만
+남는다. 같은 연결 스레드에서 같은 보기와 인자를 구독한 것들은 무리 하나다. 소유자가 스냅숏을 공개하면(구독자가 있을 때) 연결 스레드를
+깨우고, 스레드는 `--push-every-ms`(기본 10 ms)마다 많아야 한 번 무리마다 새 스냅숏을 한 번 계산해 값이 앞의 것과 다르면 `id: 스냅숏의
+바뀜 번호`와 `data:` 줄(값의 줄마다)을 무리의 구독자에게 보낸다. 값이 없으면 `event: none`과 그 까닭이다. 보내던 이벤트를 다 보내지
+못한 구독자에게는 새 값 하나만 기다리게 한다(그전 것은 바꾼다). 앱의 모듈이 바뀌면 같은 이름, 같은 종류의 보기로 이어 가고, 없으면
+끊는다. 앱이 멈추거나 토큰 파일이 그 구독의 토큰을 더 허락하지 않으면 끊는다. `--subscribers`(기본 65,536)가 스레드의 구독자 수를
+묶는다(넘으면 503 `busy`).
+
## 실행기 크기
실행기는 `-O2` text로 49.8 KB였다가 42.2 KB가 됐다. `module.c`가 34.0 → 27.0 KB, `module-list.c` 5.6 KB,
diff --git a/docs/design/019-pushing-views.md b/docs/design/019-pushing-views.md
index 3d4642f..e94fd61 100644
--- a/docs/design/019-pushing-views.md
+++ b/docs/design/019-pushing-views.md
@@ -1,6 +1,6 @@
# 019 — 보기를 미는 길 (SSE)
-작성일: 2026-10-04. 상태: 설계와 착수 기준(구현 전).
+작성일: 2026-10-04. 상태: 설계와 착수 기준. 1단계 구현, 검사로 고정했다.
[외부 리뷰 005](../outside-feedback/feedback-005.md)의 4장 (2) "실시간 클라이언트 푸시(SSE / WebSocket) 미지원"에서 시작한다.
## 문제
diff --git a/serve/serve.c b/serve/serve.c
index b26ee68..fb84425 100644
--- a/serve/serve.c
+++ b/serve/serve.c
@@ -140,6 +140,7 @@ typedef struct Connection {
Phase phase;
bool readable, writable, keep_alive, put, post;
bool hung_up; /* the peer closed its side: read until the end, never stop at a short read */
+ bool events; /* its request asks for an event stream (Accept: text/event-stream) */
bool settled; /* on its app's home thread, or staying here (a host of apps moves it once, at its first request) */
uint64_t deadline;
char key[65]; /* the document's key, or the view's or input's name */
@@ -215,6 +216,7 @@ typedef struct {
typedef struct Published {
pthread_mutex_t lock;
PureSnapshot *snapshot;
+ _Atomic unsigned subscribers; /* streams of its views on any thread (docs/design/019): publishing wakes their threads */
} Published;
/* The app and its store. A connection thread runs it itself (an app of
@@ -450,6 +452,8 @@ static Host *all_hosts; /* the connection threads, for moving a connection to i
static unsigned host_count;
static bool moved_home(Connection *c, size_t head_size);
static void schedule(Owner *owner);
+static void subscribe(Connection *c, const Route *route, const char *raw, size_t raw_size);
+static void wake_pushers(void);
struct Host {
Executor local; /* the one app of the host, when this thread runs it (documents alone) */
@@ -485,6 +489,14 @@ struct Host {
* read already (under lock, woken by wake too). */
struct Moved { int fd; bool hung_up; size_t used; char in[HEADER_LIMIT]; } *moved;
size_t moved_count, moved_capacity;
+ /* Its views' subscribers (docs/design/019), in groups that share an
+ * evaluation; push_due: an owner published since its last pass. */
+ struct Group *groups;
+ size_t subscribers, subscribers_at_stop;
+ _Atomic bool push_due;
+ bool push_waiting; /* a pass is due but --push-every-ms has not passed since the last */
+ uint64_t pushed_at; /* when the last pass ran (ms) */
+ uint64_t pushes, pushed_events, replaced, subscribed, unsubscribed;
};
/* Grow an array to hold `need` items; false when memory runs out. */
@@ -777,6 +789,7 @@ static bool parse_head(Connection *c, char *head, size_t size, char **query) {
c->batch = 1;
c->seal.suite[0] = 0;
c->bearer_given = false;
+ c->events = false;
bool counted = false;
c->body_size = 0;
c->json = false;
@@ -816,6 +829,8 @@ static bool parse_head(Connection *c, char *head, size_t size, char **query) {
condition_seen = true;
} else if (!strcasecmp(line, "Content-Type")) {
c->json = !strncasecmp(value, "application/json", 16);
+ } else if (!strcasecmp(line, "Accept")) {
+ c->events = strcasestr(value, "text/event-stream") != NULL; /* a view's stream (docs/design/019) */
} else if (!strcasecmp(line, "Authorization")) {
size_t length = strlen(value);
if (c->bearer_given || length < 8 || strncasecmp(value, "Bearer ", 7) || length - 7 > BEARER) return false;
@@ -1185,6 +1200,8 @@ static void start_request(Connection *c, size_t head_size) {
reject(c, denied, denied == 401 ? "Unauthorized" : "Forbidden", denied == 401 ? "unauthorized" : "forbidden");
return;
}
+ /* A view's stream (docs/design/019): pushed from snapshots, so an owned app's. */
+ if (c->events && route.kind == ROUTE_VIEW && route.app->owned) { subscribe(c, &route, raw, raw_size); return; }
/* A document's PUT carries its body; an input's event its payload's size,
* the payload (the fixed fields, then the body), and room for its JSON. */
bool write = route.kind == ROUTE_INPUT || c->put;
@@ -1771,6 +1788,7 @@ static void publish(Executor *ex) {
ex->published_module = ex->module;
++ex->publications;
if (ex->surface) surface_changed(ex->surface);
+ if (atomic_load_explicit(&ex->published->subscribers, memory_order_relaxed)) wake_pushers();
}
/* An output's outlets: its wires first, then its links. Each holds a
@@ -2292,6 +2310,299 @@ static PureSnapshot *read_published(Host *host, App *app) {
return snapshot;
}
+/* ---- Pushing views (docs/design/019). A view's GET that asks for an event
+ * stream makes its connection a subscriber: its socket, and the group of
+ * this thread's subscribers to that view with those arguments. A pass, at
+ * most every --push-every-ms, evaluates each group once on its app's
+ * newest snapshot and sends the value to the group when it changed; a new
+ * subscriber gets the value the group has. A subscriber still sending one
+ * event keeps only the newest after it: a value, not every event. */
+typedef struct Subscriber {
+ int fd;
+ struct Group *group;
+ struct Subscriber *next; /* in its group */
+ bool writable, fresh; /* fresh: it has not had the group's value yet */
+ char *out; /* what is left of the event being sent (NULL: none), and the newest after it */
+ size_t out_size, out_at;
+ char *waiting;
+ size_t waiting_size;
+ uint64_t stalled; /* since when out has not moved (ms) */
+ const Tokens *checked; /* the tokens it was let in by, and its token's digest (token: it gave one) */
+ bool token;
+ unsigned char digest[32];
+} Subscriber;
+
+typedef struct Group {
+ App *app;
+ Route route; /* its view in the module it was resolved with; given: text */
+ char name[PURE_MODULE_NAME];
+ char *text; /* the query as asked */
+ bool computed, failed; /* a value computed, from the snapshot of changes; failed: why holds a fault's name */
+ uint64_t changes;
+ char *value; /* the value's bytes (or why it has none), and as an event */
+ size_t value_size;
+ char *event;
+ size_t event_size;
+ Subscriber *first;
+ struct Group *next;
+} Group;
+
+static size_t subscribers_most = 65536; /* --subscribers: a thread's */
+static uint64_t push_every_ms = 10; /* --push-every-ms */
+
+/* An owner published (or its app stopped) and someone subscribes to its
+ * views: each thread that has not been told since its last pass is. */
+static void wake_pushers(void) {
+ for (unsigned i = 0; i < host_count; ++i) {
+ Host *h = &all_hosts[i];
+ uint64_t one = 1;
+ if (!atomic_exchange_explicit(&h->push_due, true, memory_order_acq_rel) && write(h->wake, &one, sizeof(one)) < 0) {
+ /* full: it is readable already */
+ }
+ }
+}
+
+static char *copied(const char *bytes, size_t size) {
+ char *copy = malloc(size ? size : 1);
+ if (!copy) abort();
+ if (size) memcpy(copy, bytes, size);
+ return copy;
+}
+
+/* An event to a subscriber: sent now as far as its socket takes it, the
+ * rest kept; while one is kept, the newest waits after it, replacing what
+ * waited. False when its socket failed. */
+static bool subscriber_send(Host *host, Subscriber *s, const char *event, size_t size) {
+ ++host->pushed_events;
+ if (s->out) {
+ if (s->waiting) { free(s->waiting); ++host->replaced; }
+ s->waiting = copied(event, size);
+ s->waiting_size = size;
+ return true;
+ }
+ ssize_t n = s->writable ? send(s->fd, event, size, MSG_NOSIGNAL | MSG_DONTWAIT) : 0;
+ if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) return false;
+ if (n < 0) n = 0;
+ if ((size_t)n < size) {
+ s->writable = false;
+ s->out = copied(event + n, size - (size_t)n);
+ s->out_size = size - (size_t)n;
+ s->out_at = 0;
+ s->stalled = host->now;
+ }
+ return true;
+}
+
+/* What a subscriber kept, sent as far as its socket takes it. False when its socket failed. */
+static bool subscriber_flush(Host *host, Subscriber *s) {
+ while (s->out && s->writable) {
+ ssize_t n = send(s->fd, s->out + s->out_at, s->out_size - s->out_at, MSG_NOSIGNAL | MSG_DONTWAIT);
+ if (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK) return false;
+ if (n <= 0) { s->writable = false; break; }
+ s->out_at += (size_t)n;
+ s->stalled = host->now;
+ if (s->out_at < s->out_size) continue;
+ free(s->out);
+ s->out = s->waiting, s->out_size = s->waiting_size, s->out_at = 0;
+ s->waiting = NULL;
+ }
+ return true;
+}
+
+static void unsubscribe(Host *host, Subscriber *s) {
+ Group *g = s->group;
+ for (Subscriber **at = &g->first; *at; at = &(*at)->next)
+ if (*at == s) { *at = s->next; break; }
+ close(s->fd); /* also leaves the epoll set */
+ free(s->out);
+ free(s->waiting);
+ atomic_fetch_sub_explicit(&g->app->owner.published.subscribers, 1, memory_order_relaxed);
+ --host->subscribers;
+ ++host->unsubscribed;
+ free(s);
+ if (g->first) return;
+ for (Group **at = &host->groups; *at; at = &(*at)->next)
+ if (*at == g) { *at = g->next; break; }
+ free(g->text);
+ free(g->value);
+ free(g->event);
+ free(g);
+}
+
+static void close_group(Host *host, Group *g) {
+ while (g->first && g->first->next) unsubscribe(host, g->first);
+ unsubscribe(host, g->first); /* the last lets the group go */
+}
+
+/* Whether a subscriber's token still lets it read (tokens: the app's now). */
+static bool still_reads(const Tokens *tokens, const Subscriber *s) {
+ if (!tokens) return true;
+ if (!s->token) return false;
+ for (unsigned g = 0; g < tokens->count; ++g) {
+ unsigned char differ = 0;
+ for (unsigned i = 0; i < 32; ++i) differ |= (unsigned char)(tokens->grant[g].digest[i] ^ s->digest[i]);
+ if (!differ) return tokens->grant[g].kind == GRANT_READ || tokens->grant[g].kind == GRANT_WRITE;
+ }
+ return false;
+}
+
+static void subscriber_event(Host *host, Subscriber *s, uint32_t events) {
+ bool gone = events & (EPOLLERR | EPOLLHUP | EPOLLRDHUP);
+ if (events & EPOLLOUT) s->writable = true;
+ if (events & EPOLLIN) { /* a stream reads nothing: what comes is let go, and its end ends it */
+ char sink[512];
+ ssize_t n;
+ while ((n = recv(s->fd, sink, sizeof(sink), 0)) > 0) {}
+ if (n == 0 || (n < 0 && errno != EAGAIN && errno != EWOULDBLOCK)) gone = true;
+ }
+ if (gone || !subscriber_flush(host, s)) unsubscribe(host, s);
+}
+
+/* A group's value from the snapshot its app's reader shows: as the view's
+ * GET answers it, or why it has none (event none). An event made when it
+ * differs from the last; then each subscriber gets it, and a fresh one the
+ * group's value at least. */
+static void push_group(Host *host, Group *g) {
+ App *app = g->app;
+ if (!atomic_load(&app->live) || atomic_load(&app->gone)) { close_group(host, g); return; }
+ const Tokens *tokens = atomic_load_explicit(&app->tokens, memory_order_acquire);
+ for (Subscriber *s = g->first, *after; s; s = after) { /* a tokens file changed: who may still read */
+ after = s->next;
+ if (s->checked == tokens) continue;
+ if (!still_reads(tokens, s)) {
+ if (!s->next && g->first == s) { unsubscribe(host, s); return; } /* the group goes with it */
+ unsubscribe(host, s);
+ } else s->checked = tokens;
+ }
+ PureSnapshot *snapshot = read_published(host, app);
+ if (!snapshot) return; /* none yet: the next publication wakes this */
+ PureApp *reader = &host->readers[app - apps];
+ if (reader->module != g->route.module) { /* the app changed module: the view of that name, if it is the same kind */
+ const PureModule *was = g->route.module, *now = reader->module;
+ int view = pure_module_view(now, g->name);
+ if (view < 0 || now->view_type[view] != was->view_type[g->route.target] ||
+ (pure_module_arguments(now, (uint32_t)view) < 0) != (pure_module_arguments(was, g->route.target) < 0)) {
+ pure_snapshot_release(snapshot);
+ close_group(host, g);
+ return;
+ }
+ g->route.module = now;
+ g->route.target = (uint32_t)view;
+ g->computed = false;
+ }
+ if (!g->computed || reader->changes != g->changes) {
+ Applied a = {0};
+ PureResult status = show_view(reader, &g->route, PURE_OBJECT_LIMIT, &a);
+ Output value = {0};
+ bool failed = status != PURE_OK;
+ if (failed) { /* why it has none */
+ PureFault fault = a.fault != PURE_FAULT_NONE ? a.fault : pure_app_view_fault(reader);
+ const char *why = status == PURE_NOT_FOUND && fault == PURE_FAULT_NONE ? "no_value" : pure_fault_name(fault);
+ put_bytes(&value, why, strlen(why));
+ } else if (a.extents) {
+ for (size_t i = 0; i < a.extent_count; ++i) put_bytes(&value, a.extents[i].iov_base, a.extents[i].iov_len);
+ } else put_bytes(&value, a.resource.body.data, a.resource.body.size);
+ if (a.resource.release) a.resource.release(a.resource.allocation, a.resource.allocation_size);
+ free(a.extents);
+ if (value.failed) abort();
+ bool same = g->computed && failed == g->failed && value.size == g->value_size &&
+ (!value.size || !memcmp(value.bytes, g->value, value.size));
+ g->changes = reader->changes;
+ g->computed = true;
+ if (!same) {
+ free(g->value);
+ g->value = (char *)value.bytes, g->value_size = value.size, g->failed = failed;
+ value.bytes = NULL;
+ Output event = {0}; /* id, an event's name if it has none, and a data line per line */
+ char id[32];
+ int length = snprintf(id, sizeof(id), "id: %" PRIu64 "\n", g->changes);
+ put_bytes(&event, id, (size_t)length);
+ if (failed) put_bytes(&event, "event: none\n", 12);
+ for (size_t at = 0;;) { /* a data line per line of the value */
+ const char *end = at < g->value_size ? memchr(g->value + at, '\n', g->value_size - at) : NULL;
+ size_t line = end ? (size_t)(end - (g->value + at)) : g->value_size - at;
+ put_bytes(&event, "data: ", 6);
+ if (line) put_bytes(&event, g->value + at, line);
+ put_bytes(&event, "\n", 1);
+ if (!end) break;
+ at += line + 1;
+ }
+ put_bytes(&event, "\n", 1);
+ if (event.failed) abort();
+ free(g->event);
+ g->event = (char *)event.bytes, g->event_size = event.size;
+ for (Subscriber *s = g->first; s; s = s->next) s->fresh = true; /* each gets it */
+ }
+ free(value.bytes);
+ }
+ pure_snapshot_release(snapshot);
+ for (Subscriber *s = g->first, *after; s; s = after) {
+ after = s->next;
+ if (!s->fresh) continue;
+ s->fresh = false;
+ if (!subscriber_send(host, s, g->event, g->event_size)) {
+ if (!s->next && g->first == s) { unsubscribe(host, s); return; }
+ unsubscribe(host, s);
+ }
+ }
+}
+
+static void push_pass(Host *host) {
+ host->pushed_at = host->now;
+ ++host->pushes;
+ for (Group *g = host->groups, *next; g; g = next) {
+ next = g->next;
+ push_group(host, g);
+ }
+}
+
+/* A view's GET that asks for a stream: the connection becomes a subscriber
+ * of its group (made if this thread has none of that view and those
+ * arguments), its answer's head sent; the pass sends it the value. */
+static void subscribe(Connection *c, const Route *route, const char *raw, size_t raw_size) {
+ Host *host = c->host;
+ if (c->in_used) { refuse(c, 400, "Bad Request", "request"); return; } /* no request follows a stream */
+ if (host->subscribers >= subscribers_most) { reject(c, 503, "Service Unavailable", "busy"); return; }
+ Group *g = host->groups;
+ while (g && !(g->app == route->app && g->route.module == route->module && g->route.target == route->target &&
+ g->route.from == route->from && g->route.count == route->count && strlen(g->text) == raw_size &&
+ !memcmp(g->text, raw, raw_size)))
+ g = g->next;
+ if (!g) {
+ if (!(g = calloc(1, sizeof(*g)))) abort();
+ g->app = route->app;
+ g->route = *route;
+ g->text = copied(raw, raw_size + 1);
+ g->text[raw_size] = 0;
+ g->route.given = g->text;
+ snprintf(g->name, sizeof(g->name), "%s", route->module->view_names[route->target]);
+ g->next = host->groups;
+ host->groups = g;
+ }
+ Subscriber *s = calloc(1, sizeof(*s));
+ if (!s) abort();
+ s->fd = c->fd;
+ s->group = g;
+ s->writable = true;
+ s->fresh = true;
+ s->checked = atomic_load_explicit(&route->app->tokens, memory_order_acquire);
+ if ((s->token = c->bearer_given && c->hashed_valid)) memcpy(s->digest, c->hashed_digest, sizeof(s->digest));
+ s->next = g->first;
+ g->first = s;
+ struct epoll_event event = {.events = EPOLLIN | EPOLLOUT | EPOLLRDHUP | EPOLLET, .data.u64 = (uintptr_t)s | 1u};
+ if (epoll_ctl(host->epoll, EPOLL_CTL_MOD, s->fd, &event)) abort();
+ c->fd = -1; /* the socket is the subscriber's now */
+ close_connection(c);
+ atomic_fetch_add_explicit(&route->app->owner.published.subscribers, 1, memory_order_relaxed);
+ ++host->subscribers;
+ ++host->subscribed;
+ static const char head[] = "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-store\r\n"
+ "X-Pure-Error: none\r\n\r\nretry: 1000\n\n";
+ --host->pushed_events; /* the head is not an event */
+ if (!subscriber_send(host, s, head, sizeof(head) - 1)) { unsubscribe(host, s); return; }
+ atomic_store_explicit(&host->push_due, true, memory_order_release); /* its value goes in this round's pass */
+}
+
/* ---- Queries: PUT /queries/NAME registers a module of the app's states and
* views of its own (aup query), checked when it comes: it must load (be
* verified) with its states laid out as the app's, and have a view NAME.
@@ -3005,6 +3316,7 @@ static void stop_executor(Executor *ex) {
ex->published->snapshot = NULL;
pthread_mutex_unlock(&ex->published->lock);
pure_snapshot_release(last);
+ if (atomic_load_explicit(&ex->published->subscribers, memory_order_relaxed)) wake_pushers(); /* their streams end */
}
pure_app_stop(&ex->app);
pure_store_close(ex->store);
@@ -3079,9 +3391,18 @@ static void *run_shard(void *context) {
while (!atomic_load(&stopping)) {
/* A thread that runs the app ticks its timers on time. */
long due = apps[0].owned ? -1 : run_timers(&host->local);
+ if (host->push_waiting) { /* a pass waits for --push-every-ms */
+ uint64_t since = host->now - host->pushed_at;
+ long left = since >= push_every_ms ? 0 : (long)(push_every_ms - since);
+ if (due < 0 || left < due) due = left;
+ }
int ready = epoll_wait(host->epoll, events, EVENTS, due >= 0 && due < 1000 ? (int)due : 1000);
host->now = now_ms();
for (int e = 0; e < ready; ++e) {
+ if (events[e].data.u64 & 1u) { /* a subscriber's socket */
+ subscriber_event(host, (Subscriber *)(uintptr_t)(events[e].data.u64 & ~(uint64_t)1), events[e].events);
+ continue;
+ }
Connection *c = events[e].data.ptr;
if (!c) { accept_all(host); continue; }
if (events[e].data.ptr == &host->wake) {
@@ -3097,6 +3418,11 @@ static void *run_shard(void *context) {
drive(c);
}
serve_bursts(host);
+ bool told = atomic_exchange_explicit(&host->push_due, false, memory_order_acq_rel);
+ if (host->groups && (told || host->push_waiting)) { /* at most one pass every --push-every-ms */
+ host->push_waiting = host->now - host->pushed_at < push_every_ms;
+ if (!host->push_waiting) push_pass(host);
+ }
uint64_t now = host->now;
if (now >= next_sweep) {
next_sweep = now + 1000;
@@ -3104,6 +3430,17 @@ static void *run_shard(void *context) {
Connection *c = host->table[i];
if (c && c->phase != WAITING && now >= c->deadline) { ++host->expired; close_connection(c); }
}
+ /* A subscriber whose event has not moved for --timeout-ms is let go. */
+ for (Group *g = host->groups, *next_group; g; g = next_group) {
+ next_group = g->next;
+ for (Subscriber *s = g->first, *after; s; s = after) {
+ after = s->next;
+ if (!s->out || now - s->stalled < host->timeout_ms) continue;
+ ++host->expired;
+ if (!s->next && g->first == s) { unsubscribe(host, s); break; }
+ unsubscribe(host, s);
+ }
+ }
}
/* The round is over: closed connections can be reused. */
while (host->closed) {
@@ -3121,6 +3458,8 @@ static void *run_shard(void *context) {
if (poll(&wake, 1, -1) > 0) answer_handed(host);
}
for (size_t i = 0; i < host->capacity; ++i) if (host->table[i]) close_connection(host->table[i]);
+ host->subscribers_at_stop = host->subscribers;
+ while (host->groups) close_group(host, host->groups);
return NULL;
}
@@ -3730,6 +4069,8 @@ static bool give_tokens(App *app) {
const Tokens *before = atomic_exchange_explicit(&app->tokens, tokens, memory_order_acq_rel);
struct RetiredTokens *kept = before ? malloc(sizeof(*kept)) : NULL;
if (kept) *kept = (struct RetiredTokens){(Tokens *)before, retired_tokens}, retired_tokens = kept;
+ if (all_hosts && atomic_load_explicit(&app->owner.published.subscribers, memory_order_relaxed))
+ wake_pushers(); /* its streams are let in again by the new tokens, or end */
return true;
}
@@ -4199,6 +4540,8 @@ int main(int argc, char **argv) {
else if (!strcmp(argv[i], "--workers") && number(argv[i + 1], 256, &value) && value) workers = (unsigned)value;
else if (!strcmp(argv[i], "--app-slots") && number(argv[i + 1], 65536, &value) && value) slots = (unsigned)value;
else if (!strcmp(argv[i], "--timeout-ms") && number(argv[i + 1], 3600000, &value) && value) timeout_ms = value;
+ else if (!strcmp(argv[i], "--subscribers") && number(argv[i + 1], 1u << 22, &value)) subscribers_most = (size_t)value;
+ else if (!strcmp(argv[i], "--push-every-ms") && number(argv[i + 1], 60000, &value)) push_every_ms = value;
else goto usage;
}
if (surface_path) surface_argv[surface_argc++] = (char *)surface_path;
@@ -4414,7 +4757,8 @@ int main(int argc, char **argv) {
PureLinkCounts sent = {0}, heard = {0}; /* what links sent, and what the listener took */
pure_link_listener_close(link_listener, &heard); /* after the owner, which tells it what it stored */
size_t active = 0, retained = 0, state_bytes = 0;
- uint64_t unauthorized = 0, forbidden = 0;
+ uint64_t unauthorized = 0, forbidden = 0, subscribed = 0, unsubscribed = 0, pushes = 0, pushed = 0, replaced = 0;
+ size_t subscribers = 0;
uint64_t moved = 0, handed = 0, accepted = 0, requests = 0, bursts = 0, events = 0, expired = 0, ticks = 0, shown = 0, checkpoints = 0,
memory_refused = 0, windows_full = 0, publications = 0, answers = 0, delivered = 0, attempts = 0;
for (unsigned i = 0; i < threads; ++i) {
@@ -4430,6 +4774,8 @@ int main(int argc, char **argv) {
memory_refused += hosts[i].local.memory_refused;
expired += hosts[i].expired;
unauthorized += hosts[i].unauthorized, forbidden += hosts[i].forbidden;
+ subscribers += hosts[i].subscribers_at_stop, subscribed += hosts[i].subscribed, unsubscribed += hosts[i].unsubscribed;
+ pushes += hosts[i].pushes, pushed += hosts[i].pushed_events, replaced += hosts[i].replaced;
stop_shard(&hosts[i]);
}
for (unsigned a = 0, count = places(); a < count; ++a) { /* each app's runs, the present one stopped now */
@@ -4465,13 +4811,14 @@ int main(int argc, char **argv) {
",\"listener_swept\":%" PRIu64 ",\"listener_active\":%" PRIu64 ",\"listener_keyed\":%" PRIu64
",\"listener_secrets\":%" PRIu64 ",\"listener_epochs\":%" PRIu64 ",\"listener_refused\":%" PRIu64
",\"state_bytes\":%zu,\"memory_refused\":%" PRIu64 ",\"windows_full\":%" PRIu64
- ",\"unauthorized\":%" PRIu64 ",\"forbidden\":%" PRIu64,
+ ",\"unauthorized\":%" PRIu64 ",\"forbidden\":%" PRIu64 ",\"subscribers\":%zu,\"subscribed\":%" PRIu64
+ ",\"pushes\":%" PRIu64 ",\"pushed\":%" PRIu64 ",\"push_replaced\":%" PRIu64,
active, retained, accepted, requests, bursts, events, ticks, expired, threads, apps[0].owned ? "true" : "false",
publications, shown, delivered, attempts, answers, groups, grouped,
checkpoints, sent.packets, sent.records, sent.resent, sent.lost, sent.acknowledged, sent.stored,
sent.refused, heard.packets, heard.received, heard.deliveries, heard.woken, heard.remembered, heard.swept,
heard.active, heard.keyed, heard.secrets, heard.epochs, heard.refused, state_bytes, memory_refused,
- windows_full, unauthorized, forbidden);
+ windows_full, unauthorized, forbidden, subscribers, subscribed, pushes, pushed, replaced);
if (named_apps) { /* what each app did */
fprintf(stderr, ",\"workers\":%u,\"moved_home\":%" PRIu64 ",\"handed\":%" PRIu64 ",\"apps\":[",
worker_count, moved, handed);
@@ -4492,12 +4839,12 @@ int main(int argc, char **argv) {
return 0;
usage:
fputs("pure-serve --module FILE.pmod --store DIRECTORY [--port 8767] [--threads 1|N|auto] [--budget BYTES] "
- "[--connections 1024] [--timeout-ms 10000] [--send OUTPUT=URL ...] [--name NAME] "
+ "[--connections 1024] [--timeout-ms 10000] [--subscribers 65536] [--push-every-ms 10] [--send OUTPUT=URL ...] [--name NAME] "
"[--send-ca FILE] [--reply OUTPUT=INPUT ...] [--seal OUTPUT=KEY ...] [--key FILE] [--sealed-only] "
"[--sender NAME=KEY ...] [--link-port PORT] [--senders FILE] [--link-idle-ms 30000] [--memory BYTES] [--work N] "
"[--window 4194304] [--tokens FILE] [--surface LIBRARY [-- ARGUMENTS]]\n"
"pure-serve --apps FILE [--port 8767] [--threads 1|N|auto] [--workers N] [--app-slots 256] [--budget BYTES] [--connections 1024] "
- "[--timeout-ms 10000] [--key FILE] [--sealed-only] [--sender NAME=KEY ...] [--link-port PORT] [--senders FILE] "
+ "[--timeout-ms 10000] [--subscribers 65536] [--push-every-ms 10] [--key FILE] [--sealed-only] [--sender NAME=KEY ...] [--link-port PORT] [--senders FILE] "
"[--send-ca FILE] [--window BYTES]\n"
" --apps FILE: a host of apps (docs/design/017-apps-on-one-host.md), a line each:\n"
" NAME MODULE.pmod STORE [memory=BYTES] [work=N] [queue=N] [send=OUTPUT=URL ...] [seal=OUTPUT=KEY ...]\n"
diff --git a/tests/check.py b/tests/check.py
index 38bc93d..91aec12 100644
--- a/tests/check.py
+++ b/tests/check.py
@@ -34,6 +34,7 @@ import random
import subprocess
import sys
import tempfile
+import time
from pathlib import Path
HERE = Path(__file__).resolve().parent
@@ -4492,6 +4493,255 @@ def server_view_bodies(aup, pure, work):
return {'answers': asked[0], 'connections': 64}
+PUSH_SOURCE = """record Row {
+ name: text
+ n = 0
+}
+state count = 0
+state note: text = "a"
+state rows: list of Row
+input tap = text
+input same = text
+input fill = message {
+ k: u32
+}
+on tap {
+ count = count + 1
+ note = "line {count}\\nsecond"
+}
+on same {
+ count = count
+}
+on fill {
+ for i in 0 .. fill.k {
+ rows.append(Row { name: "row {i} {count} ......................................................................................................................................................................................................................................................................................................................................................................", n: i })
+ }
+}
+view total = count
+view shown = note
+view twice(k: u32) = count * k
+view page = each r in rows {
+ name = r.name
+ n = r.n + count
+}
+view sum(k: u32) {
+ let acc = 0
+ for i in 0 .. k {
+ acc = acc + count
+ }
+ return acc
+}
+"""
+
+
+class EventStream:
+ """A view's event stream (docs/design/019) read off a socket: its head,
+ then its events as (id, event, data)."""
+
+ def __init__(self, port, path, token=None, rcvbuf=None):
+ import socket
+ self.sock = socket.socket()
+ if rcvbuf:
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf)
+ self.sock.connect(('127.0.0.1', port))
+ head = f'GET {path} HTTP/1.1\r\nHost: x\r\nAccept: text/event-stream\r\n'
+ if token:
+ head += f'Authorization: Bearer {token}\r\n'
+ self.sock.sendall((head + '\r\n').encode())
+ self.buffer = b''
+ self.status = None
+
+ def fill(self, timeout):
+ self.sock.settimeout(timeout)
+ data = self.sock.recv(1 << 20)
+ if not data:
+ raise EOFError
+ self.buffer += data
+
+ def head(self, timeout=5):
+ while b'\r\n\r\n' not in self.buffer:
+ self.fill(timeout)
+ head, self.buffer = self.buffer.split(b'\r\n\r\n', 1)
+ lines = head.decode().split('\r\n')
+ self.status = int(lines[0].split()[1])
+ return self.status, dict(line.split(': ', 1) for line in lines[1:])
+
+ def next(self, timeout=5):
+ if self.status is None:
+ self.head(timeout)
+ while True:
+ while b'\n\n' not in self.buffer:
+ self.fill(timeout)
+ block, self.buffer = self.buffer.split(b'\n\n', 1)
+ fields = {'data': []}
+ for line in block.decode().split('\n'):
+ name, _, value = line.partition(': ')
+ if name == 'data':
+ fields['data'].append(value)
+ else:
+ fields[name] = value
+ if 'id' in fields:
+ return int(fields['id']), fields.get('event'), '\n'.join(fields['data'])
+
+ def ends(self, timeout=5):
+ """Whether the host ends it (the socket closes) within timeout, events meanwhile let go."""
+ import socket
+ deadline = time.monotonic() + timeout
+ try:
+ while time.monotonic() < deadline:
+ self.fill(max(0.01, deadline - time.monotonic()))
+ except EOFError:
+ return True
+ except (socket.timeout, TimeoutError):
+ return False
+ return False
+
+ def close(self):
+ self.sock.close()
+
+
+def server_push(aup, pure, work):
+ """A view's value pushed to its subscribers (docs/design/019): GET with
+ Accept: text/event-stream. The value comes first, then each value that
+ differs (none for a write that leaves it), the newest at least after a
+ burst, ids rising; arguments, table pages, lines of text and a view
+ that computes; tokens; a host of apps whose module changes (a view of
+ the same name goes on, one that went ends the stream) and whose tokens
+ change (a token no longer granted ends it); a subscriber that does not
+ read holds the host to two events (the waiting one replaced); and the
+ host lets go of every subscriber that leaves."""
+ import hashlib
+ import select
+ import signal
+ module, source = work / 'push.pmod', work / 'push.aup'
+ source.write_text(PUSH_SOURCE)
+ compile_module(aup, source, module)
+ host = HttpHost([pure / 'build/pure-serve', '--module', module, '--store', work / 'push', '--threads', 2])
+ report = {}
+ try:
+ def post(path, body=b'x'):
+ assert http(host.port, 'POST', path, body)[0] == 200, path
+
+ total, shown, twice, sums = (EventStream(host.port, f'/views/{p}') for p in ('total', 'shown', 'twice?k=3', 'sum?k=4'))
+ assert total.head()[0] == 200 and total.next()[1:] == (None, '0')
+ assert shown.next()[2] == 'a' and twice.next()[2] == '0' and sums.next()[2] == '0'
+ seen = []
+ for k in range(1, 6): # one write each 50 ms: each value comes, in order
+ post('/inputs/tap')
+ time.sleep(0.05)
+ seen.append(total.next())
+ assert seen[-1][2] == str(k), seen
+ assert shown.next()[2] == f'line {k}\nsecond' and twice.next()[2] == str(3 * k) and sums.next()[2] == str(4 * k)
+ assert [i for i, _, _ in seen] == sorted({i for i, _, _ in seen})
+ post('/inputs/same') # the value stays: nothing comes
+ for _ in range(200): # a burst: the newest comes at last, never an older after a newer
+ post('/inputs/tap')
+ last, values = seen[-1][0], []
+ while not values or values[-1] != '205':
+ i, _, value = total.next()
+ assert i > last, (i, last)
+ last = i
+ values.append(value)
+ assert [int(v) for v in values] == sorted(int(v) for v in values)
+ report['burst_events'] = len(values)
+ try:
+ extra = total.next(timeout=0.3)
+ raise AssertionError(f'an event after the last value: {extra}')
+ except (TimeoutError, OSError):
+ pass
+ # A table page, and one that does not read (its socket's buffer small): the host keeps two events of it.
+ assert http(host.port, 'POST', '/inputs/fill?k=100', b'')[0] == 200
+ page = EventStream(host.port, '/views/page?from=0&count=100')
+ slow = EventStream(host.port, '/views/page?from=0&count=100', rcvbuf=4096)
+ first = json.loads(page.next()[2])
+ assert first['rows'] == 100 and len(first['items']) == 100, first
+ rss = lambda: next(int(line.split()[1]) for line in Path(f'/proc/{host.process.pid}/status').read_text().splitlines()
+ if line.startswith('VmRSS'))
+ before = rss()
+ for _ in range(600):
+ post('/inputs/tap')
+ time.sleep(0.002)
+ grew = (rss() - before) // 1024
+ final = None
+ while final is None or final['items'][0]['n'] != 805:
+ final = json.loads(slow.next(timeout=10)[2])
+ report['slow_rss_growth_mib'] = grew
+ assert grew < 32, grew
+ for stream in (total, shown, twice, sums, page, slow):
+ stream.close()
+ time.sleep(0.2)
+ finally:
+ stats = host.stop()
+ assert stats[0]['subscribers'] == 0 and stats[0]['subscribed'] == 6, stats[0]
+ assert stats[0]['push_replaced'] >= 1, stats[0]
+ report['replaced'] = stats[0]['push_replaced']
+ # A host of apps: tokens, and modules and tokens that change while streams run.
+ token, writer = 'r' * 40, 'w' * 40
+ grant = lambda kind, t: f'{kind} {hashlib.sha256(t.encode()).hexdigest()}\n'
+ (work / 'push.tokens').write_text(grant('read', token) + grant('write', writer))
+ other = work / 'push-tenfold.aup'
+ other.write_text(PUSH_SOURCE.replace('view total = count', 'view total = count * 10'))
+ compile_module(aup, other, work / 'push-tenfold.pmod')
+ gone = work / 'push-gone.aup'
+ gone.write_text(PUSH_SOURCE.replace('view total = count\n', ''))
+ compile_module(aup, gone, work / 'push-gone.pmod')
+ conf = work / 'push.conf'
+
+ def write(app_module, tokens=True):
+ conf.write_text(f"p {app_module} {work / 'push-p'}" + (f" tokens={work / 'push.tokens'}" if tokens else '') + '\n')
+
+ def reading():
+ host.process.send_signal(signal.SIGHUP)
+ ready, _, _ = select.select([host.process.stdout], [], [], 30)
+ assert ready, 'no reading'
+ return json.loads(host.process.stdout.readline())
+
+ write(module)
+ host = HttpHost([pure / 'build/pure-serve', '--apps', conf])
+ try:
+ refused = EventStream(host.port, '/p/views/total')
+ assert refused.head()[0] == 401
+ refused.close()
+ stream = EventStream(host.port, '/p/views/total', token=token)
+ assert stream.next()[2] == '0'
+ assert call_with(host.port, 'POST', '/p/inputs/tap', writer) == 200
+ assert stream.next()[2] == '1'
+ write(work / 'push-tenfold.pmod')
+ said = reading()
+ assert said['apps'][0]['did'] == 'changed', said
+ assert stream.next()[2] == '10' # the same stream, the new module's view
+ assert call_with(host.port, 'POST', '/p/inputs/tap', writer) == 200
+ assert stream.next()[2] == '20'
+ (work / 'push.tokens').write_text(grant('read', 'another') + grant('write', writer))
+ reading()
+ assert call_with(host.port, 'POST', '/p/inputs/tap', 'another') == 403 # read alone
+ assert stream.ends(), 'a stream whose token went still runs'
+ (work / 'push.tokens').write_text(grant('read', token) + grant('write', writer))
+ reading()
+ stream = EventStream(host.port, '/p/views/total', token=token)
+ assert stream.next()[2] == '20'
+ write(work / 'push-gone.pmod')
+ reading()
+ assert stream.ends(), 'a stream of a view that went still runs'
+ finally:
+ stats = host.stop()
+ assert stats[0]['subscribers'] == 0, stats[0]
+ return report
+
+
+def call_with(port, method, path, token, body=b'x'):
+ """A request bearing a token: its status."""
+ import http.client
+ connection = http.client.HTTPConnection('127.0.0.1', port, timeout=10)
+ try:
+ connection.request(method, path, body, {'Authorization': f'Bearer {token}', 'Content-Length': str(len(body))})
+ answer = connection.getresponse()
+ answer.read()
+ return answer.status
+ finally:
+ connection.close()
+
+
def server_tokens(aup, pure, work):
"""An app's routes take tokens (docs/design/017, 3b). With a tokens file
an app answers no request without a token (401, WWW-Authenticate:
@@ -4718,6 +4968,7 @@ def main():
report['server_transient'] = server_transient(aup, pure, work)
report['server_runaway'] = server_runaway(aup, pure, work)
report['server_view_bodies'] = server_view_bodies(aup, pure, work)
+ report['server_push'] = server_push(aup, pure, work)
print(json.dumps(report))