vextoly/cryn

0.1.1

Commit ee91cb1 · Vextoly · 2026-10-04T19:48:49+02:00

Changeset
ee91cb1c5258c2c3ad11f4e4a941fe636845c6c2
Parents
2c8ef979e3c6105a3e1c6dfaed9575eb4e81304b

View source at this commit

Comments

No comments yet.

Log in to comment

Diff

diff --git a/README.md b/README.md
index 138b779..d6caf2c 100644
--- a/README.md
+++ b/README.md
@@ -158,11 +158,12 @@ The runtime (`src/rt/cryn_rt.*`) provides only:
 |-----------------|----------------------------------|--------------------------------|
 | `malloc`/`free` | `make <size> of`, anonymous `make`, `free` | 16-byte header for validation and bounds checks |
 | value stack     | `push`, `pop`, `swap`, `call`    | growable array, per thread     |
-| focus history   | `move` (relative), `back`, `home`| fixed 256 entries per frame    |
+| focus history   | `move` (relative), `back`, `home`| fixed 256 entries per context (shared across active frames) |
 | FILE* streams   | `input`, `output`, `open`, `close`| stdin/stdout by default       |
 | `system()`      | `system`                         | shell escape                   |
 | pthreads        | `spawn`, `wait`                  | join provides the only ordering guarantee |
 | formatting      | `output`                         | shortest round-trip float printing |
+| relaxed atomics | all scalar loads/stores          | races yield unspecified values and never tear (§6.7 of LANGUAGE.md) |
 
 There is no garbage collector, no interpreter, and no mandatory runtime
 library beyond the above.
@@ -190,12 +191,16 @@ These are honest boundaries of the first implementation, not pretenses:
   for it.
 * **Concurrency is minimal.** `spawn` starts a thread running a
   zero-parameter transformation; `wait` joins all spawned contexts of the
-  current one. Shared world cells may be accessed concurrently; v1 gives
-  no atomicity guarantees beyond `wait` acting as a full join/sync point.
-  Communication of results happens through shared world cells.
+  current one. Shared world cells may be accessed concurrently; a race
+  on an aligned ≤ 8-byte cell yields an unspecified value of the cell's
+  type and never tears (scalar access is lowered as relaxed atomics);
+  there is no ordering beyond `wait`. Communication of results happens
+  through shared world cells.
 * **Indirect calls** (through `transform` references) are only allowed to
   zero-parameter transformations; checked at runtime when the reference is
-  not statically known.
+  not statically known. The callee's result is discarded and the caller's
+  working value (the reference) is preserved; results flow through world
+  cells or addresses.
 * **`open` sets the context's current input/output stream** rather than
   exposing a first-class handle type; `close` uses the last handle value.
   One stream pair per context at a time.
diff --git a/gitignore b/gitignore
new file mode 100644
index 0000000..f265a3c
--- /dev/null
+++ b/gitignore
@@ -0,0 +1,22 @@
+# build artifacts
+cryn
+tools/embed
+src/rt_src.h
+*.o
+*.a
+*.so
+
+# test/run artifacts
+tests/cryn_test_*.txt
+*.exe
+
+# generated compiler output
+out.c
+*.c.gen.c
+
+# editors and OS
+.vscode/
+.idea/
+*.swp
+*~
+.DS_Store
diff --git a/src/be_c.c b/src/be_c.c
index 51e4ba9..eed7925 100644
--- a/src/be_c.c
+++ b/src/be_c.c
@@ -15,7 +15,7 @@ static const char* ctype(const Type* t) {
     switch (t->kind) {
     case TY_NOTHING: return "uint8_t";
     case TY_BOOL: return "uint8_t";
-    case TY_CHAR: return "uint32_t";
+    case TY_CHAR: return "uint8_t";
     case TY_INT8: return "int8_t";
     case TY_INT16: return "int16_t";
     case TY_INT32: return "int32_t";
@@ -102,36 +102,67 @@ static void emit_local_decl(Buf* o, LocalIR* l) {
 /* statement emission                                                 */
 /* ------------------------------------------------------------------ */
 
+/* width-tagged relaxed atomic access for a scalar type */
+static const char* ld_fn(const Type* t) {
+    switch (t->kind) {
+    case TY_BOOL: case TY_CHAR: case TY_INT8: case TY_UINT8: return "crn_ld_u8";
+    case TY_INT16: case TY_UINT16: return "crn_ld_u16";
+    case TY_INT32: case TY_UINT32: case TY_FLOAT32: return "crn_ld_u32";
+    default: return "crn_ld_u64";
+    }
+}
+static const char* st_fn(const Type* t) {
+    switch (t->kind) {
+    case TY_BOOL: case TY_CHAR: case TY_INT8: case TY_UINT8: return "crn_st_u8";
+    case TY_INT16: case TY_UINT16: return "crn_st_u16";
+    case TY_INT32: case TY_UINT32: case TY_FLOAT32: return "crn_st_u32";
+    default: return "crn_st_u64";
+    }
+}
+
+/* unsigned C type matching a scalar's width, for atomic store helpers */
+static const char* uct(const Type* t) {
+    switch (t->kind) {
+    case TY_BOOL: case TY_CHAR: case TY_INT8: case TY_UINT8: return "uint8_t";
+    case TY_INT16: case TY_UINT16: return "uint16_t";
+    case TY_INT32: case TY_UINT32: return "uint32_t";
+    default: return "uint64_t";
+    }
+}
+
 static void emit_store(Buf* o, Insn* in) {
     const Type* t = in->t;
-    const char* ct = ctype(t);
+    const char* u = uct(t);
     switch (in->b) {
     case SC_EXACT:
         if (ty_is_float(t)) {
-            buf_printf(o, "    *(%s*)F = (%s)wv.d;\n", ct, ct);
+            if (t->kind == TY_FLOAT32)
+                buf_printf(o, "    crn_st_f32((float*)F, (float)wv.d);\n");
+            else
+                buf_printf(o, "    crn_st_f64((double*)F, wv.d);\n");
         } else if (t->kind == TY_FUNC) {
+            /* 16-byte reference cells are not race-defined; plain access */
             buf_printf(o, "    *(CrFnRef**)F = wv.p;\n");
         } else if (ty_is_signed_int(t)) {
-            buf_printf(o, "    *(%s*)F = (%s)wv.i;\n", ct, ct);
+            buf_printf(o, "    %s((%s*)F, (%s)wv.i);\n", st_fn(t), u, u);
         } else {
-            buf_printf(o, "    *(%s*)F = (%s)wv.u;\n", ct, ct);
+            buf_printf(o, "    %s((%s*)F, (%s)wv.u);\n", st_fn(t), u, u);
         }
         break;
     case SC_INTLIT:
-        if (ty_is_float(t)) {
-            buf_printf(o, "    *(%s*)F = (%s)(int64_t)wv.u;\n", ct, ct);
-        } else {
-            buf_printf(o, "    *(%s*)F = (%s)(int64_t)wv.u;\n", ct, ct);
-        }
+        buf_printf(o, "    %s((%s*)F, (%s)(int64_t)wv.u);\n", st_fn(t), u, u);
         break;
     case SC_INTLIT_BOOL:
-        buf_printf(o, "    *(uint8_t*)F = (uint8_t)(int64_t)wv.u;\n");
+        buf_printf(o, "    crn_st_u8((uint8_t*)F, (uint8_t)(int64_t)wv.u);\n");
         break;
     case SC_INTLIT_FLOAT:
-        buf_printf(o, "    *(%s*)F = (%s)(int64_t)wv.u;\n", ct, ct);
+        if (t->kind == TY_FLOAT32)
+            buf_printf(o, "    crn_st_f32((float*)F, (float)(int64_t)wv.u);\n");
+        else
+            buf_printf(o, "    crn_st_f64((double*)F, (double)(int64_t)wv.u);\n");
         break;
     case SC_F32:
-        buf_printf(o, "    *(float*)F = (float)wv.d;\n");
+        buf_printf(o, "    crn_st_f32((float*)F, (float)wv.d);\n");
         break;
     default:
         buf_printf(o, "    /* store coercion %d */\n", in->b);
@@ -305,15 +336,23 @@ static void emit_fn(Buf* o, ProgramIR* p, int fi) {
             buf_puts(o, "    crn_free(wv.p);\n");
             break;
         case I_LOAD: {
-            const char* ct = ctype(in->t);
-            if (ty_is_float(in->t))
-                buf_printf(o, "    wv.d = (double)*(%s*)F;\n", ct);
-            else if (in->t->kind == TY_FUNC)
+            const Type* t = in->t;
+            if (t->kind == TY_FLOAT32)
+                buf_printf(o, "    wv.d = (double)crn_ld_f32((const float*)F);\n");
+            else if (t->kind == TY_FLOAT64)
+                buf_printf(o, "    wv.d = crn_ld_f64((const double*)F);\n");
+            else if (t->kind == TY_FUNC)
                 buf_puts(o, "    wv.p = *(CrFnRef**)F;\n");
-            else if (ty_is_signed_int(in->t))
-                buf_printf(o, "    wv.i = (int64_t)*(%s*)F;\n", ct);
+            else if (t->kind == TY_INT8)
+                buf_printf(o, "    wv.i = (int64_t)(int8_t)crn_ld_u8((const uint8_t*)F);\n");
+            else if (t->kind == TY_INT16)
+                buf_printf(o, "    wv.i = (int64_t)(int16_t)crn_ld_u16((const uint16_t*)F);\n");
+            else if (t->kind == TY_INT32)
+                buf_printf(o, "    wv.i = (int64_t)(int32_t)crn_ld_u32((const uint32_t*)F);\n");
+            else if (t->kind == TY_INT64)
+                buf_printf(o, "    wv.i = (int64_t)crn_ld_u64((const uint64_t*)F);\n");
             else
-                buf_printf(o, "    wv.u = (uint64_t)*(%s*)F;\n", ct);
+                buf_printf(o, "    wv.u = (uint64_t)%s((const %s*)F);\n", ld_fn(t), uct(t));
             cur = in->t;
             break;
         }
@@ -347,8 +386,14 @@ static void emit_fn(Buf* o, ProgramIR* p, int fi) {
                 }
                 if (in->op == I_DIV)
                     buf_puts(o, "wv.d = wv.d / b; }\n");
-                else if (in->op == I_MOD)
-                    buf_puts(o, "wv.d = wv.d - b * (wv.d / b); }\n");
+                else if (in->op == I_MOD) {
+                    if (t->kind == TY_FLOAT32)
+                        /* compute and round in single precision like the
+                           other float32 operations */
+                        buf_puts(o, "wv.d = (double)((float)wv.d - (float)b * ((float)wv.d / (float)b)); }\n");
+                    else
+                        buf_puts(o, "wv.d = wv.d - b * (wv.d / b); }\n");
+                }
                 else if (t->kind == TY_FLOAT32)
                     buf_printf(o, "wv.d = (double)((float)wv.d %c (float)b); }\n", opstr);
                 else
@@ -396,11 +441,12 @@ static void emit_fn(Buf* o, ProgramIR* p, int fi) {
                                opstr, esz);
                 }
             } else if (in->a == 1 && in->op == I_SUB) {
-                /* pointer difference */
+                /* pointer difference: computed on the raw 64-bit values and
+                   divided by the element size (truncating toward zero), so
+                   it is defined for any two addresses of any provenance
+                   (C pointer subtraction of unrelated objects is UB) */
                 uint64_t esz = addr_elem_size(cur);
-                buf_printf(o, "    wv.i = (int64_t)(((%s*)wv.p) - ((%s*)crn_pop().p));\n",
-                           ctype(cur->inner->kind == TY_ARR ? cur->inner->inner : cur->inner),
-                           ctype(cur->inner->kind == TY_ARR ? cur->inner->inner : cur->inner));
+                buf_printf(o, "    wv.i = (int64_t)(wv.u - crn_pop().u) / (int64_t)%" PRIu64 ";\n", esz);
                 (void)esz;
             } else if (ty_is_signed_int(t) || t->kind == TY_INTLIT) {
                 const char* ct = ctype(t);
@@ -588,18 +634,20 @@ static void emit_fn(Buf* o, ProgramIR* p, int fi) {
             break;
         }
         case I_CALLIND:
+            /* the callee's result is discarded; the caller's working value
+               is preserved (an indirect call is a call of unknown result,
+               checked as result-nothing) */
             buf_puts(o, "    { CrFnRef* r = (CrFnRef*)wv.p; "
                         "if (r->np != 0) crn_die(\"indirect call of a transformation "
-                        "that takes parameters\"); r->fn(); wv.u = 0; }\n");
-            cur = ty_nothing();
+                        "that takes parameters\"); r->fn(); }\n");
             break;
         case I_RET:
             buf_puts(o, "    cr_ctx.fh_sp = cr_fh0; return wv;\n");
             break;
         case I_STOP:
-            if (ty_is_float(cur))
+            if (ty_is_float(in->t))
                 buf_puts(o, "    crn_stop(crn_d2i64(wv.d));\n");
-            else if (cur->kind == TY_NOTHING || cur->kind == TY_FUNC)
+            else if (in->t->kind == TY_NOTHING || in->t->kind == TY_FUNC)
                 buf_puts(o, "    crn_stop(0);\n");
             else
                 buf_puts(o, "    crn_stop((int64_t)wv.u);\n");
@@ -609,18 +657,25 @@ static void emit_fn(Buf* o, ProgramIR* p, int fi) {
             cur = ty_int(64);
             break;
         case I_OUTPUT:
-            if (ty_is_float(cur)) {
-                if (cur->kind == TY_FLOAT32)
+            if (ty_is_float(in->t)) {
+                if (in->t->kind == TY_FLOAT32)
                     buf_puts(o, "    crn_out_f32((float)wv.d);\n");
                 else
                     buf_puts(o, "    crn_out_f64(wv.d);\n");
-            } else if (cur->kind == TY_CHAR || cur->kind == TY_UINT8) {
+            } else if (in->t->kind == TY_CHAR || in->t->kind == TY_UINT8) {
                 buf_puts(o, "    crn_out_char((unsigned)wv.u);\n");
-            } else if (cur->kind == TY_BOOL) {
+            } else if (in->t->kind == TY_BOOL) {
                 buf_puts(o, "    crn_out_bool((int)wv.u);\n");
-            } else if (cur->kind == TY_ADDR) {
+            } else if (in->t->kind == TY_ADDR) {
                 buf_puts(o, "    crn_out_addr(wv.u);\n");
-            } else if (cur->kind == TY_INTLIT || ty_is_signed_int(cur)) {
+            } else if (in->t->kind == TY_INTLIT) {
+                /* an unadapted literal prints signed when it fits int64,
+                   unsigned otherwise */
+                if (!in->t->litneg && in->t->litval > (uint64_t)INT64_MAX)
+                    buf_puts(o, "    crn_out_u64(wv.u);\n");
+                else
+                    buf_puts(o, "    crn_out_i64((int64_t)wv.u);\n");
+            } else if (ty_is_signed_int(in->t)) {
                 buf_puts(o, "    crn_out_i64((int64_t)wv.u);\n");
             } else {
                 buf_puts(o, "    crn_out_u64(wv.u);\n");
@@ -668,10 +723,11 @@ void be_c_emit(ProgramIR* p, Buf* out) {
     buf_puts(out, cr_rt_c_src);
     buf_putc(out, '\n');
 
-    /* string table */
+    /* string table: writable static byte arrays (anonymous 'make string'
+       yields an ordinary mutable byte array, like every other string) */
     for (int i = 0; i < p->nstrs; i++) {
         StrIR* s = &p->strs[i];
-        buf_printf(out, "static const uint8_t cr_str_%d[] = ", i);
+        buf_printf(out, "static uint8_t cr_str_%d[] = ", i);
         emit_c_str(out, s->bytes, s->len + 1);
         buf_puts(out, ";\n");
     }
diff --git a/src/check.c b/src/check.c
index 51f34bd..ec667f5 100644
--- a/src/check.c
+++ b/src/check.c
@@ -226,13 +226,21 @@ static void st_init(Chk* c) {
 static void st_clone(CState* d, CState* s) { *d = *s; }
 
 /* join two static working-value/stack types for control flow merges.
-   Integer literals widen to the type they fit; two literals join to int64. */
+   Integer literals widen to the integer-family type they fit (int/uint/
+   char/bool); two literals join to int64. Joining a literal with a float
+   type is rejected: the two paths would carry different representations,
+   so the value must be materialized (set a float literal or convert)
+   before the paths meet. */
+static int join_lit_target(Type* t) {
+    return ty_is_int(t) || t->kind == TY_CHAR || t->kind == TY_BOOL;
+}
+
 static Type* join_types(Type* a, Type* b) {
     if (type_eq(a, b)) return a;
     if (a->kind == TY_INTLIT && b->kind == TY_INTLIT) return ty_int(64);
-    if (a->kind == TY_INTLIT && (ty_is_numeric(b) || b->kind == TY_CHAR || b->kind == TY_BOOL))
+    if (a->kind == TY_INTLIT && join_lit_target(b))
         return intlit_fits(a->litval, a->litneg, b) ? b : NULL;
-    if (b->kind == TY_INTLIT && (ty_is_numeric(a) || a->kind == TY_CHAR || a->kind == TY_BOOL))
+    if (b->kind == TY_INTLIT && join_lit_target(a))
         return intlit_fits(b->litval, b->litneg, a) ? a : NULL;
     return NULL;
 }
@@ -274,8 +282,11 @@ static int st_merge(CState* into, CState* from, Loc loc, const char* what) {
         }
         if (!type_eq(js, into->stk[i])) { into->stk[i] = js; changed = 1; }
     }
-    if (from->cmp && !into->cmp) {
-        into->cmp = 1;
+    /* comparison validity joins conservatively: valid after the label only
+       if valid on every incoming path (a conditional jump must never read
+       a comparison that one incoming path never performed) */
+    if (into->cmp && !from->cmp) {
+        into->cmp = 0;
         changed = 1;
     }
     return changed ? 2 : 1;
@@ -314,13 +325,22 @@ static Type* numeric_pair_type(Type* a, Type* b) {
     if (ty_is_float(a) || ty_is_float(b)) return ty_float64();
     if ((ty_is_int(a) || a->kind == TY_CHAR) && (ty_is_int(b) || b->kind == TY_CHAR)) {
         if (type_eq(a, b)) return a;
-        int aw = a->kind == TY_CHAR ? 32 : ty_int_bits(a);
-        int bw = b->kind == TY_CHAR ? 32 : ty_int_bits(b);
+        int aw = a->kind == TY_CHAR ? 8 : ty_int_bits(a);
+        int bw = b->kind == TY_CHAR ? 8 : ty_int_bits(b);
         int as = a->kind == TY_CHAR ? 0 : ty_is_signed_int(a);
         int bs = b->kind == TY_CHAR ? 0 : ty_is_signed_int(b);
+        if (as != bs) {
+            /* mixed signedness: only safe when the unsigned operand is
+               strictly narrower than the signed one, so every one of its
+               values fits the signed result type. Equal or wider unsigned
+               operands (e.g. uint64 vs int64) must be converted first. */
+            int uw = as ? bw : aw;
+            int sw = as ? aw : bw;
+            if (uw >= sw) return NULL;
+            return ty_int(sw);
+        }
         int w = aw > bw ? aw : bw;
-        int sg = as || bs;
-        return sg ? ty_int(w) : ty_uint(w);
+        return as ? ty_int(w) : ty_uint(w);
     }
     if (a->kind == TY_BOOL && b->kind == TY_BOOL) return ty_bool();
     return NULL;
@@ -1002,6 +1022,17 @@ static void check_insn(Chk* c, Node* n) {
             return;
         }
         int ok;
+        /* addresses convert only to integers and other address types:
+           bit reinterpretation; float<->address is rejected (a numeric
+           conversion of address bits is meaningless) */
+        if (from->kind == TY_ADDR && ty_is_float(to)) {
+            err(n->loc, "cannot convert an address to a float; convert to int64/uint64 first");
+            return;
+        }
+        if (ty_is_float(from) && to->kind == TY_ADDR) {
+            err(n->loc, "cannot convert a float to an address; convert to int64/uint64 first");
+            return;
+        }
         if (from->kind == TY_INTLIT) {
             /* integer literals convert to any scalar numeric type; the value
                wraps modulo the target width like any integer conversion */
@@ -1101,7 +1132,9 @@ static void check_insn(Chk* c, Node* n) {
             return;
         }
         emit(c, I_CALLIND, n->loc);
-        st->wv = ty_nothing();
+        /* the callee's result type is not statically known; an indirect
+           call is checked as a call with result nothing, which preserves
+           the caller's working value (the callee's result is discarded) */
         return;
     }
     case A_RET: {
diff --git a/src/rt/cryn_rt.c b/src/rt/cryn_rt.c
index ff9f743..a3c6150 100644
--- a/src/rt/cryn_rt.c
+++ b/src/rt/cryn_rt.c
@@ -101,6 +101,49 @@ void crn_check_range(uint64_t i, uint64_t n) {
     if (i >= n) crn_die_range(i, n);
 }
 
+/* ---- relaxed atomic scalar access ---- */
+
+#if defined(__GNUC__) || defined(__clang__)
+#define CRN_LD(P) __atomic_load_n((P), __ATOMIC_RELAXED)
+#define CRN_ST(P, V) __atomic_store_n((P), (V), __ATOMIC_RELAXED)
+#else
+/* fallback for hosts without the builtins: plain access (single aligned
+   scalar loads/stores do not tear on such hosts in practice) */
+#define CRN_LD(P) (*(P))
+#define CRN_ST(P, V) (*(P) = (V))
+#endif
+
+uint8_t  crn_ld_u8(const uint8_t* p) { return CRN_LD((uint8_t*)p); }
+uint16_t crn_ld_u16(const uint16_t* p) { return CRN_LD((uint16_t*)p); }
+uint32_t crn_ld_u32(const uint32_t* p) { return CRN_LD((uint32_t*)p); }
+uint64_t crn_ld_u64(const uint64_t* p) { return CRN_LD((uint64_t*)p); }
+float    crn_ld_f32(const float* p) {
+    uint32_t b = crn_ld_u32((const uint32_t*)(const void*)p);
+    float v;
+    memcpy(&v, &b, sizeof v);
+    return v;
+}
+double   crn_ld_f64(const double* p) {
+    uint64_t b = crn_ld_u64((const uint64_t*)(const void*)p);
+    double v;
+    memcpy(&v, &b, sizeof v);
+    return v;
+}
+void crn_st_u8(uint8_t* p, uint8_t v) { CRN_ST(p, v); }
+void crn_st_u16(uint16_t* p, uint16_t v) { CRN_ST(p, v); }
+void crn_st_u32(uint32_t* p, uint32_t v) { CRN_ST(p, v); }
+void crn_st_u64(uint64_t* p, uint64_t v) { CRN_ST(p, v); }
+void crn_st_f32(float* p, float v) {
+    uint32_t b;
+    memcpy(&b, &v, sizeof b);
+    crn_st_u32((uint32_t*)(void*)p, b);
+}
+void crn_st_f64(double* p, double v) {
+    uint64_t b;
+    memcpy(&b, &v, sizeof b);
+    crn_st_u64((uint64_t*)(void*)p, b);
+}
+
 /* ---- conversions ---- */
 
 int64_t crn_d2i64(double d) {
@@ -195,8 +238,6 @@ int64_t crn_input(void) {
 
 int64_t crn_open(uint8_t* path, int mode) {
     const char* m;
-    int flags_ok = 1;
-    (void)flags_ok;
     switch (mode) {
     case 0: m = "rb"; break;
     case 1: m = "wb"; break;
@@ -204,25 +245,31 @@ int64_t crn_open(uint8_t* path, int mode) {
     }
     FILE* f = fopen((const char*)path, m);
     if (!f) return -1;
+    /* one open stream table per context: refuse politely at the limit */
+    if (cr_ctx.nfiles >= CR_FILES_MAX) {
+        fclose(f);
+        return -1;
+    }
     /* success: route this context's streams through the file */
     if (mode == 0) cr_ctx.in = f;
     else if (mode == 1) cr_ctx.out = f;
     else { cr_ctx.in = f; cr_ctx.out = f; }
-    cr_ctx.files[cr_ctx.nfiles < CR_FILES_MAX ? cr_ctx.nfiles++ : 0] = f;
+    cr_ctx.files[cr_ctx.nfiles++] = f;
     return (int64_t)(uintptr_t)f;
 }
 
 int64_t crn_close(int64_t h) {
     FILE* f = (FILE*)(uintptr_t)h;
-    if (h <= 0) return -1;
-    if (cr_ctx.in == f) cr_ctx.in = stdin;
-    if (cr_ctx.out == f) cr_ctx.out = stdout;
+    int found = -1;
     for (int i = 0; i < cr_ctx.nfiles; i++) {
-        if (cr_ctx.files[i] == f) {
-            cr_ctx.files[i] = cr_ctx.files[--cr_ctx.nfiles];
-            break;
-        }
+        if (cr_ctx.files[i] == f) { found = i; break; }
     }
+    if (found < 0)
+        crn_die("close of a handle that is not open in this context "
+                "(handles come from 'open' and are closed once)");
+    cr_ctx.files[found] = cr_ctx.files[--cr_ctx.nfiles];
+    if (cr_ctx.in == f) cr_ctx.in = stdin;
+    if (cr_ctx.out == f) cr_ctx.out = stdout;
     return fclose(f) == 0 ? 0 : -1;
 }
 
diff --git a/src/rt/cryn_rt.h b/src/rt/cryn_rt.h
index 907cd96..f570181 100644
--- a/src/rt/cryn_rt.h
+++ b/src/rt/cryn_rt.h
@@ -67,6 +67,25 @@ uint64_t crn_block_n(void* data, uint64_t elemsz);
 void crn_check_block(void* data, uint64_t i, uint64_t elemsz);
 void crn_check_range(uint64_t i, uint64_t n);
 
+/* Relaxed atomic scalar access. Cryn defines a data race on an aligned
+   scalar cell (<= 8 bytes) as: the access yields an unspecified value of
+   the cell's type and never tears. To keep that promise (and avoid C data
+   races, which are undefined behavior), the backend performs every scalar
+   load/store through these helpers. Racily accessed transform-reference
+   cells (16 bytes) are undefined. No ordering is provided beyond wait. */
+uint8_t  crn_ld_u8(const uint8_t* p);
+uint16_t crn_ld_u16(const uint16_t* p);
+uint32_t crn_ld_u32(const uint32_t* p);
+uint64_t crn_ld_u64(const uint64_t* p);
+float    crn_ld_f32(const float* p);
+double   crn_ld_f64(const double* p);
+void crn_st_u8(uint8_t* p, uint8_t v);
+void crn_st_u16(uint16_t* p, uint16_t v);
+void crn_st_u32(uint32_t* p, uint32_t v);
+void crn_st_u64(uint64_t* p, uint64_t v);
+void crn_st_f32(float* p, float v);
+void crn_st_f64(double* p, double v);
+
 int64_t crn_d2i64(double d);
 uint64_t crn_d2u64(double d);
 int64_t crn_d2i8(double d);
diff --git a/src/types.c b/src/types.c
index 27a47db..ae1a235 100644
--- a/src/types.c
+++ b/src/types.c
@@ -83,7 +83,7 @@ uint64_t type_size(const Type* t) {
     switch (t->kind) {
     case TY_NOTHING: return 1;
     case TY_BOOL: return 1;
-    case TY_CHAR: return 4;
+    case TY_CHAR: return 1; /* byte 0-255, character intent */
     case TY_INT8: case TY_UINT8: return 1;
     case TY_INT16: case TY_UINT16: return 2;
     case TY_INT32: case TY_UINT32: case TY_FLOAT32: return 4;
diff --git a/src/types.h b/src/types.h
index 3b5a8bd..7a66700 100644
--- a/src/types.h
+++ b/src/types.h
@@ -7,7 +7,7 @@
 typedef enum {
     TY_NOTHING,
     TY_BOOL,
-    TY_CHAR,      /* 32-bit codepoint */
+    TY_CHAR,      /* byte 0-255 with character intent */
     TY_INT8, TY_INT16, TY_INT32, TY_INT64,
     TY_UINT8, TY_UINT16, TY_UINT32, TY_UINT64,
     TY_FLOAT32, TY_FLOAT64,
diff --git a/tests/26_indirect.cryn b/tests/26_indirect.cryn
new file mode 100644
index 0000000..2b1f42d
--- /dev/null
+++ b/tests/26_indirect.cryn
@@ -0,0 +1,31 @@
+[# an indirect call preserves the working value: call call runs twice #]
+make string "x" s
+
+define tick result nothing
+  make string "tick"
+  output text
+  set nothing
+  return
+end
+
+define twice transform f result nothing
+  move f
+  read
+  call
+  call
+  set nothing
+  return
+end
+
+define main
+  make int64 n
+  set 2
+  move n
+  write
+  address tick
+  push
+  call twice
+  make string "\n"
+  output text
+  return
+end
diff --git a/tests/errors/literal_float_join.cryn b/tests/errors/literal_float_join.cryn
new file mode 100644
index 0000000..6c65a8a
--- /dev/null
+++ b/tests/errors/literal_float_join.cryn
@@ -0,0 +1,22 @@
+[# an integer literal does not join a float path: representations differ #]
+define main
+  make float64 f
+  make int64 n
+  set 1
+  move n
+  write
+  set 0
+  mark top
+  move n
+  read
+  compare 0
+  jump small when less
+  set 7
+  jump join
+  mark small
+  set 1.5
+  mark join
+  move f
+  write
+  stop
+end
diff --git a/tests/errors/mixed_sign.cryn b/tests/errors/mixed_sign.cryn
new file mode 100644
index 0000000..2cb7944
--- /dev/null
+++ b/tests/errors/mixed_sign.cryn
@@ -0,0 +1,18 @@
+[# equal-width mixed signedness must be converted first #]
+define main
+  make int64 a
+  make uint64 b
+  set -1
+  move a
+  write
+  set 1
+  move b
+  write
+  move a
+  read
+  push
+  move b
+  read
+  compare
+  stop
+end
diff --git a/tests/expected/26_indirect.txt b/tests/expected/26_indirect.txt
new file mode 100644
index 0000000..e22b493
--- /dev/null
+++ b/tests/expected/26_indirect.txt
@@ -0,0 +1 @@
+ticktick