fix: codegen O(n²) HTML memory leak + elb stderr surface + runtime dir path

This commit is contained in:
Will Anderson
2026-05-05 14:40:15 -05:00
parent 962c8cbe57
commit 7f295bffe9
5 changed files with 87 additions and 27 deletions
BIN
View File
Binary file not shown.
+28
View File
@@ -1887,6 +1887,34 @@ el_val_t fs_write_bytes(el_val_t pathv, el_val_t bytesv, el_val_t lengthv) {
return 1;
}
// stdout_to_file / stdout_restore — redirect process stdout to a file and
// restore it. Used by the compiler's JS post-processing pipeline to capture
// codegen output before piping through terser / obfuscator.
#include <fcntl.h>
static int _el_saved_stdout_fd = -1;
el_val_t stdout_to_file(el_val_t pathv) {
const char* path = EL_CSTR(pathv);
if (!path) return (el_val_t)(int64_t)-1;
fflush(stdout);
_el_saved_stdout_fd = dup(STDOUT_FILENO);
int fd = open(path, O_WRONLY | O_CREAT | O_TRUNC, 0600);
if (fd < 0) return (el_val_t)(int64_t)-1;
dup2(fd, STDOUT_FILENO);
close(fd);
return (el_val_t)(int64_t)0;
}
el_val_t stdout_restore(void) {
if (_el_saved_stdout_fd >= 0) {
fflush(stdout);
dup2(_el_saved_stdout_fd, STDOUT_FILENO);
close(_el_saved_stdout_fd);
_el_saved_stdout_fd = -1;
}
return (el_val_t)(int64_t)0;
}
// exec_command — run a shell command, return exit code (0 = success).
// Used by elb and other El tooling to invoke subprocesses.
el_val_t exec_command(el_val_t cmdv) {
+4
View File
@@ -750,6 +750,10 @@ el_val_t exec_capture(el_val_t cmd); /* run shell command, capture stdout */
el_val_t exec(el_val_t cmd); /* exec(cmd) → stdout String (30s timeout) */
el_val_t exec_bg(el_val_t cmd); /* exec_bg(cmd) → PID String (non-blocking) */
/* ── Stdout redirection (used by compiler JS pipeline) ───────────────────── */
el_val_t stdout_to_file(el_val_t path); /* redirect process stdout to a file */
el_val_t stdout_restore(void); /* restore process stdout to terminal */
el_val_t emit_log(el_val_t level, el_val_t msg, el_val_t fields_json);
el_val_t emit_metric(el_val_t name, el_val_t value, el_val_t tags_json);
el_val_t trace_span_start(el_val_t name);
+29 -24
View File
@@ -205,40 +205,42 @@ fn next_html_id() -> String {
// We build them all into parts, then the caller wraps with concat chain.
fn cg_html_parts(children: [Map<String, Any>], acc_var: String) -> String {
// Accumulate fragments into a list to avoid O(n²) string growth.
// Each append is O(1); the single str_join at the end is O(total_size).
let n: Int = native_list_len(children)
let i = 0
let out = ""
let parts: [String] = native_list_empty()
while i < n {
let child: Map<String, Any> = native_list_get(children, i)
let html_kind: String = child["html"]
if str_eq(html_kind, "Text") {
let text: String = child["text"]
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(" + c_str_lit(text) + ")); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", EL_STR(" + c_str_lit(text) + ")); ")
}
if str_eq(html_kind, "Doctype") {
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"<!doctype html>\")); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"<!doctype html>\")); ")
}
if str_eq(html_kind, "Interp") {
let val_node = child["value"]
let val_c: String = cg_expr(val_node)
let out = out + acc_var + " = el_str_concat(" + acc_var + ", html_escape(" + val_c + ")); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", html_escape(" + val_c + ")); ")
}
if str_eq(html_kind, "Raw") {
let val_node = child["value"]
let val_c: String = cg_expr(val_node)
let out = out + acc_var + " = el_str_concat(" + acc_var + ", html_raw(" + val_c + ")); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", html_raw(" + val_c + ")); ")
}
if str_eq(html_kind, "Element") {
let elem_c: String = cg_html_element_str(child, acc_var)
let out = out + elem_c
let parts = native_list_append(parts, elem_c)
}
if str_eq(html_kind, "Each") {
let each_c: String = cg_html_each(child, acc_var)
let out = out + each_c
let parts = native_list_append(parts, each_c)
}
let i = i + 1
}
out
str_join(parts, "")
}
// Generate open-tag attribute fragments inline.
@@ -247,9 +249,10 @@ fn cg_html_parts(children: [Map<String, Any>], acc_var: String) -> String {
// Dynamic: "value" is an expr node.
// Bool: no "value" field.
fn cg_html_attrs_str(attrs: [Map<String, Any>], acc_var: String) -> String {
// Accumulate fragments into a list to avoid O(n²) string growth.
let n: Int = native_list_len(attrs)
let i = 0
let out = ""
let parts: [String] = native_list_empty()
// Closing-quote snippet: EL_STR("\"") in C text.
let close_q: String = "EL_STR(" + c_str_lit("\"") + ")"
while i < n {
@@ -262,26 +265,26 @@ fn cg_html_attrs_str(attrs: [Map<String, Any>], acc_var: String) -> String {
if str_eq(kind, "static") {
// Static attribute: value is a raw string.
let sv: String = attr["value"]
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + open_attr + "); "
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(" + c_str_lit(sv) + ")); "
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + close_q + "); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", " + open_attr + "); ")
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", EL_STR(" + c_str_lit(sv) + ")); ")
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", " + close_q + "); ")
} else {
if str_eq(kind, "dynamic") {
// Dynamic attribute: value is an expr node html_escape it.
let val_node = attr["value"]
let val_c: String = cg_expr(val_node)
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + open_attr + "); "
let out = out + acc_var + " = el_str_concat(" + acc_var + ", html_escape(" + val_c + ")); "
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + close_q + "); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", " + open_attr + "); ")
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", html_escape(" + val_c + ")); ")
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", " + close_q + "); ")
} else {
// Boolean attribute (no value): emit " name"
let bool_attr: String = "EL_STR(" + c_str_lit(" " + attr_name) + ")"
let out = out + acc_var + " = el_str_concat(" + acc_var + ", " + bool_attr + "); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", " + bool_attr + "); ")
}
}
let i = i + 1
}
out
str_join(parts, "")
}
// Generate code for a single element, appending into acc_var.
@@ -290,19 +293,21 @@ fn cg_html_element_str(elem: Map<String, Any>, acc_var: String) -> String {
let attrs: [Map<String, Any>] = elem["attrs"]
let children: [Map<String, Any>] = elem["children"]
let self_closing: Bool = elem["self_closing"]
// Accumulate into a list to avoid O(n²) string growth for deeply nested trees.
let parts: [String] = native_list_empty()
// Open tag: <tagname
let out = acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"<" + tag + "\")); "
let out = out + cg_html_attrs_str(attrs, acc_var)
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"<" + tag + "\")); ")
let parts = native_list_append(parts, cg_html_attrs_str(attrs, acc_var))
if self_closing {
// Self-closing void element: />
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"/>\")); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"/>\")); ")
} else {
// Close open tag: >
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\">\")); "
let out = out + cg_html_parts(children, acc_var)
let out = out + acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"</" + tag + ">\")); "
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\">\")); ")
let parts = native_list_append(parts, cg_html_parts(children, acc_var))
let parts = native_list_append(parts, acc_var + " = el_str_concat(" + acc_var + ", EL_STR(\"</" + tag + ">\")); ")
}
out
str_join(parts, "")
}
// Generate code for {#each list as item} ... {/each}.
+26 -3
View File
@@ -225,6 +225,7 @@ fn compile_module(src_path: String, out_dir: String, elc_bin: String, dry_run: B
let bname: String = basename_noext(src_path)
let c_out: String = out_dir + "/" + bname + ".c"
let elh_out: String = out_dir + "/" + bname + ".elh"
let err_tmp: String = "/tmp/elb-err-" + bname + ".txt"
// Check if recompile needed
if !file_is_newer(src_path, c_out) {
@@ -234,18 +235,26 @@ fn compile_module(src_path: String, out_dir: String, elc_bin: String, dry_run: B
return true
}
// elc streams C to stdout (collect mode not yet implemented); use
// shell redirection so the output lands in the file, not the terminal.
let cmd: String = elc_bin + " --emit-header " + src_path + " > " + c_out + " 2>&1"
// elc streams C to stdout; redirect stderr to a temp file so we can
// surface the actual error message on failure instead of swallowing it.
let cmd: String = elc_bin + " --emit-header " + src_path + " > " + c_out + " 2>" + err_tmp
println(" compile " + src_path)
if dry_run { return true }
let ret: Int = exec_command(cmd)
if ret != 0 {
// Surface the actual compiler error from stderr
let err_msg: String = str_trim(fs_read(err_tmp))
if !str_eq(err_msg, "") {
println(err_msg)
}
// Remove partial output so a retry starts clean
exec_command("rm -f " + c_out + " " + err_tmp)
println("elb: compile failed: " + src_path)
return false
}
exec_command("rm -f " + err_tmp)
// Move the generated .elh (written next to the source by elc) into
// out_dir so that #include "module.elh" lines in the generated .c
@@ -320,6 +329,20 @@ fn main() -> Void {
runtime_path = elc_dir + "/../el-compiler/runtime/el_runtime.c"
}
}
// If --runtime points to a directory, auto-locate el_runtime.c inside it.
// This lets both forms work:
// --runtime=/opt/el/el-compiler/runtime (directory form)
// --runtime=/opt/el/el-compiler/runtime/el_runtime.c (file form)
if !str_eq(runtime_path, "") {
let is_dir: String = str_trim(exec_capture("test -d " + runtime_path + " && echo dir || echo file"))
if str_eq(is_dir, "dir") {
let candidate: String = runtime_path + "/el_runtime.c"
let has_file: String = str_trim(exec_capture("test -f " + candidate + " && echo yes || echo no"))
if str_eq(has_file, "yes") {
let runtime_path = candidate
}
}
}
if str_eq(runtime_path, "") {
println("elb: cannot locate el_runtime.c - use --runtime=PATH")
exit(1)