lang: give cross-cutting concerns an owner instead of a convention
El's units of encapsulation are the function and the module. Neither can hold
a concern that belongs to the process, so each one had been expressed the only
way it could be -- as a convention: call this at every site. Conventions of
that shape do not hold. Measured here: zero process-identity guards at any
layer, 20 environment variables each with its default written inline at the
read site, 62 persist call sites, 10 per-route auth checks. One absence, four
times.
Step 0 first, because the premise was wrong. El was believed to have no
middleware or effect mechanism. It has one, and it is already load-bearing:
codegen injects engram_boundary_beat at the entry of every @manager/@accessor
fn, decorators take arguments and stack, dharma_emit from a non-@manager fn is
a #error, and the cgi block injects el_cgi_init at the head of main(). So the
correct move was not to invent a mechanism but to generalize the seam that
already existed. The real gap is narrower and is now recorded: the seam is
prologue-only and its callee is a fixed builtin.
Adds a `program` block -- the third program-level declarative block. cgi and
service declare what a program may do; program declares what it is.
program "engram" {
singleton: "engram"
env ENGRAM_BIND: String = ":8742"
env GUIDE_PORT: Int = "8771"
}
singleton takes an exclusive flock before any user statement runs and refuses a
second start, reporting the holder's pid. It is a lock rather than a pidfile so
the kernel releases it on death including SIGKILL -- no stale state, and so no
"delete the lock file to get unstuck" ritual, which would itself be a
convention. It reports the pid because "already running" is not actionable; a
pid is. That is the direct answer to a stale process surviving a pkill and
going on answering probes.
env entries resolve once at startup -- environment wins, declaration supplies
the fallback -- and validate as a whole, reporting every problem at once rather
than costing one restart per variable. config("X") for an undeclared X is
fatal, because an advisory schema is just another convention. Programs without
a program block are unaffected, so migration is per-program.
Only one keyword is added. `config` and `env` could not become keywords -- both
are real identifiers in the tree -- so the block's fields are read as
identifier token values by its own parse loop and stay usable everywhere else.
The init function is emitted at the block site and called from main() rather
than inlined into main(). The live backend is codegen_streaming, which emits in
source order and cannot hold the entry list alive until main(); this way only a
single bool has to survive.
Also fixes: config() was defined in el_runtime.c but never prototyped in
el_runtime.h, so any el program calling it failed to compile under C99.
Spec: section 18 documents what shipped. Section 9 is corrected -- it claimed
decorators had no structural meaning, which has not been true for some time.
Section 19 designs durability-as-an-epilogue-effect and route authorization
and states plainly why neither is implemented here: both land in files under
concurrent modification, and the prerequisite for both is lifting the seam
from prologue-only to prologue/epilogue.
Self-hosting fixpoint verified byte-identical.
This commit is contained in:
+188
-2
@@ -43,6 +43,7 @@
|
||||
#include <dlfcn.h> /* dlsym for http_set_handler fallback */
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <sys/file.h> /* flock — process-identity singleton (program block) */
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
@@ -18335,11 +18336,196 @@ void log_warn(el_val_t msg_v) {
|
||||
fprintf(stderr, "[WARN] %s\n", msg ? msg : "");
|
||||
}
|
||||
|
||||
/* config — read a configuration value from the environment.
|
||||
* Returns "" if the variable is not set (same as __env_get). */
|
||||
/* ── Cross-cutting concerns: process identity and configuration ──────────────
|
||||
*
|
||||
* These back the `program` block (see lang/spec/language.md §18). Both concerns
|
||||
* were previously conventions — "check nothing is already running first",
|
||||
* "remember the right default at every read site" — and conventions is exactly
|
||||
* what they failed as. Here they are mechanisms, injected by the compiler at
|
||||
* the process boundary, so no call site has to remember anything.
|
||||
*/
|
||||
|
||||
/* -- Process identity ------------------------------------------------------- */
|
||||
|
||||
/* The lock fd is deliberately never closed. Holding it open for the process
|
||||
* lifetime is what makes the guarantee work: the kernel drops an flock when the
|
||||
* owning process dies, including on SIGKILL and on crash. That is why this is an
|
||||
* flock and not a bare pidfile — there is no stale-lock state to clean up, and
|
||||
* therefore no "delete the pidfile to get unstuck" ritual that would itself
|
||||
* become a convention. */
|
||||
static int el_singleton_fd = -1;
|
||||
static char el_singleton_path[1024];
|
||||
|
||||
static const char* el_singleton_dir(void) {
|
||||
const char* d = getenv("EL_SINGLETON_DIR");
|
||||
if (d && *d) return d;
|
||||
d = getenv("TMPDIR");
|
||||
if (d && *d) return d;
|
||||
return "/tmp";
|
||||
}
|
||||
|
||||
/* el_singleton_acquire — claim exclusive process identity, or refuse to start.
|
||||
* Compiler-injected as the FIRST statement of main() for any program whose
|
||||
* `program` block declares `singleton:`. */
|
||||
el_val_t el_singleton_acquire(el_val_t id_v) {
|
||||
const char* id = EL_CSTR(id_v);
|
||||
if (!id || !*id) return EL_NULL;
|
||||
|
||||
/* Sanitise the id into a filename. */
|
||||
char safe[256];
|
||||
size_t si = 0;
|
||||
for (const char* p = id; *p && si + 1 < sizeof(safe); p++) {
|
||||
char c = *p;
|
||||
int ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')
|
||||
|| (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.';
|
||||
safe[si++] = (char)(ok ? c : '-');
|
||||
}
|
||||
safe[si] = '\0';
|
||||
snprintf(el_singleton_path, sizeof(el_singleton_path),
|
||||
"%s/el-singleton-%s.lock", el_singleton_dir(), safe);
|
||||
|
||||
int fd = open(el_singleton_path, O_RDWR | O_CREAT, 0644);
|
||||
if (fd < 0) {
|
||||
fprintf(stderr, "[el] FATAL: singleton '%s': cannot open lock file %s: %s\n",
|
||||
id, el_singleton_path, strerror(errno));
|
||||
exit(1);
|
||||
}
|
||||
if (flock(fd, LOCK_EX | LOCK_NB) != 0) {
|
||||
/* Someone else holds it. Report WHO. A pid is actionable; "already
|
||||
* running" is not — and the observed failure was precisely a stale
|
||||
* process that `pkill -f` had silently failed to match, still answering
|
||||
* probes while a fresh build was believed to be under test. */
|
||||
char buf[64];
|
||||
buf[0] = '\0';
|
||||
ssize_t n = pread(fd, buf, sizeof(buf) - 1, 0);
|
||||
if (n > 0) buf[n] = '\0';
|
||||
long holder = strtol(buf, NULL, 10);
|
||||
fprintf(stderr, "[el] FATAL: another instance of '%s' is already running", id);
|
||||
if (holder > 0) fprintf(stderr, " (pid %ld)", holder);
|
||||
fprintf(stderr, ".\n"
|
||||
"[el] lock: %s\n"
|
||||
"[el] Refusing to start a second instance against the same\n"
|
||||
"[el] state. Stop the running one and VERIFY it is gone\n"
|
||||
"[el] (ps -p <pid>) before retrying.\n",
|
||||
el_singleton_path);
|
||||
close(fd);
|
||||
exit(1);
|
||||
}
|
||||
/* We own it. Record our pid so the next would-be starter can name us. */
|
||||
if (ftruncate(fd, 0) != 0) { /* best effort — the lock is the guarantee */ }
|
||||
char pidbuf[32];
|
||||
int pn = snprintf(pidbuf, sizeof(pidbuf), "%ld\n", (long)getpid());
|
||||
if (pn > 0) { ssize_t w = write(fd, pidbuf, (size_t)pn); (void)w; }
|
||||
el_singleton_fd = fd; /* never closed, by design */
|
||||
return EL_NULL;
|
||||
}
|
||||
|
||||
/* -- Configuration ---------------------------------------------------------- */
|
||||
|
||||
#define EL_CONFIG_MAX 128
|
||||
|
||||
typedef struct {
|
||||
char name[128];
|
||||
char type[16];
|
||||
char* value; /* resolved: env value, else default; NULL if unset */
|
||||
int has_default;
|
||||
int required;
|
||||
} ElConfigEntry;
|
||||
|
||||
static ElConfigEntry el_config_tab[EL_CONFIG_MAX];
|
||||
static int el_config_n = 0;
|
||||
static int el_config_has_schema = 0; /* did this program declare one at all? */
|
||||
|
||||
static int el_config_is_int(const char* s) {
|
||||
if (!s || !*s) return 0;
|
||||
if (*s == '-' || *s == '+') s++;
|
||||
if (!*s) return 0;
|
||||
for (; *s; s++) if (*s < '0' || *s > '9') return 0;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* el_config_declare — record ONE configuration entry and resolve it now.
|
||||
* The default lives here, in the declaration, and nowhere else. */
|
||||
el_val_t el_config_declare(el_val_t name_v, el_val_t type_v, el_val_t def_v,
|
||||
el_val_t has_default_v, el_val_t required_v) {
|
||||
const char* name = EL_CSTR(name_v);
|
||||
if (!name || !*name) return EL_NULL;
|
||||
el_config_has_schema = 1;
|
||||
if (el_config_n >= EL_CONFIG_MAX) {
|
||||
fprintf(stderr, "[el] FATAL: more than %d config entries declared.\n", EL_CONFIG_MAX);
|
||||
exit(1);
|
||||
}
|
||||
const char* type = EL_CSTR(type_v);
|
||||
const char* def = (def_v == EL_NULL) ? NULL : EL_CSTR(def_v);
|
||||
ElConfigEntry* e = &el_config_tab[el_config_n++];
|
||||
snprintf(e->name, sizeof(e->name), "%s", name);
|
||||
snprintf(e->type, sizeof(e->type), "%s", type ? type : "String");
|
||||
e->has_default = (int)(long)has_default_v;
|
||||
e->required = (int)(long)required_v;
|
||||
/* Resolution order: environment wins, declaration supplies the fallback. */
|
||||
const char* env = getenv(name);
|
||||
if (env && *env) e->value = el_strdup_persist(env);
|
||||
else if (e->has_default && def) e->value = el_strdup_persist(def);
|
||||
else e->value = NULL;
|
||||
return EL_NULL;
|
||||
}
|
||||
|
||||
/* el_config_validate — check the whole schema at once, before main() runs.
|
||||
* Reports EVERY problem, not just the first: a startup that fails one variable
|
||||
* at a time costs one restart per variable. */
|
||||
el_val_t el_config_validate(el_val_t program_v) {
|
||||
const char* prog = EL_CSTR(program_v);
|
||||
int bad = 0;
|
||||
for (int i = 0; i < el_config_n; i++) {
|
||||
ElConfigEntry* e = &el_config_tab[i];
|
||||
if (!e->value) {
|
||||
if (e->required) {
|
||||
fprintf(stderr, "[el] config: %s is required but is not set "
|
||||
"(no value in the environment, no default declared)\n", e->name);
|
||||
bad++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (strcmp(e->type, "Int") == 0 && !el_config_is_int(e->value)) {
|
||||
fprintf(stderr, "[el] config: %s is declared Int but its value is \"%s\"\n",
|
||||
e->name, e->value);
|
||||
bad++;
|
||||
}
|
||||
}
|
||||
if (bad) {
|
||||
fprintf(stderr, "[el] FATAL: program '%s' has %d invalid configuration "
|
||||
"entr%s. Refusing to start.\n",
|
||||
prog ? prog : "?", bad, bad == 1 ? "y" : "ies");
|
||||
exit(1);
|
||||
}
|
||||
return EL_NULL;
|
||||
}
|
||||
|
||||
/* config — read a configuration value.
|
||||
*
|
||||
* When the program declared a schema, that schema is authoritative: the value
|
||||
* has already been resolved and validated at startup, so this is a lookup and
|
||||
* NOT a place where a default gets decided. Reading a key that was never
|
||||
* declared is a bug at the read site, and is reported as one — that enforcement
|
||||
* is what makes the declaration real rather than advisory.
|
||||
*
|
||||
* With no schema declared, behaviour is unchanged (plain getenv), so programs
|
||||
* that have not migrated keep working. */
|
||||
el_val_t config(el_val_t key_v) {
|
||||
const char* key = EL_CSTR(key_v);
|
||||
if (!key || !*key) return EL_STR("");
|
||||
if (el_config_has_schema) {
|
||||
for (int i = 0; i < el_config_n; i++) {
|
||||
if (strcmp(el_config_tab[i].name, key) == 0) {
|
||||
const char* v = el_config_tab[i].value;
|
||||
return el_wrap_str(el_strdup(v ? v : ""));
|
||||
}
|
||||
}
|
||||
fprintf(stderr, "[el] FATAL: config(\"%s\") is not declared in the "
|
||||
"program block. Declare it there, with its default, or stop "
|
||||
"reading it.\n", key);
|
||||
exit(1);
|
||||
}
|
||||
const char* val = getenv(key);
|
||||
if (!val) return EL_STR("");
|
||||
return el_wrap_str(el_strdup(val));
|
||||
|
||||
@@ -957,6 +957,22 @@ el_val_t __url_decode(el_val_t s);
|
||||
/* Environment */
|
||||
el_val_t __env_get(el_val_t key);
|
||||
|
||||
/* Cross-cutting concerns declared by a `program` block (spec §18).
|
||||
* All three are COMPILER-INJECTED at the head of main() — they are not meant to
|
||||
* be written by hand, which is the point: the guarantee cannot be forgotten at a
|
||||
* call site because there is no call site. */
|
||||
el_val_t el_singleton_acquire(el_val_t id); /* §18.1 process identity */
|
||||
el_val_t el_config_declare(el_val_t name, el_val_t type,
|
||||
el_val_t deflt, el_val_t has_default,
|
||||
el_val_t required); /* §18.2 config schema */
|
||||
el_val_t el_config_validate(el_val_t program_name); /* §18.2 startup validate */
|
||||
|
||||
/* config(key) — the READ side, and the only one programs write by hand. With a
|
||||
* schema declared it is a validated lookup; without one it degrades to getenv.
|
||||
* (Defined in el_runtime.c but previously never prototyped here, so any program
|
||||
* calling it failed to compile under -Werror=implicit-function-declaration.) */
|
||||
el_val_t config(el_val_t key);
|
||||
|
||||
/* Subprocess */
|
||||
el_val_t __exec(el_val_t cmd);
|
||||
el_val_t __exec_bg(el_val_t cmd);
|
||||
|
||||
Reference in New Issue
Block a user