vendor/agent/ is a package like any other - agent.flan declares three calls, flan_agent.c implements them, link asks for -lpthread. start listens on a unix socket, poll installs whatever arrived and says how many, wait does the same after waiting for something. The split between poll and the listener is the whole design. dlopen relocates a module and takes the loader lock, which is milliseconds and unbounded, so it happens on the listener thread. flan_reload_install is one store per function and must not land while a redefined function is on the stack, so it happens on the game thread at the top of the frame, when the program asks. A ring and two atomics connect them; the game thread never blocks on the loader. wait exists for tests. A test that races the frame rate fails on a loaded machine, so test/programs/agent.flan waits for the reload rather than sleeping past it. It also sends a junk path first: the daemon is a separate process and can send anything, and a bad path must be refused rather than take down the program it was sent to. Two things came out of running it. The reply goes out before the module is queued, because the other way round the game thread can install and the program can exit between the two, and the answer reaches the sender as a connection reset instead of as ok. And ok means queued, not installed - the sender does not get to know when the swap happened, since only the program knows when it is between frames. sand.flan now polls at the top of its loop, which is what this step was for. Under Xvfb, one line on the socket and 455 consecutive frames drew from a game-draw that did not exist when the process started. Building without --dev still works: there are no cells, so a module is refused on the listener thread and the loop never notices. flan reload builds one module the way the daemon will. --new names what the host was not built with, which is the one thing the command cannot work out for itself and exactly what the session will track.
163 lines
5.6 KiB
C
163 lines
5.6 KiB
C
/* flan_agent — the half of the dev loop that lives in the running program.
|
|
*
|
|
* A redefinition arrives as a path to a .so (runtime/flan_dev.c and
|
|
* Emit.redefinition are what put it there). Two things have to happen to it,
|
|
* and they must happen on different threads:
|
|
*
|
|
* dlopen relocates the module and runs the loader. It is milliseconds,
|
|
* unbounded, and takes the loader lock. Doing it on the game thread
|
|
* is a dropped frame.
|
|
* install is one store per redefined function. It is sub-microsecond, and
|
|
* it must happen at a point where no redefined function is on the
|
|
* stack — a frame boundary — or a frame runs half in the old code
|
|
* and half in the new.
|
|
*
|
|
* So the listener thread does the loading and hands over a function pointer;
|
|
* the game thread calls flan-poll or flan-wait when it is between frames and
|
|
* that is when the swap becomes visible. Nothing else in the program needs to
|
|
* know the agent exists.
|
|
*
|
|
* Nothing is ever dlclosed: a cell holds an address inside a module's text,
|
|
* and unloading it would leave every call site pointing at unmapped memory.
|
|
*
|
|
* This is not a protocol. One line per request, the path to load, and a one
|
|
* line answer. The daemon and its nREPL are a separate program that will speak
|
|
* to a socket, not something this file grows into.
|
|
*/
|
|
|
|
#include <dlfcn.h>
|
|
#include <errno.h>
|
|
#include <pthread.h>
|
|
#include <stdatomic.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <string.h>
|
|
#include <sys/socket.h>
|
|
#include <sys/un.h>
|
|
#include <time.h>
|
|
#include <unistd.h>
|
|
|
|
typedef void (*install_fn)(void);
|
|
|
|
/* A ring the listener writes and the game thread reads. One producer, one
|
|
* consumer, so two atomics and no lock — the game thread must never block on
|
|
* the loader. Overflow drops the oldest request rather than stalling; a dev
|
|
* loop that queues 64 reloads between two frames has a bigger problem. */
|
|
#define QUEUE 64
|
|
static install_fn queue[QUEUE];
|
|
static atomic_uint head; /* written by the listener */
|
|
static atomic_uint tail; /* written by the game thread */
|
|
|
|
static int listen_fd = -1;
|
|
static pthread_t listener;
|
|
static atomic_int started;
|
|
|
|
static void publish(install_fn f) {
|
|
unsigned h = atomic_load_explicit(&head, memory_order_relaxed);
|
|
queue[h % QUEUE] = f;
|
|
/* Release: the store to the slot must be visible before the index that
|
|
* advertises it. */
|
|
atomic_store_explicit(&head, h + 1, memory_order_release);
|
|
}
|
|
|
|
/* Returns how many modules were installed. Call it between frames. */
|
|
int32_t flan_agent_poll(void) {
|
|
unsigned t = atomic_load_explicit(&tail, memory_order_relaxed);
|
|
unsigned h = atomic_load_explicit(&head, memory_order_acquire);
|
|
int32_t n = 0;
|
|
while (t != h) {
|
|
install_fn f = queue[t % QUEUE];
|
|
t++;
|
|
if (f != NULL) { f(); n++; }
|
|
}
|
|
atomic_store_explicit(&tail, t, memory_order_relaxed);
|
|
return n;
|
|
}
|
|
|
|
/* The same, but waits up to [ms] for something to arrive first. A game loop
|
|
* does not want this; a headless test does, because it makes the reload
|
|
* deterministic instead of a race against the frame rate. */
|
|
int32_t flan_agent_wait(int32_t ms) {
|
|
struct timespec step = { 0, 1000000 }; /* 1ms */
|
|
for (int32_t i = 0; i < ms; i++) {
|
|
int32_t n = flan_agent_poll();
|
|
if (n > 0) return n;
|
|
nanosleep(&step, NULL);
|
|
}
|
|
return flan_agent_poll();
|
|
}
|
|
|
|
static void reply(int fd, const char *s) {
|
|
size_t n = strlen(s);
|
|
while (n > 0) {
|
|
ssize_t k = write(fd, s, n);
|
|
if (k <= 0) return;
|
|
s += k;
|
|
n -= (size_t)k;
|
|
}
|
|
}
|
|
|
|
/* One connection, one line, one module. Loading here rather than in the game
|
|
* thread is the whole reason this thread exists. */
|
|
static void serve(int fd) {
|
|
char line[4096];
|
|
size_t n = 0;
|
|
for (;;) {
|
|
ssize_t k = read(fd, line + n, sizeof line - n - 1);
|
|
if (k <= 0) return;
|
|
n += (size_t)k;
|
|
line[n] = '\0';
|
|
char *nl = strchr(line, '\n');
|
|
if (nl == NULL) {
|
|
if (n == sizeof line - 1) { reply(fd, "err path too long\n"); return; }
|
|
continue;
|
|
}
|
|
*nl = '\0';
|
|
void *h = dlopen(line, RTLD_NOW | RTLD_LOCAL);
|
|
if (h == NULL) {
|
|
reply(fd, "err ");
|
|
reply(fd, dlerror());
|
|
reply(fd, "\n");
|
|
return;
|
|
}
|
|
install_fn f = (install_fn)(uintptr_t)dlsym(h, "flan_reload_install");
|
|
if (f == NULL) { reply(fd, "err no flan_reload_install\n"); return; }
|
|
/* Answer before queueing, not after. The game thread can install and run
|
|
* to completion between the two, and a program that exits there would tear
|
|
* down this connection with the reply still unwritten — which reaches the
|
|
* sender as a reset, not as an answer. */
|
|
reply(fd, "ok\n");
|
|
/* "queued", not "installed": the store happens on the game thread, at a
|
|
* time this thread does not get to choose. */
|
|
publish(f);
|
|
return;
|
|
}
|
|
}
|
|
|
|
static void *accept_loop(void *arg) {
|
|
(void)arg;
|
|
for (;;) {
|
|
int fd = accept(listen_fd, NULL, NULL);
|
|
if (fd < 0) { if (errno == EINTR) continue; return NULL; }
|
|
serve(fd);
|
|
close(fd);
|
|
}
|
|
}
|
|
|
|
/* [path] is a Flan string: ptr and len, not NUL-terminated. */
|
|
int32_t flan_agent_start(const uint8_t *path, int64_t len) {
|
|
struct sockaddr_un addr;
|
|
if (atomic_exchange(&started, 1)) return 0;
|
|
if (len <= 0 || (size_t)len >= sizeof addr.sun_path) return -1;
|
|
memset(&addr, 0, sizeof addr);
|
|
addr.sun_family = AF_UNIX;
|
|
memcpy(addr.sun_path, path, (size_t)len);
|
|
unlink(addr.sun_path);
|
|
listen_fd = socket(AF_UNIX, SOCK_STREAM, 0);
|
|
if (listen_fd < 0) return -1;
|
|
if (bind(listen_fd, (struct sockaddr *)&addr, sizeof addr) < 0) return -1;
|
|
if (listen(listen_fd, 4) < 0) return -1;
|
|
if (pthread_create(&listener, NULL, accept_loop, NULL) != 0) return -1;
|
|
return 0;
|
|
}
|