fix: stabilize runtime follow-up regressions

This commit is contained in:
udo 2026-06-11 23:03:07 +00:00
parent 7f757654b6
commit 20db669589
37 changed files with 906 additions and 144 deletions

View File

@ -490,3 +490,212 @@ stale. One exported source-file → artifact-path helper in `compiler.cpp`,
used by both the compile-failure formatter and the runtime failure page.
**Status:** fixed, but fix not verified yet.
---
## Part 7 — Verification of the fixes (review pass, 2026-06-11)
All fixes above were re-reviewed against commit `7f75765` and, where possible,
verified live against the dev server (network suite: 21/21 public tests pass).
> **Status update (later the same day):** 7.17.8 are fixed, built, deployed,
> and verified — full suite including internal tests passes 76/76, once-init
> renders again, dashboard/workspace ship their ONCE assets (now asserted by
> the smoke suite), and a new `uce_demo_smoke.py` plugin covers every
> `site/demo/*.uce` page. Unit ABI bumped to 3 (the @fragment prelude now
> instantiates the shared `UceFragmentCapture` from `functionlib.h`). Two notes:
>
> - Fixing 7.3 exposed a latent bug: on a failed `mysql_real_connect`, the old
> code left `connection` pointing at the freed handle, and `error()` read the
> error message out of freed memory — that's the only reason the services
> page's "skip when no MySQL" branch ever worked. `connect()` now nulls the
> handle and signals failure via `_preload_next_error_code`, and `query()` /
> `error()` guard against a null connection (clean error message instead of
> the SIGSEGV this otherwise caused).
> - The 7.8 nit about the `assets.uce` lambda taking `DTree` by value is
> withdrawn: `DTree::to_string()` is non-const, so a `const DTree&` parameter
> cannot call it. Making the DTree read accessors const-correct would be the
> real fix and is a separate, larger change (worth doing before the WASM
> DTree C ABI freezes the surface).
### Verified clean
**1.1** (double-quoted attr + `html_escape` escapes `'`, regression test),
**1.4** (multi-statement rejection is comment/whitespace-tolerant, test),
**1.9/5.5** (`compiler_generated_cpp_path` agrees with `setup_unit_paths`;
confirmed live — the error page now prints the real `/tmp/uce/work/...` path),
**1.10** (`each`/`push` take `const DTree&`, `is_list()` hoisted),
**1.11** (both the initial scan and the loop body are wrapped),
**2.1, 2.2, 2.3, 2.5/3.4, 3.1, 3.2, 3.5, 4.1, 4.2, 4.4, 4.5** (SQLITE_STATIC
is safe: the params map outlives the statement), **5.3, 5.4**.
### 7.1 REGRESSION (live): ONCE output captured into a slot nobody prints
**Files:** `src/lib/compiler-parser.cpp` (default slot `once`),
`components/theme/head.uce:23`
`ONCE` now defaults to `@fragment once`, but no theme prints
`context.call["fragments"]["once"]`. Verified live: the dashboard page serves
without `views/dashboard.css`, the widgets page without any ag-grid assets —
the ONCE blocks in `views/dashboard.uce`, `components/data/widgets.uce`, and
`components/workspace/primitives.uce` are silently swallowed. The suite passed
anyway because it only checks body markers, not asset tags.
**Fix:** print `fragments["once"]` in `head.uce` (next to `fragments["head"]`),
or convert those three ONCE blocks to `@fragment head`. Also worth adding an
asset-tag assertion to the dashboard smoke test so this can't regress silently.
### 7.2 REGRESSION (live): fragment rewriter fires on literal text and duplicates lines
**File:** `src/lib/compiler-parser.cpp` (`compiler_rewrite_fragment_attributes`)
The rewriter is a line-based pre-pass with no literal awareness, so any literal
HTML line starting with `ONCE(` / `RENDER(` / `COMPONENT(` is treated as an
entry point. Verified live: `/demo/once-init.uce` is currently a compile error
("extraneous closing brace") because the heading text `ONCE() and INIT()`
matches. Two compounding defects:
- When the entry line has no `{`, the code scans forward across arbitrary
lines to inject the prelude into whatever `{` it finds next.
- If no `{` is ever found, the inner loop emits the remaining lines but never
advances `i`, so the outer loop emits them all a second time.
**Fix (minimal):** only treat a match as an entry point when the `{` is on the
entry line or the immediately following non-`@`-attribute line, and advance
`i = j` when the forward scan exhausts. **Fix (right altitude):** literal
tracking lives in the char-wise pass — fold fragment rewriting into it
eventually; `compiler_rewrite_named_render_syntax` shares the same blind spot.
### 7.3 Dangling pointers: stack-allocated connectors register `this` but never unregister
**Files:** `src/lib/mysql-connector.cpp:40`, `src/lib/sqlite-connector.cpp`
(connect paths), exposed by `site/tests/services.uce:100`
`connect()` now registers `this` in `context->resources.*_connections`, but
neither class has a destructor, so a stack-allocated connector (`MySQL mysql;
mysql.connect(...)` — exactly what `services.uce` does) leaves a dangling
pointer behind when it goes out of scope. End-of-request cleanup then calls
`disconnect()` on dead stack memory. Currently latent only because the dev
host has no reachable MySQL server (the test skips).
**Fix:** add `~MySQL() { disconnect(); }` and `~SQLite() { disconnect(); }`
`disconnect()` already unregisters (and forgets the worker cache entry), so
destructors make stack usage safe and cleanup only ever sees live heap objects.
### 7.4 Leaks on the failed-connect path; `worker_cache` set before the cache insert
**File:** `src/lib/sqlite-connector.cpp:267` (`sqlite_connect`),
`mysql-connector.h:38` (`mysql_connect`)
Registration happens only when `connect()` succeeds, so on failure the factory
returns a wrapper that is in no registry: `request_cleanup_delete = true` never
fires and the wrapper leaks per failed connect. Worse for sqlite: if
`sqlite3_open_v2` succeeds but `apply_default_pragmas` fails, the wrapper has
`worker_cache = true` but is *not* in the cache map — cleanup skips both the
close and the delete, leaking an open connection per occurrence.
**Fix:** set `worker_cache = true` only at the point of cache insertion, and
register the wrapper with the request on the failure path too (the caller still
needs it alive to read `sqlite_error`).
### 7.5 Worker-cached sqlite connections can carry an open transaction across requests
**File:** `src/lib/sqlite-connector.cpp` (`cleanup_sqlite_connections`)
The end-of-request reset clears stats/error state but not transaction state. A
page that runs `BEGIN` and then faults (or simply forgets `COMMIT`) leaves the
cached connection mid-transaction; the next request on this worker inherits it,
holding the WAL write lock indefinitely.
**Fix:** in the cleanup reset branch, `if(!sqlite3_get_autocommit(handle))
sqlite3_exec(handle, "ROLLBACK", ...)`.
### 7.6 split_http_headers: request lines containing `:` are misclassified as headers
**File:** `src/lib/functionlib.cpp:745`
The 1.3 fix keys request-line detection on "first non-blank line contains no
colon". A legal request line like `GET /page.uce?t=12:30 HTTP/1.1` (colon in
the query string, absolute-form URIs, IPv6 hosts) now parses as a header and
`REQUEST_METHOD` stays empty — this regresses the direct-HTTP server path for
real-world URLs.
**Fix:** classify as header only when the colon appears before the first
space/tab (`colon != npos && colon < first_whitespace`); otherwise it is the
request line. Add `GET /x.uce?t=12:30 HTTP/1.1` to the core.uce checks.
### 7.7 Backtrace capture now mallocs inside the signal handler
**File:** `src/linux_fastcgi.cpp:108`
The 1.5 fix captures on the correct (faulting) stack, but
`capture_backtrace_string` calls `backtrace_symbols` (malloc) and builds
`String`s inside the SIGSEGV handler. If the fault itself is heap corruption or
happens inside malloc, the handler deadlocks on the heap lock or double-faults
— the worker dies with no graceful 500 at all, which is worse than a shallow
trace. Acceptable tradeoff for a diagnostics path, but the standard shape is
cheap:
**Fix:** in the handler, only `backtrace()` into a `static void* frames[32]`
(no allocation) and stash the depth; call `backtrace_symbols` + string-building
in `handle_complete` after the `siglongjmp`. Call `backtrace()` once at worker
startup so libgcc's lazy init doesn't allocate on first use either.
### 7.8 Minor / cleanup
- `request_populate_context_params` (`src/lib/uri.cpp:367`) has zero callers
now — delete it or make it a two-line wrapper over the `_from_route` variant
(its body is a verbatim duplicate).
- MySQL positional-`?` check runs twice on the params path (params overload +
the re-check inside `query(String)` after substitution; substituted values
are always quoted by `escape()`, so the inner check alone suffices).
- Both unquoted-`?` scanners treat `?` inside SQL comments (`-- ?`, `/* ? */`)
as positional and reject the query — worth a code comment as a known
limitation.
- `MySQL::error()` consumes/clears state in the new `statement_info` branch but
not in the errno-switch branch, and `SQLite::error()` never consumes — pick
one semantic.
- `theme/assets.uce` lambda still takes `DTree item` by value; make it
`const DTree&` to match the new `each()` signature.
- `__UceFragmentCapture` is regenerated inline in every captured entry point;
define the struct once in the runtime headers and have the prelude emit only
the instantiation line (less generated-code surface — also smaller wasm unit
modules later, where the capture logic belongs in the core module).
- `uce_site_suite.py` does `from run_network_tests import TestFailure` while
the runner executes as `__main__` — the import creates a second module
instance, so the runner's `except TestFailure` never matches the plugin's
class and missing-manifest cases report as "unexpected error" instead of a
clean failure (still red, just noisier). Compare by name or pass a fail
helper through the context.
- Default `run_network_tests.py` executes only the public set (21 cases);
sqlite/services/io/zip/tasks are internal-tagged and need the internal run to
count as a gate. Worth wiring both into whatever becomes the WASM Phase 5
parity gate, plus a demo-pages smoke plugin — both live regressions above
(7.1, 7.2) sit exactly in the coverage gaps (asset tags, `demo/`).
### 7.9 NEW: lists with ten or more entries iterate in lexicographic, not numeric, order
**File:** `src/lib/dtree.cpp` (`DTree::each`), found while documenting the accessors (2026-06-11)
`DTree` stores list entries as string keys in a `std::map`, so iteration order
is lexicographic: `"0", "1", "10", "11", "2", ...`. Every consumer of `each()`
inherits this — `dtree_map`/`dtree_filter`/`dtree_values` re-push in that
order and therefore *scramble* any `push()`-built list with ≥ 10 entries, and
a `sqlite_query()` result with ≥ 10 rows renders rows 10+ before row 2 when
iterated. `is_list()` still reports true because the key *set* is sequential.
**Fix options:** iterate numerically in `each()` when `is_list()` is true
(cheapest, fixes all consumers at once); or use a numeric-aware comparator in
the map type. Documented honestly on the `each` doc page in the meantime.
This also needs deciding before the WASM DTree C ABI freezes iteration-order
semantics.
**Status:** fixed — and the regression test exposed the deeper layer:
`is_list()` itself validated keys against *map iteration order*, so it
returned false for any list with ≥ 10 entries. That silently flipped `push()`
out of list mode on the 12th element and made the json/yaml encoders
serialize big lists as objects instead of arrays. `is_list()` now does an
order-independent check (n unique canonical index keys with max n-1), index
keys require canonical form (`"1"`, not `"01"`), and `each()` iterates lists
in numeric index order, matching the encoders. Regression test with a
12-entry list in core.uce covers `each`, `dtree_values`, and `dtree_map`.

View File

@ -10,11 +10,19 @@ dtree_map
dtree_omit
dtree_pick
dtree_values
each
get_by_path
has
is_array
is_list
set_status
String
StringList
StringMap
to_bool
to_f64
to_json
to_s64
to_string
to_stringmap
to_u64

View File

@ -42,11 +42,18 @@ You will encounter `DTree` throughout the runtime, especially in:
- `.key("key")` returns a child pointer when it already exists.
- `.get_or_create("key")` returns a child pointer and creates it when missing.
- `.get_by_path("a/b/c")` traverses nested children without creating missing keys.
- `.to_string()` reads scalar content as text.
- `.to_s64()`, `.to_u64()`, and `.to_f64()` perform best-effort numeric conversion.
- `.to_bool()` performs best-effort boolean conversion.
- `.to_string(default)` reads scalar content as text.
- `.to_s64(default)`, `.to_u64(default)`, and `.to_f64(default)` perform best-effort numeric conversion.
- `.to_bool(default)` performs best-effort boolean conversion.
- `.to_stringmap()` converts a map-shaped tree into `StringMap`.
All read accessors are `const` and never modify the tree; they work directly on `const DTree&` values such as `each()` callback parameters. Every `to_*` conversion takes an optional default that is returned when the value is missing or cannot be converted — see the individual pages (`to_string`, `to_s64`, `to_u64`, `to_f64`, `to_bool`) for the exact rules:
```cpp
String title = context.props["title"].to_string("Untitled");
s64 page_size = context.cfg.get_by_path("app/page_size").to_s64(25);
```
`operator[]` creates missing entries, just like `std::map`. `.has()` and `.key()` are the non-mutating lookup helpers, and `.get_by_path()` is the non-creating traversal helper.
`json_decode()` currently stores JSON numbers as string-valued `DTree` nodes, so typed numeric conversion is the normal way to consume those values.
@ -60,7 +67,7 @@ References are dereferenced automatically in most normal reads.
- `bool` values convert numerically to `1` and `0`.
- Pointer values convert numerically when read as numbers.
- Single-value maps can act as scalar wrappers for numeric and boolean conversion.
- Invalid numeric input falls back to `0`.
- Missing values and invalid numeric input fall back to the accessor's `default_value` argument (`0`, `""`, or `false` when not given).
- `.to_stringmap()` converts map children key-by-key using each child's `to_string()`.
## Writing Values
@ -93,11 +100,11 @@ Useful inspection helpers include:
## each()
`each(std::function<void (DTree t, String key)> f)` iterates over the current tree value.
`each(std::function<void (const DTree& t, String key)> f)` iterates over the current tree value (see the `each` page for details).
For map-shaped `DTree` values, the callback runs once per child entry and receives:
- `t` as the child value
- `t` as the child value, by const reference (no copy)
- `key` as the child key
For non-map values, `each()` still invokes the callback once:
@ -106,7 +113,7 @@ For non-map values, `each()` still invokes the callback once:
- `key` is an empty string
```cpp
context.connection["items"].each([&](DTree item, String key) {
context.connection["items"].each([&](const DTree& item, String key) {
print(key, ": ", item.to_string(), "\n");
});
```

36
site/doc/pages/each.txt Normal file
View File

@ -0,0 +1,36 @@
:sig
void DTree::each(function<void (const DTree& item, String key)> f) const
:params
f : callback invoked per child with the child node and its key
return value : none
:see
>types
0_DTree
dtree_map
dtree_filter
dtree_keys
is_list
:content
Iterates a `DTree`. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
For map-shaped nodes the callback runs once per child, in key order, receiving the child and its key. For scalar nodes it runs exactly once with the node itself and an empty key.
The callback receives the child as `const DTree&` — no copy is made, and all read accessors (`to_string()`, `to_s64()`, `get_by_path()`, ...) work on it directly:
```cpp
rows.each([&](const DTree& row, String key) {
out("<li>", html_escape(row.get_by_path("title").to_string("Untitled")), "</li>");
});
```
Declaring the callback parameter as plain `DTree` also works but deep-copies every child; prefer `const DTree&`. To transform or filter into a new tree, use `dtree_map()` / `dtree_filter()` instead of mutating inside the callback.
List-shaped trees (see `is_list`) iterate in numeric index order, matching `json_encode()` and the other serializers. Keyed maps iterate in string key order.
## Related Concepts
- PHP: `foreach ($tree as $key => $item)`
- JavaScript: `Object.entries(obj).forEach(([key, item]) => ...)`

View File

@ -1,5 +1,5 @@
:sig
DTree DTree::get_by_path(String path, String delim = "/")
DTree DTree::get_by_path(String path, String delim = "/") const
:params
path : slash-delimited path to traverse
@ -11,16 +11,21 @@ return value : the resolved child node, or an empty `DTree` when the path cannot
0_Request
>types
json_decode
has
to_string
:content
Traverses a nested `DTree` without creating missing keys.
Traverses a nested `DTree` without creating missing keys. This is a read accessor: it is `const` and never modifies the tree, unlike `operator[]`, which creates missing entries.
Empty path segments are ignored, so leading and trailing `/` characters are harmless. If any intermediate node is not a map or a segment is missing, `get_by_path()` returns an empty `DTree`.
A missing path therefore reads like an empty value — combine it with the `to_*` default arguments to express a fallback in one call:
## Example
```cpp
context.cfg.get_by_path("theme/options/portal-dark/label").to_string()
String label = context.cfg.get_by_path("theme/options/portal-dark/label").to_string("Portal Dark");
s64 page_size = context.cfg.get_by_path("app/page_size").to_s64(25);
```
## Related Concepts

36
site/doc/pages/has.txt Normal file
View File

@ -0,0 +1,36 @@
:sig
bool DTree::has(String s) const
:params
s : child key to test
return value : true when the node is map-shaped and contains the key
:see
>types
0_DTree
get_by_path
each
is_array
:content
Tests whether a map-shaped `DTree` contains a child key, without creating it. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
Returns `false` for scalar nodes and for missing keys.
This matters because `operator[]` creates missing entries, exactly like `std::map`. Use `has()` (or `get_by_path()`) when you only want to look:
```cpp
if(context.props.has("avatar"))
print(component("components/basic/avatar", context.props["avatar"], context));
```
`has()` only checks one level. For nested checks, combine with `get_by_path()`:
```cpp
bool configured = context.cfg.get_by_path("mail/smtp").is_array();
```
## Related Concepts
- PHP: `array_key_exists()` / `isset()`
- JavaScript: `Object.hasOwn(obj, key)` / `key in obj`

View File

@ -0,0 +1,34 @@
:sig
bool DTree::is_array() const
:params
return value : true when the node is map-shaped
:see
>types
0_DTree
is_list
has
each
:content
Tests whether a `DTree` node is map-shaped, i.e. holds nested child values rather than a scalar. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
`is_array()` is true for both list-like and keyed containers; use `is_list()` to distinguish the two:
```cpp
DTree rows = sqlite_query(db, "select * from notes");
if(rows.is_array())
{
rows.each([&](const DTree& row, String key) {
// ...
});
}
```
Scalar values (strings, numbers, booleans, pointers) return `false`, as do unresolvable references.
## Related Concepts
- PHP: `is_array()`
- JavaScript: `typeof value === "object"`

View File

@ -0,0 +1,35 @@
:sig
bool DTree::is_list() const
:params
return value : true when the node is a sequential, numerically indexed container
:see
>types
0_DTree
is_array
push
each
dtree_values
:content
Tests whether a map-shaped `DTree` represents a list: its keys are the numeric strings `"0"`, `"1"`, `"2"`, ... in unbroken sequence. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
Containers built with `push()` are lists. An empty container counts as a list when it was created with `set_array()` or `push()`; a keyed map (or a map with gaps in its numeric keys) is `is_array()` but not `is_list()`.
```cpp
DTree items;
items.push(first_item);
items.push(second_item);
// items.is_list() == true
items["custom"] = "x";
// items.is_list() == false, items.is_array() == true
```
`dtree_map()` and `dtree_filter()` use this distinction to decide whether results re-index from zero or keep their original keys.
## Related Concepts
- PHP: `array_is_list()`
- JavaScript: `Array.isArray()`

View File

@ -1,5 +1,9 @@
:sig
bool DTree::to_bool()
bool DTree::to_bool(bool default_value = false) const
:params
default_value : returned when the value is missing or empty
return value : the value as a boolean, or `default_value`
:see
>types
@ -7,12 +11,27 @@ bool DTree::to_bool()
json_decode
to_f64
to_u64
to_string
:content
Reads a `DTree` value as a boolean.
Reads a `DTree` value as a boolean. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
String values such as `true`, `yes`, `on`, and `1` read as true. Values such as `false`, `no`, `off`, `0`, and `null` read as false.
String values such as `true`, `yes`, `on`, and `1` read as true. Values such as `false`, `no`, `off`, `0`, and `null` read as false. Numeric values read as true when non-zero.
Numeric values read as true when non-zero. Empty strings read as false.
`default_value` is returned when the node is unset, holds an empty string, or is an unresolvable reference. Note the truthiness rule: a non-empty string that parses as neither a boolean word nor a number still reads as `true` — only missing/empty values fall back to the default.
A map-shaped node with exactly one entry unwraps to that entry's value; other maps read as true when non-empty.
## Example
```cpp
bool log_requests = context.cfg.get_by_path("app/log_requests").to_bool(true);
bool wants_compact = context.props["compact"].to_bool();
```
Use this when consuming request data, JSON-decoded values, config trees, or component props where the original input may be string-shaped.
## Related Concepts
- PHP: `filter_var($v, FILTER_VALIDATE_BOOLEAN)`
- JavaScript: `Boolean(value)` plus string handling

View File

@ -1,5 +1,9 @@
:sig
f64 DTree::to_f64()
f64 DTree::to_f64(f64 default_value = 0) const
:params
default_value : returned when the value is missing or cannot be parsed as a number
return value : the value as a floating-point number, or `default_value`
:see
>types
@ -7,11 +11,32 @@ f64 DTree::to_f64()
json_decode
float_val
to_bool
to_s64
to_u64
:content
Reads a `DTree` value as a floating-point number.
Reads a `DTree` value as a floating-point number. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
String values are parsed using the same permissive conversion rules used by the runtime's scalar helpers. Boolean values become `1.0` or `0.0`.
String values are trimmed and parsed permissively: numeric forms and the boolean words understood by `to_bool()` both convert. Boolean values become `1.0` or `0.0`.
`default_value` is returned when:
- the node is unset or holds an empty string
- the string does not parse as a finite number
- the node is map-shaped with more than one entry, or an unresolvable reference
A map-shaped node with exactly one entry unwraps to that entry's value before converting.
## Example
```cpp
f64 ratio = context.props["ratio"].to_f64(1.0);
f64 threshold = context.cfg.get_by_path("alerts/threshold").to_f64(0.75);
```
Use this for numeric config values, JSON-decoded fields, component props, and request data that should be treated as a number.
## Related Concepts
- PHP: `floatval()` with a fallback
- JavaScript: `parseFloat(x) || fallback`

View File

@ -0,0 +1,34 @@
:sig
String DTree::to_json(char quote_char = '"') const
:params
quote_char : quote character used around string output
return value : the scalar as a single JSON token
:see
>types
0_DTree
json_encode
json_decode
to_string
:content
Renders a single scalar `DTree` value as a JSON token. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
Strings are escaped and quoted, numbers render as numeric literals, and booleans render as `true` / `false`.
This is **not** a serializer for nested data: map-shaped nodes render as the placeholder string `"(array)"`. Use `json_encode()` to serialize a whole tree.
## Example
```cpp
String token = context.props["label"].to_json(); // "\"Don't panic\""
String body = json_encode(context.props); // full tree serialization
```
The `quote_char` parameter exists for embedding output inside single-quoted contexts; for HTML attributes prefer double quotes plus `html_escape()`.
## Related Concepts
- JavaScript: `JSON.stringify(value)` for a single scalar
- PHP: `json_encode($scalar)`

41
site/doc/pages/to_s64.txt Normal file
View File

@ -0,0 +1,41 @@
:sig
s64 DTree::to_s64(s64 default_value = 0) const
:params
default_value : returned when the value is missing or cannot be parsed as a number
return value : the value as a signed 64-bit integer, or `default_value`
:see
>types
0_DTree
to_u64
to_f64
to_bool
to_string
:content
Reads a `DTree` value as a signed integer. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
String values are trimmed and parsed permissively: plain integers, floating-point forms (truncated toward zero), and the boolean words understood by `to_bool()` (`yes` reads as `1`) all convert. Results outside the `s64` range clamp to the range boundaries. Boolean values become `1` or `0`.
`default_value` is returned when:
- the node is unset or holds an empty string
- the string does not parse as a number (`"not-a-number"`)
- the node is map-shaped with more than one entry, or an unresolvable reference
A map-shaped node with exactly one entry unwraps to that entry's value before converting — this matches how single-value rows from query results read.
## Example
```cpp
s64 page = context.get["page"].to_s64(1);
s64 limit = context.cfg.get_by_path("app/page_size").to_s64(25);
```
`json_decode()` stores JSON numbers as string-valued nodes, so this is the normal way to consume decoded numeric fields.
## Related Concepts
- PHP: `intval()` with a fallback
- JavaScript: `parseInt(x) || fallback`

View File

@ -0,0 +1,40 @@
:sig
String DTree::to_string(String default_value = "") const
:params
default_value : returned when the node holds no usable text
return value : the scalar content as text, or `default_value`
:see
>types
0_DTree
to_s64
to_f64
to_bool
to_json
get_by_path
:content
Reads a `DTree` value as text. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
`default_value` is returned when the value is missing or not text-convertible:
- the node is unset or holds an empty string (a missing key read via `get_by_path()` looks exactly like this)
- the node is map-shaped (use `json_encode()` or `to_stringmap()` for those)
- the node is an unresolvable reference
Other scalar kinds convert instead of falling back: `f64` values format with six decimal places (`std::to_string`), `bool` values become `(true)` / `(false)`, and pointers format as their numeric address.
## Example
```cpp
String title = context.props["title"].to_string("Untitled");
String theme = context.cfg.get_by_path("theme/key").to_string("light");
```
The default argument replaces the older `first(x.to_string(), "fallback")` idiom.
## Related Concepts
- PHP: `$value ?? 'fallback'` / `strval()`
- JavaScript: `String(value || "fallback")`

View File

@ -0,0 +1,36 @@
:sig
StringMap DTree::to_stringmap() const
:params
return value : a flat `StringMap` projection of the node
:see
>types
0_DTree
StringMap
to_string
dtree_keys
dtree_values
:content
Converts a `DTree` into a flat `StringMap`. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
- Map-shaped nodes produce one entry per child, with each child read via `to_string()`. Nested maps flatten to empty strings — this is a one-level projection, not a serializer.
- A non-empty scalar produces a single `"value"` entry holding the scalar.
- Empty values and unresolvable references produce an empty map.
Use this when handing request- or config-shaped data to APIs that take `StringMap`, such as `sqlite_query()` / `mysql_query()` parameter maps or `encode_query()`.
## Example
```cpp
StringMap params = context.props["filters"].to_stringmap();
DTree rows = sqlite_query(db, "select * from notes where author = :author", params);
```
For a faithful representation of nested data, use `json_encode()` instead.
## Related Concepts
- PHP: casting a one-level array with `array_map('strval', $a)`
- JavaScript: `Object.fromEntries(Object.entries(o).map(([k, v]) => [k, String(v)]))`

View File

@ -1,5 +1,9 @@
:sig
u64 DTree::to_u64()
u64 DTree::to_u64(u64 default_value = 0) const
:params
default_value : returned when the value is missing or cannot be parsed as a number
return value : the value as an unsigned 64-bit integer, or `default_value`
:see
>types
@ -7,11 +11,32 @@ u64 DTree::to_u64()
json_decode
int_val
to_bool
to_s64
to_f64
:content
Reads a `DTree` value as an unsigned integer.
Reads a `DTree` value as an unsigned integer. This is a read accessor: it is `const`, never creates or modifies nodes, and dereferences internal references automatically.
String values are parsed numerically. Boolean values become `1` or `0`. Negative values clamp to `0`.
String values are trimmed and parsed permissively, like `to_s64()`. Boolean values become `1` or `0`. Negative values clamp to `0`, and results above the `u64` range clamp to the maximum.
`default_value` is returned when:
- the node is unset or holds an empty string
- the string does not parse as a number
- the node is map-shaped with more than one entry, or an unresolvable reference
A map-shaped node with exactly one entry unwraps to that entry's value before converting. Note that a *present* negative value clamps to `0` rather than returning the default — the default signals "missing or unparseable", not "out of range".
## Example
```cpp
u64 limit = context.get["limit"].to_u64(50);
u64 owner_id = row.get_by_path("owner/id").to_u64();
```
Use this for counts, identifiers, limits, timestamps, and other non-negative numeric values stored inside a `DTree`.
## Related Concepts
- PHP: `intval()` with a fallback
- JavaScript: `parseInt(x) || fallback` (within unsigned range)

View File

@ -26,7 +26,7 @@ void app_asset_tags(Request& context, String kind, DTree assets)
app_asset_tag_once(context, kind, scalar);
return;
}
assets.each([&](DTree item, String key) {
assets.each([&](const DTree& item, String key) {
app_asset_tag_once(context, kind, item.to_string());
});
}

View File

@ -21,5 +21,6 @@ COMPONENT(Request& context)
<script src="<?= app_asset_url("js/morphdom.js", context) ?>"></script>
<script src="<?= app_asset_url("js/site.js", context) ?>"></script>
<?: context.call["fragments"]["head"].to_string() ?>
<?: context.call["fragments"]["once"].to_string() ?>
</>
}

View File

@ -28,6 +28,8 @@ RENDER(Request& context)
StringMap http_headers = split_http_headers("GET /demo.uce?x=1 HTTP/1.1\r\nHost: example.test\r\nX-Empty:\r\n");
StringMap leading_crlf_headers = split_http_headers("\r\nGET /lead.uce HTTP/1.1\r\nHost: lead.example\r\n");
StringMap header_only = split_http_headers("Host: example.test\nX-Token: abc\n");
StringMap colon_uri_headers = split_http_headers("GET /clock.uce?t=12:30 HTTP/1.1\r\nHost: colon.example\r\n");
check("split_http_headers() request line with colon in URI", colon_uri_headers["REQUEST_METHOD"] == "GET" && colon_uri_headers["DOCUMENT_URI"] == "/clock.uce" && colon_uri_headers["QUERY_STRING"] == "t=12:30" && colon_uri_headers["HTTP_HOST"] == "colon.example", var_dump(colon_uri_headers));
check("trim() / split_kv() / split_http_headers()", trim(" padded value ") == "padded value" && kv["alpha"] == "one" && kv["empty"] == "" && http_headers["REQUEST_METHOD"] == "GET" && http_headers["DOCUMENT_URI"] == "/demo.uce" && http_headers["QUERY_STRING"] == "x=1" && http_headers["HTTP_X_EMPTY"] == "" && leading_crlf_headers["REQUEST_METHOD"] == "GET" && leading_crlf_headers["DOCUMENT_URI"] == "/lead.uce" && leading_crlf_headers["HTTP_HOST"] == "lead.example" && header_only["REQUEST_METHOD"] == "" && header_only["HTTP_HOST"] == "example.test" && header_only["HTTP_X_TOKEN"] == "abc", trim(" padded value ") + " / " + var_dump(kv) + " / " + var_dump(http_headers) + " / " + var_dump(leading_crlf_headers) + " / " + var_dump(header_only));
check("replace()", replace("alpha-beta-beta", "beta", "done") == "alpha-done-done", replace("alpha-beta-beta", "beta", "done"));
check("html_escape() attribute-safe quotes", html_escape("<&>\"Don't") == "&lt;&amp;&gt;&quot;Don&#39;t", html_escape("<&>\"Don't"));
@ -84,6 +86,29 @@ RENDER(Request& context)
DTree nav_dash_public = dtree_omit(nav_dash, {"section"});
check("DTree collection helpers", empty_pop.to_string() == "" && app_nav.is_list() && nav_titles["0"].to_string() == "Dashboard" && grouped_nav["app"]["1"]["title"].to_string() == "Themes" && join(dtree_keys(nav_dash_summary), ",") == "title" && dtree_values(nav_dash_public)["0"].to_string() == "Dashboard", json_encode(grouped_nav));
DTree conv;
conv["name"] = "ada";
conv["count"] = "12";
conv["junk"] = "not-a-number";
conv["flag"] = "off";
check("DTree to_* defaults", conv.get_by_path("missing/key").to_string("fallback") == "fallback" && conv["name"].to_string("fallback") == "ada" && conv["junk"].to_s64(-7) == -7 && conv["count"].to_s64(-7) == 12 && conv["junk"].to_f64(2.5) == 2.5 && conv["junk"].to_u64(9) == 9 && conv.get_by_path("nope").to_bool(true) && !conv["flag"].to_bool(true), json_encode(conv));
const DTree& conv_read = conv;
String const_each_keys = "";
conv_read.each([&](const DTree& item, String key) { const_each_keys += key + ":" + item.to_string("-") + " "; });
check("DTree const read accessors", conv_read.has("name") && conv_read.get_by_path("name").to_string() == "ada" && conv_read.is_array() && !conv_read.is_list() && conv_read.to_stringmap()["count"] == "12" && conv_read.get_type_name() == "array" && contains(const_each_keys, "count:12"), const_each_keys);
DTree ordered;
for(u32 i = 0; i < 12; i++)
{
DTree ordered_entry;
ordered_entry = "v" + std::to_string(i);
ordered.push(ordered_entry);
}
String ordered_keys = "";
ordered.each([&](const DTree& item, String key) { ordered_keys += key + ","; });
check("DTree list iteration is numeric", ordered_keys == "0,1,2,3,4,5,6,7,8,9,10,11," && dtree_values(ordered)["10"].to_string() == "v10" && dtree_map(ordered, [](const DTree& item, String key) { return(item); })["11"].to_string() == "v11", ordered_keys);
String binary_payload = "core";
binary_payload.push_back((char)0x00);
binary_payload.push_back((char)0xff);

View File

@ -345,7 +345,7 @@ bool compiler_line_starts_fragmentable_entrypoint(String trimmed, String& kind)
String compiler_fragment_capture_prelude(String slot)
{
return("\nstruct __UceFragmentCapture { Request& context; String slot; __UceFragmentCapture(Request& c, String s) : context(c), slot(s) { ob_start(); } ~__UceFragmentCapture() { String html = ob_get_close(); if(html != \"\") context.call[\"fragments\"][slot] = context.call[\"fragments\"][slot].to_string() + html; } }; __UceFragmentCapture __uce_fragment_capture(context, " + compiler_cpp_string_literal(slot) + ");\n");
return("\nUceFragmentCapture __uce_fragment_capture(context, " + compiler_cpp_string_literal(slot) + ");\n");
}
String compiler_rewrite_fragment_attributes(String content)
@ -365,7 +365,6 @@ String compiler_rewrite_fragment_attributes(String content)
}
String slot = (kind == "ONCE" ? "once" : "");
StringList pending;
bool has_fragment_attr = false;
u32 j = i + 1;
while(j < lines.size())
@ -402,28 +401,31 @@ String compiler_rewrite_fragment_attributes(String content)
continue;
}
result += line;
if(j < lines.size())
result += "\n";
while(j < lines.size())
// The body must open on the next non-blank line after the attribute
// lines; anything else (e.g. literal text that merely starts with
// "ONCE(") is not an entry point and passes through untouched.
u32 body_index = j;
while(body_index < lines.size() && trim(lines[body_index]) == "")
body_index++;
bool body_opens = body_index < lines.size() && trim(lines[body_index]).rfind("{", 0) == 0;
if(!body_opens)
{
String body_line = lines[j];
auto brace_pos = body_line.find("{");
if(brace_pos == String::npos)
{
result += body_line;
if(j + 1 < lines.size())
result += "\n";
j++;
continue;
}
result += body_line.substr(0, brace_pos + 1) + compiler_fragment_capture_prelude(slot) + body_line.substr(brace_pos + 1);
if(j + 1 < lines.size())
result += line;
if(i + 1 < lines.size())
result += "\n";
i = j;
break;
continue;
}
result += line;
result += "\n";
for(u32 k = j; k < body_index; k++)
result += lines[k] + "\n";
String body_line = lines[body_index];
auto brace_pos = body_line.find("{");
result += body_line.substr(0, brace_pos + 1) + compiler_fragment_capture_prelude(slot) + body_line.substr(brace_pos + 1);
if(body_index + 1 < lines.size())
result += "\n";
i = body_index;
}
return(result);
}

View File

@ -18,7 +18,7 @@ const char* UCE_CLI_SYMBOL = "__uce_cli";
const char* UCE_SERVE_HTTP_SYMBOL = "__uce_serve_http";
const char* UCE_ONCE_SYMBOL = "__uce_once";
const char* UCE_INIT_SYMBOL = "__uce_init";
const u64 UCE_UNIT_ABI_VERSION = 2;
const u64 UCE_UNIT_ABI_VERSION = 4;
struct SharedUnitFilesystemState
{

View File

@ -20,7 +20,7 @@ TreePtr dtree_resolve_reference(TreePtr tree)
return(tree);
}
bool dtree_key_is_index(String key, s64 expected_index = -1)
bool dtree_key_is_index(String key)
{
if(key == "")
return(false);
@ -29,8 +29,10 @@ bool dtree_key_is_index(String key, s64 expected_index = -1)
if(!isdigit(c))
return(false);
}
if(expected_index >= 0)
return(key == std::to_string(expected_index));
// Only canonical index strings count ("1", not "01"), so each numeric
// value has exactly one representation.
if(key.length() > 1 && key[0] == '0')
return(false);
return(true);
}
@ -140,12 +142,25 @@ u64 dtree_clamp_to_u64_range(long double value)
}
void DTree::each(std::function <void (const DTree& t, String key)> f)
void DTree::each(std::function <void (const DTree& t, String key)> f) const
{
const DTree& target = deref();
switch(target.type)
{
case('M'):
// Lists iterate in numeric index order (string keys sort "10"
// before "2"), matching the json/xml/yaml encoders.
if(target.is_list())
{
for(u64 i = 0; i < target._map.size(); i++)
{
auto it = target._map.find(std::to_string(i));
if(it == target._map.end())
break;
f(it->second, it->first);
}
break;
}
for (auto it = target._map.begin(); it != target._map.end(); ++it)
{
f(it->second, it->first);
@ -157,7 +172,7 @@ void DTree::each(std::function <void (const DTree& t, String key)> f)
}
}
bool DTree::is_array()
bool DTree::is_array() const
{
return(deref().type == 'M');
}
@ -169,44 +184,45 @@ bool DTree::is_list() const
return(false);
if(target._map.size() == 0)
return(target._list_mode);
s64 expected_index = 0;
// The map iterates in string order ("10" before "2"), so the check must
// be order-independent: n unique canonical index keys with maximum n-1
// are exactly 0..n-1.
s64 max_index = -1;
for(const auto& entry : target._map)
{
if(!dtree_key_is_index(entry.first, expected_index))
if(!dtree_key_is_index(entry.first))
return(false);
expected_index += 1;
s64 index = strtoll(entry.first.c_str(), 0, 10);
if(index > max_index)
max_index = index;
}
return(true);
return(max_index == (s64)target._map.size() - 1);
}
String DTree::to_string()
String DTree::to_string(String default_value) const
{
const DTree& target = deref();
switch(target.type)
{
case('S'):
if(target._String == "")
return(default_value);
return(target._String);
break;
case('F'):
return(std::to_string(target._float));
break;
case('B'):
return(target._bool ? "(true)" : "(false)");
break;
case('M'):
return("");
break;
return(default_value);
case('P'):
return(std::to_string((u64)target._ptr));
break;
case('R'):
return("");
break;
return(default_value);
}
return("");
return(default_value);
}
s64 DTree::to_s64()
s64 DTree::to_s64(s64 default_value) const
{
const DTree& target = deref();
switch(target.type)
@ -215,7 +231,7 @@ s64 DTree::to_s64()
{
f64 value = 0;
if(!dtree_string_to_f64_value(target._String, value))
return(0);
return(default_value);
return(dtree_clamp_to_s64_range((long double)value));
}
case('F'):
@ -226,18 +242,18 @@ s64 DTree::to_s64()
{
const DTree* item = dtree_scalar_map_value(target);
if(item)
return(const_cast<DTree*>(item)->to_s64());
return(0);
return(item->to_s64(default_value));
return(default_value);
}
case('P'):
return(dtree_clamp_to_s64_range((long double)(u64)target._ptr));
case('R'):
return(0);
return(default_value);
}
return(0);
return(default_value);
}
u64 DTree::to_u64()
u64 DTree::to_u64(u64 default_value) const
{
const DTree& target = deref();
switch(target.type)
@ -246,7 +262,7 @@ u64 DTree::to_u64()
{
f64 value = 0;
if(!dtree_string_to_f64_value(target._String, value))
return(0);
return(default_value);
return(dtree_clamp_to_u64_range((long double)value));
}
case('F'):
@ -257,18 +273,18 @@ u64 DTree::to_u64()
{
const DTree* item = dtree_scalar_map_value(target);
if(item)
return(const_cast<DTree*>(item)->to_u64());
return(0);
return(item->to_u64(default_value));
return(default_value);
}
case('P'):
return((u64)target._ptr);
case('R'):
return(0);
return(default_value);
}
return(0);
return(default_value);
}
f64 DTree::to_f64()
f64 DTree::to_f64(f64 default_value) const
{
const DTree& target = deref();
switch(target.type)
@ -277,7 +293,7 @@ f64 DTree::to_f64()
{
f64 value = 0;
if(!dtree_string_to_f64_value(target._String, value))
return(0);
return(default_value);
return(value);
}
case('F'):
@ -288,18 +304,18 @@ f64 DTree::to_f64()
{
const DTree* item = dtree_scalar_map_value(target);
if(item)
return(const_cast<DTree*>(item)->to_f64());
return(0);
return(item->to_f64(default_value));
return(default_value);
}
case('P'):
return(dtree_clamp_to_f64_range((long double)(u64)target._ptr));
case('R'):
return(0);
return(default_value);
}
return(0);
return(default_value);
}
bool DTree::to_bool()
bool DTree::to_bool(bool default_value) const
{
const DTree& target = deref();
switch(target.type)
@ -312,7 +328,11 @@ bool DTree::to_bool()
f64 numeric_value = 0;
if(dtree_string_to_f64_value(target._String, numeric_value))
return(numeric_value != 0);
return(dtree_trim(target._String) != "");
// Non-empty unparseable strings stay truthy; only a missing/empty
// value falls back to the default.
if(dtree_trim(target._String) != "")
return(true);
return(default_value);
}
case('F'):
return(target._float != 0);
@ -322,18 +342,18 @@ bool DTree::to_bool()
{
const DTree* item = dtree_scalar_map_value(target);
if(item)
return(const_cast<DTree*>(item)->to_bool());
return(item->to_bool(default_value));
return(target._map.size() > 0);
}
case('P'):
return(target._ptr != 0);
case('R'):
return(false);
return(default_value);
}
return(false);
return(default_value);
}
StringMap DTree::to_stringmap()
StringMap DTree::to_stringmap() const
{
const DTree& target = deref();
StringMap result;
@ -341,7 +361,7 @@ StringMap DTree::to_stringmap()
{
case('M'):
for(const auto& entry : target._map)
result[entry.first] = const_cast<DTree&>(entry.second.deref()).to_string();
result[entry.first] = entry.second.deref().to_string();
break;
case('S'):
if(dtree_trim(target._String) != "")
@ -350,7 +370,7 @@ StringMap DTree::to_stringmap()
case('F'):
case('B'):
case('P'):
result["value"] = const_cast<DTree&>(target).to_string();
result["value"] = target.to_string();
break;
case('R'):
break;
@ -358,7 +378,7 @@ StringMap DTree::to_stringmap()
return(result);
}
String DTree::to_json(char quote_char)
String DTree::to_json(char quote_char) const
{
const DTree& target = deref();
switch(target.type)
@ -385,7 +405,7 @@ String DTree::to_json(char quote_char)
return("\"(unknown)\"");
}
String DTree::get_type_name()
String DTree::get_type_name() const
{
const DTree& target = deref();
switch(target.type)
@ -412,7 +432,7 @@ String DTree::get_type_name()
return("unknown");
}
DTree DTree::get_by_path(String path, String delim)
DTree DTree::get_by_path(String path, String delim) const
{
const DTree* current = &deref();
if(path == "")
@ -447,7 +467,7 @@ DTree DTree::get_by_path(String path, String delim)
return(current->deref());
}
bool DTree::is_reference()
bool DTree::is_reference() const
{
return(type == 'R');
}
@ -778,12 +798,12 @@ String to_String(DTree t)
return(t.to_string());
}
String var_dump(DTree map, String prefix, String postfix)
String var_dump(const DTree& map, String prefix, String postfix)
{
String result = "";
if(!map.is_array())
return(map.to_string());
map.each([&] (DTree item, String key) {
map.each([&] (const DTree& item, String key) {
result += prefix + key + ": " + item.to_string() + postfix;
if(item.is_array())
result += var_dump(item, prefix + "\t");

View File

@ -19,19 +19,22 @@ struct DTree {
void* _ptr;
std::map<String, DTree> _map;
void each(std::function <void (const DTree& t, String key)> f);
bool is_array();
// Read accessors are const and never create or modify nodes. The to_*
// conversions take an optional default that is returned when the value is
// missing (empty) or cannot be converted to the requested type.
void each(std::function <void (const DTree& t, String key)> f) const;
bool is_array() const;
bool is_list() const;
String to_string();
s64 to_s64();
u64 to_u64();
f64 to_f64();
bool to_bool();
StringMap to_stringmap();
String to_json(char quote_char = '"');
String get_type_name();
DTree get_by_path(String path, String delim = "/");
bool is_reference();
String to_string(String default_value = "") const;
s64 to_s64(s64 default_value = 0) const;
u64 to_u64(u64 default_value = 0) const;
f64 to_f64(f64 default_value = 0) const;
bool to_bool(bool default_value = false) const;
StringMap to_stringmap() const;
String to_json(char quote_char = '"') const;
String get_type_name() const;
DTree get_by_path(String path, String delim = "/") const;
bool is_reference() const;
DTree* reference_target();
const DTree* reference_target() const;
DTree& deref();
@ -63,4 +66,4 @@ struct DTree {
};
String to_String(DTree t);
String var_dump(DTree map, String prefix = "", String postfix = "\n");
String var_dump(const DTree& map, String prefix = "", String postfix = "\n");

View File

@ -745,7 +745,19 @@ StringMap split_http_headers(String s)
while(header_start < lines.size() && trim(lines[header_start]) == "")
header_start++;
if(header_start < lines.size() && lines[header_start].find(':') == String::npos)
// A header line has its colon before any whitespace ("Host: x"); a request
// line has a space first even when the URI contains colons
// ("GET /x.uce?t=12:30 HTTP/1.1").
bool first_line_is_header = false;
if(header_start < lines.size())
{
String first_line = trim(lines[header_start]);
size_t colon = first_line.find(':');
size_t space = first_line.find_first_of(" \t");
first_line_is_header = colon != String::npos && (space == String::npos || colon < space);
}
if(header_start < lines.size() && !first_line_is_header)
{
String request_line = trim(lines[header_start]);
result["REQUEST_METHOD"] = nibble(request_line, " ");

View File

@ -132,6 +132,26 @@ void ob_close();
String ob_get();
String ob_get_close();
// RAII capture used by the preprocessor's @fragment rewrite: buffers the
// handler's output and appends it to context.call["fragments"][slot].
struct UceFragmentCapture
{
Request& context;
String slot;
UceFragmentCapture(Request& c, String s) : context(c), slot(s)
{
ob_start();
}
~UceFragmentCapture()
{
String html = ob_get_close();
if(html != "")
context.call["fragments"][slot] = context.call["fragments"][slot].to_string() + html;
}
};
String safe_name(String raw);
String ascii_safe_name(String raw);

View File

@ -3,16 +3,28 @@
#include <stdlib.h>
#include "mysql-connector.h"
static void mysql_register_request_connection(MySQL* db)
{
if(!context || !db)
return;
auto& connections = context->resources.mysql_connections;
if(std::find(connections.begin(), connections.end(), (void*)db) == connections.end())
connections.push_back(db);
}
bool MySQL::connect(String host, String username, String password)
{
// Register regardless of outcome: tracking is about the wrapper's
// lifetime, not the connection's. disconnect()/~MySQL() unregister.
mysql_register_request_connection(this);
//switch_to_system_alloc();
connection = mysql_init(NULL);
if (connection == NULL)
{
auto e = mysql_error((MYSQL*)connection);
fprintf(stderr, "%s\n", e);
fprintf(stderr, "mysql_init failed\n");
//switch_to_arena(context->mem);
statement_info.assign(e);
_preload_next_error_code = CR_OUT_OF_MEMORY;
statement_info = "mysql_init failed";
return(false);
}
@ -21,9 +33,11 @@ bool MySQL::connect(String host, String username, String password)
{
auto e = mysql_error((MYSQL*)connection);
fprintf(stderr, "%s\n", e);
mysql_close((MYSQL*)connection);
//switch_to_arena(context->mem);
_preload_next_error_code = CR_UNKNOWN_ERROR;
statement_info.assign(e);
mysql_close((MYSQL*)connection);
connection = NULL;
//switch_to_arena(context->mem);
return(false);
}
@ -37,8 +51,6 @@ bool MySQL::connect(String host, String username, String password)
*/
//switch_to_arena(context->mem);
statement_info = String("connected");
if(context)
context->resources.mysql_connections.push_back(this);
return(true);
}
@ -192,6 +204,12 @@ DTree MySQL::query(String q)
statement_info = "mysql positional ? placeholders are not supported; use named :name placeholders";
return(DTree());
}
if(!connection)
{
_preload_next_error_code = CR_UNKNOWN_ERROR;
statement_info = "mysql connection is not open";
return(DTree());
}
_preload_next_error_code = mysql_query((MYSQL*)connection, q.c_str());
DTree result;
if(_preload_next_error_code == 0)
@ -237,12 +255,8 @@ static bool mysql_has_unquoted_positional_placeholder(String query)
DTree MySQL::query(String q, StringMap params)
{
if(mysql_has_unquoted_positional_placeholder(q))
{
_preload_next_error_code = CR_UNKNOWN_ERROR;
statement_info = "mysql positional ? placeholders are not supported; use named :name placeholders";
return(DTree());
}
// Positional ? placeholders survive named substitution (values are always
// quoted by escape()), so the check in query(String) covers this path too.
return(query(
parse_query_parameters(q, params).c_str()
));
@ -347,6 +361,8 @@ String MySQL::error()
_preload_next_error_code = 0;
return(p);
}
if(!connection)
return("");
const char* res = mysql_error((MYSQL*)connection);
if(res)
{

View File

@ -33,6 +33,10 @@ struct MySQL {
DTree query(String q, StringMap params);
DTree get_pending_result();
// Unregisters from the request's connection tracking, so stack-allocated
// instances cannot leave dangling pointers behind for request cleanup.
~MySQL() { disconnect(); }
};
MySQL* mysql_connect(String host = "localhost", String username = "root", String password = "")

View File

@ -70,15 +70,21 @@ bool SQLite::connect(String path)
{
disconnect();
this->path = path;
// Register regardless of outcome: tracking is about the wrapper's
// lifetime, not the connection's. disconnect()/~SQLite() unregister.
sqlite_register_request_connection(this);
s32 rc = sqlite3_open_v2(path.c_str(), (sqlite3**)&connection, SQLITE_OPEN_READWRITE | SQLITE_OPEN_CREATE | SQLITE_OPEN_FULLMUTEX, 0);
if(rc != SQLITE_OK)
{
set_error(rc, "sqlite open failed for " + path);
disconnect();
if(connection)
{
sqlite3_close((sqlite3*)connection);
connection = 0;
}
return(false);
}
sqlite3_busy_timeout((sqlite3*)connection, 5000);
sqlite_register_request_connection(this);
if(!apply_default_pragmas())
return(false);
statement_info = "connected";
@ -278,9 +284,11 @@ SQLite* sqlite_connect(String path)
}
SQLite* db = new SQLite();
db->worker_cache = true;
if(db->connect(path) && db->connection)
{
db->worker_cache = true;
sqlite_worker_connection_cache[path] = db;
}
else
db->request_cleanup_delete = true;
return(db);
@ -339,6 +347,10 @@ void cleanup_sqlite_connections()
context->resources.sqlite_connections.pop_back();
if(db->worker_cache)
{
// A page that ran BEGIN and faulted must not leak its transaction
// (and the WAL write lock) into the next request on this worker.
if(db->connection && !sqlite3_get_autocommit((sqlite3*)db->connection))
sqlite3_exec((sqlite3*)db->connection, "ROLLBACK", 0, 0, 0);
db->affected_rows = 0;
db->insert_id = 0;
db->error_code = SQLITE_OK;

View File

@ -17,6 +17,10 @@ struct SQLite {
DTree query(String q);
DTree query(String q, const StringMap& params);
// Unregisters from the request's connection tracking, so stack-allocated
// instances cannot leave dangling pointers behind for request cleanup.
~SQLite() { disconnect(); }
private:
void set_error(s32 code, String info = "");
bool apply_default_pragmas();

View File

@ -17,17 +17,12 @@ constexpr f64 FILE_LOCK_WAIT_TIMEOUT_SECONDS = 3.0;
}
String capture_backtrace_string(u32 max_frames, u32 skip_frames)
String backtrace_frames_string(void* const* frames, size_t size, u32 skip_frames)
{
if(max_frames == 0)
return("");
std::vector<void*> frames(max_frames);
size_t size = backtrace(frames.data(), max_frames);
if(size == 0)
return("");
char** symbols = backtrace_symbols(frames.data(), size);
char** symbols = backtrace_symbols(frames, size);
if(!symbols)
return("");
@ -38,7 +33,17 @@ String capture_backtrace_string(u32 max_frames, u32 skip_frames)
trace += "\n";
}
free(symbols);
return(trace);
return(trace);
}
String capture_backtrace_string(u32 max_frames, u32 skip_frames)
{
if(max_frames == 0)
return("");
std::vector<void*> frames(max_frames);
size_t size = backtrace(frames.data(), max_frames);
return(backtrace_frames_string(frames.data(), size, skip_frames));
}
String signal_name(int sig)

View File

@ -65,6 +65,7 @@ bool ws_send(String message, bool binary = false, String scope = "");
bool ws_send_to(String connection_id, String message, bool binary = false);
bool ws_close(String connection_id = "");
String backtrace_frames_string(void* const* frames, size_t size, u32 skip_frames = 0);
String capture_backtrace_string(u32 max_frames = 32, u32 skip_frames = 0);
String signal_name(int sig);

View File

@ -366,14 +366,9 @@ DTree request_route_from_raw_path(String raw_path, String default_path)
void request_populate_context_params(Request& context, String default_path)
{
DTree route = request_query_route(context, default_path);
String script_url = request_script_url(context);
context.params["SCRIPT_URL"] = script_url;
context.params["BASE_URL"] = request_base_url_from_script_url(script_url);
context.params["ROUTE_PATH"] = route["l_path"].to_string();
context.params["ROUTE_PAGE"] = route["page"].to_string();
context.params["ROUTE_PATH_RAW"] = route["raw_path"].to_string();
context.params["ROUTE_VALID"] = route["valid"].to_bool() ? "1" : "0";
String raw_path;
parse_query(context.params["QUERY_STRING"], &raw_path);
request_populate_context_params_from_route(context, raw_path, default_path);
}
void request_populate_context_params_from_route(Request& context, String raw_path, String default_path)

View File

@ -18,7 +18,10 @@ static sigjmp_buf request_fault_jmp;
static volatile sig_atomic_t request_fault_active = 0;
static volatile sig_atomic_t request_fault_signal = 0;
static Request* request_fault_request = 0;
static String request_fault_trace = "";
// Raw frame pointers only: the signal handler must not allocate, so frames are
// captured here and symbolized after the siglongjmp.
static void* request_fault_frames[64];
static volatile sig_atomic_t request_fault_frame_count = 0;
static int websocket_exec_fd = -1;
static String websocket_exec_read_buffer = "";
static std::deque<DTree> websocket_exec_pending_jobs;
@ -105,7 +108,7 @@ void on_request_fault_signal(int sig)
request_fault_signal = sig;
if(request_fault_active && request_fault_request)
{
request_fault_trace = capture_backtrace_string(32, 1);
request_fault_frame_count = backtrace(request_fault_frames, 64);
siglongjmp(request_fault_jmp, 1);
}
on_segfault(sig);
@ -887,7 +890,7 @@ int handle_complete(FastCGIRequest& request) {
request_fault_request = &request;
request_fault_active = 1;
request_fault_signal = 0;
request_fault_trace = "";
request_fault_frame_count = 0;
install_request_fault_handlers();
String failure_title = "";
@ -898,7 +901,7 @@ int handle_complete(FastCGIRequest& request) {
{
failure_title = "fatal signal during request";
failure_details = "worker recovered before closing the upstream connection";
failure_trace = request_fault_trace;
failure_trace = backtrace_frames_string(request_fault_frames, request_fault_frame_count, 1);
}
else
{
@ -1502,6 +1505,10 @@ void init_base_process()
int main(int argc, char** argv)
{
// Warm up libgcc's backtrace state so the first in-handler backtrace()
// after a fault does not allocate.
backtrace(request_fault_frames, 4);
init_base_process();
ensure_proactive_compiler();

View File

@ -0,0 +1,37 @@
from pathlib import Path
# The demo pages are not regression suites, but every one of them must at
# least compile and render: a preprocessor or runtime change that breaks a
# demo page (e.g. literal text that looks like an entry point) should fail CI.
ERROR_MARKERS = [
"uce compile error",
"fatal signal during request",
"uncaught exception during request",
]
def _repo_root():
return Path(__file__).resolve().parents[2]
def _make_demo_case(page_path):
def run(context):
response = context.expect_status(page_path, 200)
body_lower = response.text.lower()
for marker in ERROR_MARKERS:
if marker in body_lower:
raise Exception("response body contained error marker %r for %s" % (marker, page_path))
return "HTTP 200 without error markers for %s" % page_path
return run
def register(registry):
demo_dir = _repo_root() / "site" / "demo"
for path in sorted(demo_dir.glob("*.uce")):
registry.case(
"demo page " + path.name,
_make_demo_case("/demo/" + path.name),
tags=["http", "smoke", "uce", "demo", "public"],
)

View File

@ -12,7 +12,9 @@ def register(registry):
starter_pages = [
("starter home", "/examples/uce-starter/", 200, "Stunning Apps"),
("starter dashboard", "/examples/uce-starter/?dashboard", 200, "Dashboard"),
("starter dashboard ONCE assets reach head", "/examples/uce-starter/?dashboard", 200, "views/dashboard.css"),
("starter workspace nested route", "/examples/uce-starter/?workspace/projects", 200, "Workspace"),
("starter workspace ONCE assets reach head", "/examples/uce-starter/?workspace/projects", 200, "css/workspace.css"),
("starter ajax section", "/examples/uce-starter/?page2-section1", 200, "UCE starter AJAX fragment response"),
("starter route traversal blocked", "/examples/uce-starter/?../../../demo/index", 404, "The requested page does not exist."),
]

View File

@ -14,6 +14,12 @@ from dataclasses import asdict, dataclass, field
from pathlib import Path
from typing import Callable, Dict, Iterable, List, Optional
# When executed as a script this module is "__main__"; register it under its
# real name too, so plugins doing `from run_network_tests import TestFailure`
# get this module instance (and the same TestFailure class) instead of a
# re-imported copy whose exceptions the runner's except clause cannot catch.
sys.modules.setdefault("run_network_tests", sys.modules[__name__])
@dataclass
class Target: