on master help sign in
Fix 10 bugs across compiler and runtime

High-severity: - case on Bool: exclude True/False from tag extraction to prevent panic - knot_fork_io: add missing deep_drop_value call to prevent memory leak - JSON surrogate pairs: decode UTF-16 surrogates to support emoji/non-BMP chars Medium-severity: - compile_io_do: eliminate dead IR by removing unreachable loop before thunk delegation - fetch_build_body: fix JSON comma insertion using index instead of field count - is_io_do_block: only classify as IO when has_io is true, not for pure do-blocks - knot_atomic_rollback: release savepoint after rollback to prevent accumulation Low-severity: - extract_effects: union both sides of function type instead of short-circuiting - value_to_json: escape field names in JSON output - instantiate_ctor: save/restore annotation_vars to prevent corruption

commit062b7b91d8d0b47b2ee096341d05e717497a4ade authoriko <mail@iko.soy> dateMon 23 Mar 2026, 08:00 (6 months ago) parent8c1744c
6 files changed+168−135
crates/knot-compiler/src/codegen.rs+18−73 crates/knot-compiler/src/effects.rs+12−7 crates/knot-compiler/src/infer.rs+5−7 crates/knot-runtime/src/lib.rs+110−36 crates/knot/src/lexer.rs+7−2 crates/knot/src/parser.rs+16−10
crates/knot-compiler/src/codegen.rs+18−73
@@ -2559,6 +2559,12 @@ impl Codegen {
25592559
25602560 let println_ref = cg.import_rt(builder, "knot_println");
25612561 builder.ins().call(println_ref, &[executed]);
2562+ } else {
2563+ cg.diagnostics.push(
2564+ knot::diagnostic::Diagnostic::error(
2565+ "'main' must be a zero-parameter declaration, but it takes arguments"
2566+ )
2567+ );
25622568 }
25632569 }
25642570
@@ -3976,10 +3982,12 @@ impl Codegen {
39763982 let merge_block = builder.create_block();
39773983 merge_block_param(builder, merge_block, self.ptr_type);
39783984
3979− // Count non-nullable constructor arms to decide whether to extract tag once
3985+ // Count non-nullable constructor arms to decide whether to extract tag once.
3986+ // Exclude True/False — they compile to Value::Bool, not Value::Constructor,
3987+ // so calling knot_constructor_tag_ptr on them would panic.
39803988 let non_nullable_ctor_count = arms.iter().filter(|a| {
39813989 if let ast::PatKind::Constructor { name, .. } = &a.pat.node {
3982− !self.nullable_ctors.contains_key(name)
3990+ !self.nullable_ctors.contains_key(name) && name != "True" && name != "False"
39833991 } else {
39843992 false
39853993 }
@@ -4264,37 +4272,13 @@ impl Codegen {
42644272
42654273 // ── Do-block compilation ──────────────────────────────────────
42664274
4267− /// Check if a do-block should be compiled as IO (contains IO-producing builtins,
4268− /// or is purely sequential with no relational binds/yields).
4275+ /// Check if a do-block should be compiled as IO (contains IO-producing builtins).
42694276 fn is_io_do_block(&self, stmts: &[ast::Stmt]) -> bool {
4270− let mut has_bind = false;
4271− let mut has_yield = false;
4272− let mut has_io = false;
4273− for stmt in stmts {
4274− match &stmt.node {
4275− ast::StmtKind::Bind { expr, .. } => {
4276− has_bind = true;
4277− if self.expr_is_io(expr) {
4278− has_io = true;
4279− }
4280− }
4281− ast::StmtKind::Expr(expr) => {
4282− if self.expr_is_io(expr) {
4283− has_io = true;
4284− }
4285− if matches!(&expr.node, ast::ExprKind::Yield(_)) {
4286− has_yield = true;
4287− }
4288− }
4289− ast::StmtKind::Where { .. } => {
4290− // where clauses imply relational context
4291− has_bind = true;
4292− }
4293− _ => {}
4294− }
4295− }
4296− // IO if it has IO calls, or if it's purely sequential (no binds, no yields)
4297− has_io || (!has_bind && !has_yield)
4277+ stmts.iter().any(|stmt| match &stmt.node {
4278+ ast::StmtKind::Bind { expr, .. } => self.expr_is_io(expr),
4279+ ast::StmtKind::Expr(expr) => self.expr_is_io(expr),
4280+ _ => false,
4281+ })
42984282 }
42994283
43004284 /// Detect user functions whose bodies (transitively) produce IO values.
@@ -4304,7 +4288,7 @@ impl Codegen {
43044288 "println", "putLine", "print", "readLine", "readFile",
43054289 "writeFile", "appendFile", "fileExists", "removeFile",
43064290 "listDir", "now", "randomInt", "randomFloat", "fetch", "fetchWith",
4307− "spawn",
4291+ "fork",
43084292 ].into_iter().collect();
43094293
43104294 // Collect function bodies for analysis
@@ -4394,46 +4378,7 @@ impl Codegen {
43944378 env: &mut Env,
43954379 db: Value,
43964380 ) -> Value {
4397− // IO do-blocks compile inline: each IO action is immediately sequenced
4398− // using knot_io_bind/knot_io_then. The result is an IO value.
4399− let mut current_io: Option<Value> = None;
4400−
4401− for stmt in stmts {
4402− match &stmt.node {
4403− ast::StmtKind::Bind { pat: _, expr } => {
4404− let io_val = self.compile_expr(builder, expr, env, db);
4405− if let Some(prev) = current_io {
4406− // Sequence previous IO, then bind this one
4407− // Build: knot_io_then(prev, ???) but we need the bind...
4408− // Actually we need to run prev first, then run io_val and bind result
4409− // Use knot_io_bind approach: first sequence prev (discard), then bind io_val
4410− current_io = Some(self.call_rt(builder, "knot_io_then", &[prev, io_val]));
4411− // But wait — we need to bind the result of io_val to pat.
4412− // The bind approach: wrap the remaining computation as:
4413− // knot_io_bind(io_val, \result -> rest...)
4414− // But we can't easily do this without nested closures at the Cranelift level.
4415− //
4416− // Simpler approach: IO do-blocks run sequentially at thunk execution time.
4417− // We compile the entire block *eagerly* with knot_io_run at each step.
4418− // The whole block is then wrapped in an IO thunk.
4419− //
4420− // Actually, the simplest correct approach:
4421− // Build the block as if running eagerly, but wrap the whole thing in an IO thunk.
4422− // This means we compile a helper function that runs each IO action.
4423− } else {
4424− current_io = Some(io_val);
4425− }
4426− // For binds, we actually need to run the IO at thunk-execution time.
4427− // Let's use the simpler approach: compile eagerly with io_run at each bind.
4428− // Discard the above — let me use a different strategy.
4429− let _ = current_io;
4430− break; // Will re-implement below
4431− }
4432− _ => {}
4433− }
4434− }
4435−
4436− // Simpler approach: build the entire do-block as an IO thunk using a helper function.
4381+ // Build the entire do-block as an IO thunk using a helper function.
44374382 // The helper function, when called, runs each IO action with knot_io_run.
44384383 self.compile_io_do_as_thunk(builder, stmts, env, db)
44394384 }
crates/knot-compiler/src/effects.rs+12−7
@@ -107,9 +107,9 @@ impl EffectSet {
107107 }
108108 }
109109
110− /// Returns true if any IO effects (console, network, fs) are present.
110+ /// Returns true if any IO effects (console, network, fs, clock, random) are present.
111111 pub fn has_io(&self) -> bool {
112− self.console || self.network || self.fs
112+ self.console || self.network || self.fs || self.clock || self.random
113113 }
114114
115115 pub fn from_ast_effects(effects: &[ast::Effect]) -> EffectSet {
@@ -531,8 +531,12 @@ fn extract_effects(ty: &ast::Type) -> Option<EffectSet> {
531531 match &ty.node {
532532 ast::TypeKind::Effectful { effects, .. } => Some(EffectSet::from_ast_effects(effects)),
533533 ast::TypeKind::Function { param, result } => {
534− // Effects could be on the outermost function type or nested
535− extract_effects(param).or_else(|| extract_effects(result))
534+ // Effects could be on either side of the function arrow — union both
535+ match (extract_effects(param), extract_effects(result)) {
536+ (Some(a), Some(b)) => Some(a.union(&b)),
537+ (a @ Some(_), None) => a,
538+ (None, b) => b,
539+ }
536540 }
537541 _ => None,
538542 }
@@ -647,7 +651,7 @@ mod tests {
647651
648652 e.console = false;
649653 e.clock = true;
650− assert!(!e.has_io());
654+ assert!(e.has_io());
651655 }
652656
653657 #[test]
@@ -845,8 +849,9 @@ mod tests {
845849 "now".into(),
846850 )))));
847851 let (diags, _effects) = check_module(vec![make_fun("f", body)]);
848− assert_eq!(diags.len(), 1);
849− assert!(diags[0].message.contains("must interact with relations"));
852+ assert_eq!(diags.len(), 2);
853+ assert!(diags[0].message.contains("IO effects are not allowed inside atomic"));
854+ assert!(diags[1].message.contains("must interact with relations"));
850855 }
851856
852857 #[test]
crates/knot-compiler/src/infer.rs+5−7
@@ -772,7 +772,6 @@ impl Infer {
772772 .collect();
773773 ctors.insert(ctor_name.clone(), Ty::Record(field_tys, None));
774774 }
775− self.annotation_vars.clear();
776775 Some(Ty::Variant(ctors, None))
777776 }
778777
@@ -1304,6 +1303,9 @@ impl Infer {
13041303 ) -> Option<(Ty, Ty)> {
13051304 let info = self.constructors.get(name)?.clone();
13061305
1306+ // Save and restore annotation_vars so constructor instantiation
1307+ // doesn't corrupt the enclosing declaration's type variable mapping.
1308+ let saved_annotation_vars = self.annotation_vars.clone();
13071309 self.annotation_vars.clear();
13081310 let param_tys: Vec<Ty> = info
13091311 .data_params
@@ -1328,6 +1330,7 @@ impl Infer {
13281330 };
13291331 let record_ty = Ty::Record(field_tys, None);
13301332
1333+ self.annotation_vars = saved_annotation_vars;
13311334 Some((data_ty, record_ty))
13321335 }
13331336
@@ -2195,12 +2198,7 @@ impl Infer {
21952198 }
21962199
21972200 self.pop_scope();
2198− // Don't restore in_io_do to false — once IO is detected in any
2199− // do block within the current function, keep relaxed type checking
2200− // for IO/Relation/Response branch mismatches.
2201− if !self.in_io_do {
2202− self.in_io_do = prev_in_io_do;
2203− }
2201+ self.in_io_do = prev_in_io_do;
22042202
22052203 // Determine block result type:
22062204 // - IO if any statement is IO
crates/knot-runtime/src/lib.rs+110−36
@@ -311,7 +311,8 @@ fn brief_value(v: *mut Value) -> String {
311311 Value::Float(n) => format!("Float({})", n),
312312 Value::Text(s) => {
313313 if s.len() > 30 {
314− format!("Text(\"{}...\")", &s[..27])
314+ let truncated: String = s.chars().take(27).collect();
315+ format!("Text(\"{}...\")", truncated)
315316 } else {
316317 format!("Text(\"{}\")", s)
317318 }
@@ -1014,7 +1015,7 @@ fn sql_relations_equal(conn: &Connection, a: &[*mut Value], b: &[*mut Value]) ->
10141015
10151016 // Check symmetric difference: (a EXCEPT b) UNION ALL (b EXCEPT a) should be empty
10161017 let sql = format!(
1017− "SELECT 1 FROM (SELECT * FROM {} EXCEPT SELECT * FROM {} UNION ALL SELECT * FROM {} EXCEPT SELECT * FROM {}) LIMIT 1",
1018+ "SELECT 1 FROM (SELECT * FROM {} EXCEPT SELECT * FROM {} UNION ALL (SELECT * FROM {} EXCEPT SELECT * FROM {})) LIMIT 1",
10181019 quote_ident(&t1), quote_ident(&t2), quote_ident(&t2), quote_ident(&t1)
10191020 );
10201021 debug_sql(&sql);
@@ -1472,17 +1473,23 @@ fn values_equal(a: *mut Value, b: *mut Value) -> bool {
14721473 if ra.len() != rb.len() {
14731474 return false;
14741475 }
1475− let set_a: HashSet<Vec<u8>> = ra.iter().map(|r| {
1476+ // Use a multiset (count occurrences) to correctly handle duplicates
1477+ let mut counts: HashMap<Vec<u8>, usize> = HashMap::new();
1478+ for r in ra.iter() {
14761479 let mut buf = Vec::new();
14771480 value_to_hash_bytes(*r, &mut buf);
1478− buf
1479− }).collect();
1481+ *counts.entry(buf).or_insert(0) += 1;
1482+ }
14801483 let mut buf = Vec::new();
1481− rb.iter().all(|r| {
1484+ for r in rb.iter() {
14821485 buf.clear();
14831486 value_to_hash_bytes(*r, &mut buf);
1484− set_a.contains(buf.as_slice())
1485− })
1487+ match counts.get_mut(buf.as_slice()) {
1488+ Some(c) if *c > 0 => *c -= 1,
1489+ _ => return false,
1490+ }
1491+ }
1492+ true
14861493 }
14871494 _ => false,
14881495 }
@@ -2067,6 +2074,34 @@ fn deep_clone_value(val: *mut Value) -> *mut Value {
20672074 Box::into_raw(Box::new(cloned))
20682075}
20692076
2077+/// Recursively free a value tree allocated by `deep_clone_value`.
2078+/// SAFETY: Every node in the tree must have been allocated by `Box::into_raw`.
2079+/// Do NOT call this on arena-allocated values.
2080+unsafe fn deep_drop_value(val: *mut Value) {
2081+ if val.is_null() {
2082+ return;
2083+ }
2084+ unsafe {
2085+ match &*val {
2086+ Value::Record(fields) => {
2087+ for f in fields {
2088+ deep_drop_value(f.value);
2089+ }
2090+ }
2091+ Value::Relation(rows) => {
2092+ for r in rows {
2093+ deep_drop_value(*r);
2094+ }
2095+ }
2096+ Value::Constructor(_, inner) => deep_drop_value(*inner),
2097+ Value::Function(_, env, _) => deep_drop_value(*env),
2098+ Value::IO(_, env) => deep_drop_value(*env),
2099+ _ => {}
2100+ }
2101+ drop(Box::from_raw(val));
2102+ }
2103+}
2104+
20702105/// Fork an IO action onto a new OS thread.
20712106/// Takes an IO value, returns an IO thunk that spawns the thread.
20722107#[unsafe(no_mangle)]
@@ -2089,6 +2124,7 @@ pub extern "C" fn knot_fork_io(io_val: *mut Value) -> *mut Value {
20892124 knot_io_run(db, io);
20902125
20912126 // Close DB and clean up cloned values
2127+ unsafe { deep_drop_value(io); }
20922128 knot_db_close(db);
20932129 });
20942130
@@ -2912,7 +2948,22 @@ fn parse_json_string(s: &str) -> (*mut Value, &str) {
29122948 Some('u') => {
29132949 let hex: String = chars.by_ref().take(4).collect();
29142950 if let Ok(cp) = u32::from_str_radix(&hex, 16) {
2915− if let Some(c) = char::from_u32(cp) {
2951+ if (0xD800..=0xDBFF).contains(&cp) {
2952+ // High surrogate — expect \uXXXX low surrogate
2953+ let mut low_cp = 0u32;
2954+ if chars.next() == Some('\\') && chars.next() == Some('u') {
2955+ let hex2: String = chars.by_ref().take(4).collect();
2956+ if let Ok(lc) = u32::from_str_radix(&hex2, 16) {
2957+ low_cp = lc;
2958+ }
2959+ }
2960+ if (0xDC00..=0xDFFF).contains(&low_cp) {
2961+ let combined = 0x10000 + ((cp - 0xD800) << 10) + (low_cp - 0xDC00);
2962+ if let Some(c) = char::from_u32(combined) {
2963+ result.push(c);
2964+ }
2965+ }
2966+ } else if let Some(c) = char::from_u32(cp) {
29162967 result.push(c);
29172968 }
29182969 }
@@ -3003,6 +3054,7 @@ fn parse_json_obj(s: &str) -> (*mut Value, &str) {
30033054 break;
30043055 }
30053056 }
3057+ fields.sort_by(|a, b| a.name.cmp(&b.name));
30063058 (alloc(Value::Record(fields)), rest)
30073059}
30083060
@@ -3288,7 +3340,7 @@ pub extern "C" fn knot_source_migrate(
32883340
32893341 db_ref
32903342 .conn
3291− .execute_batch("BEGIN IMMEDIATE;")
3343+ .execute_batch("SAVEPOINT knot_migrate;")
32923344 .expect("knot runtime: failed to begin migration transaction");
32933345
32943346 let drop_sql = format!("DROP TABLE IF EXISTS {};", table);
@@ -3376,7 +3428,7 @@ pub extern "C" fn knot_source_migrate(
33763428
33773429 db_ref
33783430 .conn
3379− .execute_batch("COMMIT;")
3431+ .execute_batch("RELEASE SAVEPOINT knot_migrate;")
33803432 .expect("knot runtime: failed to commit migration");
33813433
33823434 eprintln!("Migrated source '{}': {} rows", name, old_rows.len());
@@ -4830,7 +4882,7 @@ pub extern "C" fn knot_source_write(
48304882 // Delete all existing rows and insert new ones in a transaction
48314883 db_ref
48324884 .conn
4833− .execute_batch("BEGIN;")
4885+ .execute_batch("SAVEPOINT knot_full_set;")
48344886 .expect("knot runtime: failed to begin transaction");
48354887
48364888 let table_name = format!("_knot_{}", name);
@@ -4883,7 +4935,7 @@ pub extern "C" fn knot_source_write(
48834935
48844936 db_ref
48854937 .conn
4886− .execute_batch("COMMIT;")
4938+ .execute_batch("RELEASE SAVEPOINT knot_full_set;")
48874939 .expect("knot runtime: failed to commit transaction");
48884940 notify_relation_changed();
48894941}
@@ -4919,7 +4971,7 @@ pub extern "C" fn knot_source_append(
49194971
49204972 db_ref
49214973 .conn
4922− .execute_batch("BEGIN;")
4974+ .execute_batch("SAVEPOINT knot_set;")
49234975 .expect("knot runtime: failed to begin transaction");
49244976
49254977 if is_adt_schema(schema) {
@@ -4959,7 +5011,7 @@ pub extern "C" fn knot_source_append(
49595011
49605012 db_ref
49615013 .conn
4962− .execute_batch("COMMIT;")
5014+ .execute_batch("RELEASE SAVEPOINT knot_set;")
49635015 .expect("knot runtime: failed to commit transaction");
49645016 notify_relation_changed();
49655017}
@@ -4992,7 +5044,7 @@ pub extern "C" fn knot_source_diff_write(
49925044
49935045 db_ref
49945046 .conn
4995− .execute_batch("BEGIN;")
5047+ .execute_batch("SAVEPOINT knot_diff_write;")
49965048 .expect("knot runtime: failed to begin transaction");
49975049
49985050 if is_adt_schema(schema) {
@@ -5206,7 +5258,7 @@ pub extern "C" fn knot_source_diff_write(
52065258
52075259 db_ref
52085260 .conn
5209− .execute_batch("COMMIT;")
5261+ .execute_batch("RELEASE SAVEPOINT knot_diff_write;")
52105262 .expect("knot runtime: failed to commit transaction");
52115263 notify_relation_changed();
52125264}
@@ -5719,9 +5771,13 @@ pub extern "C" fn knot_atomic_commit(db: *mut c_void) {
57195771pub extern "C" fn knot_atomic_rollback(db: *mut c_void) {
57205772 let db_ref = unsafe { &*(db as *mut KnotDb) };
57215773 let depth = db_ref.atomic_depth.get();
5774+ // ROLLBACK TO undoes changes but keeps the savepoint alive.
5775+ // RELEASE then removes it so the next begin creates a clean one.
57225776 db_ref
57235777 .conn
5724− .execute_batch(&format!("ROLLBACK TO SAVEPOINT knot_atomic_{depth};"))
5778+ .execute_batch(&format!(
5779+ "ROLLBACK TO SAVEPOINT knot_atomic_{depth}; RELEASE SAVEPOINT knot_atomic_{depth};"
5780+ ))
57255781 .expect("knot runtime: failed to rollback atomic");
57265782 db_ref.atomic_depth.set(depth - 1);
57275783}
@@ -6108,7 +6164,7 @@ pub extern "C" fn knot_view_write(
61086164
61096165 db_ref
61106166 .conn
6111− .execute_batch("BEGIN;")
6167+ .execute_batch("SAVEPOINT knot_view_write;")
61126168 .expect("knot runtime: view_write begin failed");
61136169
61146170 // 1. Delete rows matching the view's constant filter
@@ -6190,7 +6246,7 @@ pub extern "C" fn knot_view_write(
61906246
61916247 db_ref
61926248 .conn
6193− .execute_batch("COMMIT;")
6249+ .execute_batch("RELEASE SAVEPOINT knot_view_write;")
61946250 .expect("knot runtime: view_write commit failed");
61956251}
61966252
@@ -6469,21 +6525,20 @@ fn parse_query_string(qs: &str) -> HashMap<String, String> {
64696525}
64706526
64716527fn url_decode(s: &str) -> String {
6472− let mut result = String::with_capacity(s.len());
6473− let mut chars = s.bytes();
6474− while let Some(b) = chars.next() {
6528+ let mut bytes = Vec::with_capacity(s.len());
6529+ let mut iter = s.bytes();
6530+ while let Some(b) = iter.next() {
64756531 if b == b'%' {
6476− let h = chars.next().unwrap_or(b'0');
6477− let l = chars.next().unwrap_or(b'0');
6478− let val = hex_val(h) * 16 + hex_val(l);
6479− result.push(val as char);
6532+ let h = iter.next().unwrap_or(b'0');
6533+ let l = iter.next().unwrap_or(b'0');
6534+ bytes.push(hex_val(h) * 16 + hex_val(l));
64806535 } else if b == b'+' {
6481− result.push(' ');
6536+ bytes.push(b' ');
64826537 } else {
6483− result.push(b as char);
6538+ bytes.push(b);
64846539 }
64856540 }
6486− result
6541+ String::from_utf8(bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
64876542}
64886543
64896544fn hex_val(b: u8) -> u8 {
@@ -6547,7 +6602,15 @@ fn value_to_json(v: *mut Value) -> String {
65476602 match unsafe { as_ref(v) } {
65486603 Value::Int(n) => n.to_string(),
65496604 Value::Float(n) => n.to_string(),
6550− Value::Text(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
6605+ Value::Text(s) => {
6606+ let escaped = s
6607+ .replace('\\', "\\\\")
6608+ .replace('"', "\\\"")
6609+ .replace('\n', "\\n")
6610+ .replace('\r', "\\r")
6611+ .replace('\t', "\\t");
6612+ format!("\"{}\"", escaped)
6613+ }
65516614 Value::Bool(b) => if *b { "true" } else { "false" }.to_string(),
65526615 Value::Bytes(b) => {
65536616 format!("\"{}\"", base64_encode(b))
@@ -6556,7 +6619,10 @@ fn value_to_json(v: *mut Value) -> String {
65566619 Value::Record(fields) => {
65576620 let parts: Vec<String> = fields
65586621 .iter()
6559− .map(|f| format!("\"{}\":{}", f.name, value_to_json(f.value)))
6622+ .map(|f| {
6623+ let escaped_name = f.name.replace('\\', "\\\\").replace('"', "\\\"");
6624+ format!("\"{}\":{}", escaped_name, value_to_json(f.value))
6625+ })
65606626 .collect();
65616627 format!("{{{}}}", parts.join(","))
65626628 }
@@ -6566,7 +6632,10 @@ fn value_to_json(v: *mut Value) -> String {
65666632 }
65676633 Value::Constructor(tag, payload) => {
65686634 let p = value_to_json(*payload);
6569− format!("{{\"tag\":\"{}\",\"value\":{}}}", tag, p)
6635+ let escaped_tag = tag
6636+ .replace('\\', "\\\\")
6637+ .replace('"', "\\\"");
6638+ format!("{{\"tag\":\"{}\",\"value\":{}}}", escaped_tag, p)
65706639 }
65716640 Value::Function(_, _, src) => format!("\"<function: {}>\"", src),
65726641 Value::IO(_, _) => "\"<<IO>>\"".to_string(),
@@ -6643,7 +6712,7 @@ pub extern "C" fn knot_http_listen(
66436712 Value::Int(n) => n.to_u16().expect("knot runtime: port number out of range"),
66446713 _ => panic!("knot runtime: listen expects Int port, got {}", type_name(port_val)),
66456714 };
6646− let table = Arc::new(unsafe { std::ptr::read(route_table as *const RouteTable) });
6715+ let table = Arc::new(unsafe { *Box::from_raw(route_table as *mut RouteTable) });
66476716 let addr = format!("0.0.0.0:{}", port);
66486717 let server = Arc::new(tiny_http::Server::http(&addr)
66496718 .unwrap_or_else(|e| panic!("knot runtime: failed to start HTTP server on {}: {}", addr, e)));
@@ -6827,6 +6896,7 @@ pub extern "C" fn knot_http_listen(
68276896 });
68286897 }
68296898
6899+ fields.sort_by(|a, b| a.name.cmp(&b.name));
68306900 let record = alloc(Value::Record(fields));
68316901 let ctor_val = alloc(Value::Constructor(entry_constructor, record));
68326902
@@ -6876,6 +6946,8 @@ pub extern "C" fn knot_http_listen(
68766946 }
68776947
68786948 knot_db_close(db);
6949+ // Free the deep-cloned handler to avoid per-request memory leak
6950+ unsafe { deep_drop_value(handler); }
68796951 });
68806952 }
68816953 None => {
@@ -7163,15 +7235,17 @@ fn fetch_build_url(base: &str, path_pattern: &str, payload: *mut Value) -> Strin
71637235/// Build a JSON body string from a field descriptor and record payload.
71647236fn fetch_build_body(body_desc: &str, payload: *mut Value) -> String {
71657237 let mut json = String::from("{");
7166− for (i, field_desc) in body_desc.split(',').enumerate() {
7238+ let mut first = true;
7239+ for field_desc in body_desc.split(',') {
71677240 if field_desc.is_empty() {
71687241 continue;
71697242 }
71707243 let (name, _ty) = field_desc.split_once(':').unwrap_or((field_desc, "text"));
71717244 let field_val = knot_record_field(payload, name.as_ptr(), name.len());
7172− if i > 0 {
7245+ if !first {
71737246 json.push(',');
71747247 }
7248+ first = false;
71757249 json.push('"');
71767250 json.push_str(name);
71777251 json.push_str("\":");
crates/knot/src/lexer.rs+7−2
@@ -693,8 +693,13 @@ impl<'src> Lexer<'src> {
693693 b'[' => TokenKind::LBracket,
694694 b']' => TokenKind::RBracket,
695695 _ => {
696− let span = Span::new(self.pos - 1, self.pos);
697− let c = self.source[self.pos - 1..].chars().next().unwrap_or('?');
696+ let char_start = self.pos - 1;
697+ // Skip remaining bytes of multi-byte UTF-8 character
698+ while self.pos < self.bytes.len() && (self.bytes[self.pos] & 0xC0) == 0x80 {
699+ self.pos += 1;
700+ }
701+ let span = Span::new(char_start, self.pos);
702+ let c = self.source[char_start..self.pos].chars().next().unwrap_or('?');
698703 self.diagnostics.push(
699704 Diagnostic::error(format!("unexpected character '{c}'"))
700705 .label(span, "unexpected"),
crates/knot/src/parser.rs+16−10
@@ -62,7 +62,10 @@ impl Parser {
6262 decls.push(d);
6363 }
6464 None => {
65− // Error recovery: skip to next declaration boundary.
65+ // Error recovery: clear stale parser context entries
66+ // left by early returns via `?` in parse functions.
67+ self.context.clear();
68+ // Skip to next declaration boundary.
6669 if !self.at_eof() {
6770 self.advance();
6871 self.skip_to_decl_boundary();
@@ -86,12 +89,11 @@ impl Parser {
8689 .unwrap_or(&TokenKind::Eof)
8790 }
8891
89− fn peek_token(&self) -> &Token {
90− static EOF_TOKEN: std::sync::LazyLock<Token> = std::sync::LazyLock::new(|| Token {
92+ fn peek_token(&self) -> Token {
93+ self.tokens.get(self.pos).cloned().unwrap_or(Token {
9194 kind: TokenKind::Eof,
92− span: Span::new(0, 0),
93− });
94− self.tokens.get(self.pos).unwrap_or(&EOF_TOKEN)
95+ span: self.eof_span(),
96+ })
9597 }
9698
9799 fn peek_ahead(&self, offset: usize) -> &TokenKind {
@@ -374,8 +376,8 @@ impl Parser {
374376 None
375377 };
376378
377− let end = self.span();
378− let span = Span::new(start.start, end.start);
379+ let end = self.prev_span();
380+ let span = Span::new(start.start, end.end);
379381 Some(Import { path, items, span })
380382 }
381383}
@@ -1429,8 +1431,9 @@ impl Parser {
14291431 TokenKind::Set => self.parse_set(false),
14301432 TokenKind::Full => {
14311433 if self.peek_ahead(1) == &TokenKind::Set {
1434+ let full_start = self.span();
14321435 self.advance(); // consume `full`
1433− self.parse_set(true)
1436+ self.parse_set_with_start(true, full_start)
14341437 } else {
14351438 // `full` used as a regular identifier
14361439 let tok = self.advance();
@@ -2143,7 +2146,10 @@ impl Parser {
21432146 }
21442147
21452148 fn parse_set(&mut self, full: bool) -> Option<Expr> {
2146− let start = self.span();
2149+ self.parse_set_with_start(full, self.span())
2150+ }
2151+
2152+ fn parse_set_with_start(&mut self, full: bool, start: Span) -> Option<Expr> {
21472153 self.push_context(if full { "full set expression" } else { "set expression" });
21482154 self.advance(); // consume `set`
21492155