Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -802,7 +802,7 @@ if (shcl_save_file(doc, "server.shcl") != SHCL_SAVE_OK)
shcl_free(doc); // frees the document and everything handed out from it
```

The C binding uses `round()`, so link the math library - `cc -std=c11 -O2 ex.c -o ex -lm`. There are no per-object frees: reads hand back pointers into the document's arena, and the single `shcl_free` releases all of it, so anything you need afterwards must be copied out first. The file calls are an optional companion: `-DSHCL_NO_FILE_IO` compiles them out for an embedded target, leaving `shcl_parse` and `shcl_to_canonical` to work on text you hold yourself.
The C binding uses `round()`, so link the math library - `cc -std=c11 -O2 ex.c -o ex -lm`. There are no per-object frees: reads hand back pointers into the document's arena, and the single `shcl_free` releases all of it, so anything you need afterwards must be copied out first. A long-running process has two optional calls for a document it keeps: `shcl_reads_release` gives back what the reads have handed out, and `shcl_compact` gives back what repeated writes left behind. The file calls are an optional companion: `-DSHCL_NO_FILE_IO` compiles them out for an embedded target, leaving `shcl_parse` and `shcl_to_canonical` to work on text you hold yourself.

### Bash

Expand Down
18 changes: 17 additions & 1 deletion changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

- `ReadFile(path, maxBytes)` in every binding and the C++ veneer: the file tier's read half on its own - the file's text, or the load status saying why not, with a cap on how much is read (past it is `Unreadable`; 0 is no cap). `LoadFile` is now this plus a parse. It is for a consumer that needs the exact bytes it last saw, to tell its own save coming back as a change notification from somebody else's edit, or a bound on what it will read before parsing - both of which meant keeping a hand-rolled read beside the library.

- `ParseLimited` (each binding's spelling, and the C++ veneer): a parse with caller-supplied caps, for input the consumer does not control. A document holds many times its byte size in memory, so `ReadFile`'s byte cap alone cannot bound a load. A node cap stops the parse with one `E020` and counts the unparsed remainder as lost, so a save cannot silently truncate; an element cap refuses any line whose array would exceed it (`E021`), skipping the line whole rather than truncating the value. 0 disables a cap.
- `ParseLimited` (each binding's spelling, and the C++ veneer): a parse with caller-supplied caps, for input the consumer does not control. A document holds many times its byte size in memory, so `ReadFile`'s byte cap alone cannot bound a load. A node cap stops the parse with one `E020` and counts the unparsed remainder as lost, so a save cannot silently truncate; an element cap refuses any line whose array would exceed it (`E021`), skipping the line whole rather than truncating the value; a diagnostic cap lists only that many and ends the list with one `E022` that counts the rest, since a document of nothing but bad lines costs a diagnostic per line. 0 disables a cap.

- `shcl_compact()` in the C binding, and `compact()` on the C++ veneer: the write-side counterpart to `shcl_reads_release`. A write lands in the document's bump arena and the value it replaced stays there until `shcl_free`, so a process rewriting one field once a second grew by a few megabytes a day with no way to give it back. Compaction rebuilds the document into fresh arenas holding only what it now contains, diagnostics, lost count and strictness included, so a save or a strict gate afterwards reads the same. Optional; a write-once consumer never needs it.

- `shcl_reads_release()` in the C binding: gives back the memory the read calls have handed out, without touching the document. Read results live in the document's arena until it is freed, which is right for a read-once consumer and wrong for a process polling one document in a loop - 200k array reads held 15.7 MB it could not give back. Optional, so nothing changes for a caller that ignores it; the C++ veneer calls it on every read, since it copies each result out immediately.

Expand All @@ -34,6 +36,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Changed

- A float literal past the double range (`1e400`) reads as `BadType` instead of an infinity at `Good`. No double holds the value, and the infinity could not be written back, so a read-modify-write left a field the reader then refused. A literal below the range still reads as zero.

- `get`, `count` and `instances` print the load's diagnostics to stderr, the way `fmt` and `set` already did. Below strict a damaged file used to read back a correct value at exit 0 with nothing said, so the only way to learn a line had been dropped was a separate `check` run. One report per run; stdout is unchanged.

- A file or stream that could not be read or written now exits 8, and exit 1 means a usage error alone. A missing file, an unreadable one, a directory named where a file was wanted, and a target whose directory refuses a write all used to share 1 with a mistyped flag, so a script could not tell "fix the command line" from "fix the path". A path a write option refuses stays at 1, since what has to change there is the option's value.
Expand Down Expand Up @@ -68,6 +72,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),

### Fixed

- A file of lines with no colon at a constant indent parsed in quadratic time - a 1 MB plain text file took half a minute, and neither `ParseLimited` cap could stop it because no nodes or elements were built. Each refused line is kept as trivia, and every following line rewalked the whole retained list. The list is walked only as far as an incoming line could change it now, so the parse is linear again.

- `ParseLimited`'s element cap bounded nothing for an inline array: the line was built in full and refused afterwards, so 9 MB of input peaked at the same 256 MB with the cap as without, and in C the refused array stayed held for the document's lifetime. The count is taken before anything splits the value now, so a refused line costs its text and no more.

- `SetFloat` wrote `inf`, `-inf` and `NaN`, and `SetDateTime` wrote whatever the struct held (month 99, February 30, a fraction with no seconds, an empty struct as an empty value), each reporting success and each leaving a field the reader refused. Both refuse the value now and return false, the way `SetRaw` refuses an info-string it cannot spell. The CLI's float ops refuse `inf`, `nan` and a literal past the double range for the same reason; a datetime op already did.

- `init` wrote a child under a valued parent as a dotted line - `srv: web` and then `srv.port:` - which is two `srv` instances to the parser, so the child never landed where the schema looks. With a repeat lower bound of 1 on the child the self-check waved it through, and the starter config failed the schema that produced it at the very next `check --schema`. A line under a valued live parent now selects that instance by its value (`srv[web].port:`), and the self-check lets through only the one documented shortfall, a repeat lower bound of 2 or more. The C CLI reported a schema that does not build with the faults an empty document would owe it added on; it reports the build faults alone now, like the other three.

- `SetLiteral` (and `--set-literal`) took bracket-array text and wrote a two-element array holding `[80` and `443]`, with nothing said, where the same text in a file is `E019` and the line is refused. It refuses the text now, the way it already refused a quote that never closes.

- `shcl_paths` in the C binding grew the document by about 11 KB on every call, and `shcl_reads_release` could not give it back, so a process polling a document's key list climbed for the document's lifetime. It was the one read that took no path and so missed the scratch reset the path lookup does; it resets on entry now.

- The C validator put one scratch arena per level of the nesting cap on the stack - 16 KB, fine on a main thread and past the whole stack of a small worker, where it crashed. They are heap-allocated now.

- Go's atomic write ignored the result of closing the temp file, so a write error that surfaced only at close would publish a truncated file over the target. The other three bindings already reported it.
Expand Down
1 change: 1 addition & 0 deletions cicd/config.bash
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ TEST_EXTRA=(
'vbin="$(mktemp)"; g++ -std=c++17 -O2 -Wall -Wextra -Werror -Isource/c source/c/tests/veneer_smoke.cpp -o "${vbin}" -lm && "${vbin}"; vrc=$?; rm -f "${vbin}"; ((vrc==0))'
'obin="$(mktemp)"; cc -std=c11 -O2 -Wall -Wextra -Werror -Isource/c source/c/tests/oom_hook.c -o "${obin}" -lm && "${obin}"; orc=$?; rm -f "${obin}"; ((orc==0))'
'rbin="$(mktemp)"; cc -std=c11 -O2 -Wall -Wextra -Werror -Isource/c source/c/tests/oom_recover.c -o "${rbin}" -lm && "${rbin}"; rrc=$?; rm -f "${rbin}"; ((rrc==0))'
'mbin="$(mktemp)"; cc -std=c11 -O2 -Wall -Wextra -Werror -Isource/c source/c/tests/mem_bounds.c -o "${mbin}" -lm && "${mbin}"; mrc=$?; rm -f "${mbin}"; ((mrc==0))'
## CLI behavior the corpus cannot reach: closed streams, '-' twice on one
## command line, a carriage return ending an ops line, error-message shape.
'cicd/utility/cli-regress.bash "${BINDING_CLIS[@]}"'
Expand Down
3 changes: 2 additions & 1 deletion cicd/utility/check-c-compilers.bash
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ for cc in "${compilers[@]}"; do
## is a setjmp, and which locals a compiler thinks the unwind can clobber
## differs by version and optimization level.
for src in source/c/cmd/shcl/main.c source/c/tests/conformance.c \
source/c/tests/oom_hook.c source/c/tests/oom_recover.c; do
source/c/tests/oom_hook.c source/c/tests/oom_recover.c \
source/c/tests/mem_bounds.c; do
nRun+=1
if ! out="$("${cc}" -std=c11 -O2 -Wall -Wextra -Werror -I"${repoDir}/source/c" \
"${repoDir}/${src}" -o "${tmpDir}/out" -lm -lpthread 2>&1)"; then
Expand Down
30 changes: 28 additions & 2 deletions cicd/utility/cli-regress.bash
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,15 @@ awk 'BEGIN{ for (i = 0; i < 511; i++) { for (j = 0; j < i; j++) printf "\t"; pri
## A schema whose own default breaks the field's constraints. Generation used to
## emit it anyway, so the starter config failed the schema that produced it.
printf 'field: server.port\n\ttype: int\n\trequired: yes\n\tmin: 1\n\tmax: 10\n\tdefault: 99\n' > "${tmpDir}/baddef.shcl"
## A must-exist path with no name to generate: repeat 1 is a must-exist bound
## like `required`, and generation cannot satisfy it, so the schema is faulted
## rather than a starter that fails its first check. Repeat 2 is the one
## documented shortfall and generates.
printf 'field: "*"\n\ttype: int\n\trepeat: 1\n' > "${tmpDir}/star1.shcl"
printf 'field: "*"\n\ttype: int\n\trepeat: 2\n' > "${tmpDir}/star2.shcl"
## A schema that does not build: the report is the build faults alone, not the
## faults plus what an empty document would owe the schema.
printf 'field: a\n\ttype: int\n\trequired: yes\nfield: b\n\ttype: nope\n' > "${tmpDir}/nobuild.shcl"
## An instance whose discriminator holds an '=', which is what made --set's own
## split ambiguous.
printf 'x[a=b]:\n\tc: 0\n' > "${tmpDir}/sel.shcl"
Expand All @@ -61,12 +70,15 @@ printf 'a: 1\nb: 2\n' > "${tmpDir}/two.shcl"

## Rows: id | argv | stdin | rc | stdout | stderr-regex
## argv placeholders: %F% the good file, %B% the two-error file, %D% a directory,
## %P% the deepest legal document, %S% the self-contradicting schema, %X% an
## %P% the deepest legal document, %S% the self-contradicting schema, %S1%/%S2%
## a nameless must-exist path at repeat 1 and 2, %S3% a schema that does not
## build, %X% an
## instance whose discriminator holds an '=', %T% a document with a name that
## needs quoting in a path, %F2% a two-key file for the edit options, %M% a
## path with no file at it.
## stdin: printf %b text, '-' none, '@closedin' / '@closedout' close that stream.
## stdout and stderr: '-' means unchecked; an empty stdout field means exactly empty.
## A stderr regex starting with '!' must match NO line.
## Each row names the round and item it pins.
rows=(
## 20260830 item 15: a second '-' read an empty document that looked like an answer.
Expand All @@ -91,6 +103,13 @@ rows=(
'deep-nesting|fmt %P%|-|0|-|^$'
## 20260830b item 4: init emitted a config that fails the schema that made it.
'init-bad-default|init --schema=%S%|-|6||V097 generated value fails the schema'
## 20260901 item 5: the self-check waved every V007 through, so a repeat
## lower bound of 1 - a must-exist path - went out as a config that fails
## its own schema at exit 0.
'init-star-repeat1|init --schema=%S1%|-|6||V097 .*not in 1\.\.1'
'init-star-repeat2|init --schema=%S2%|-|0|-|^$'
'init-build-fault|init --schema=%S3%|-|6||V091 unknown schema type'
'init-build-fault-only|init --schema=%S3%|-|6||!V002'
## 20260830 item 35: -h and --help after FILE were an unknown option, though
## every other option is read there.
'help-after-file|get %F% -h|-|0|-|-'
Expand Down Expand Up @@ -150,6 +169,9 @@ for row in "${rows[@]}"; do
argv="${argv//%D%/${tmpDir}/adir}"
argv="${argv//%P%/${tmpDir}/deep.shcl}"
argv="${argv//%S%/${tmpDir}/baddef.shcl}"
argv="${argv//%S1%/${tmpDir}/star1.shcl}"
argv="${argv//%S2%/${tmpDir}/star2.shcl}"
argv="${argv//%S3%/${tmpDir}/nobuild.shcl}"
argv="${argv//%X%/${tmpDir}/sel.shcl}"
argv="${argv//%T%/${tmpDir}/tree.shcl}"
argv="${argv//%F2%/${tmpDir}/two.shcl}"
Expand Down Expand Up @@ -178,7 +200,11 @@ for row in "${rows[@]}"; do
if [[ "${wantErr}" != "-" ]]; then
## The stdin notice is a prompt, not a diagnostic; it is not what a row is about.
gotErr="$(grep -v 'reading write-ops from stdin' "${tmpDir}/err" || true)"
if ! grep -qE -- "${wantErr}" <<<"${gotErr}"; then
if [[ "${wantErr}" == !* ]]; then
if grep -qE -- "${wantErr#!}" <<<"${gotErr}"; then
echo "cli-regress: ${id} [${name}]: stderr ${gotErr@Q} matches /${wantErr#!}/" >&2; nBad+=1
fi
elif ! grep -qE -- "${wantErr}" <<<"${gotErr}"; then
echo "cli-regress: ${id} [${name}]: stderr ${gotErr@Q} does not match /${wantErr}/" >&2; nBad+=1
fi
fi
Expand Down
47 changes: 32 additions & 15 deletions cicd/utility/perf-gate.bash
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
#!/usr/bin/env bash

## Purpose:
## Fail when the read or write path stops being linear. Two rounds running,
## a fix
## that was correct made bulk writes 4.5x slower and absent-path defaults
## 140x slower, and both reached dev because nothing had a number to fail
## on. Each workload is timed against the same binding's parse-only
## baseline on the same machine, so the gate carries no wall-clock constant
## and does not care how fast the runner is: applying the ops must stay
## small beside reading the document. A per-op index rebuild or a scan of
## every sibling breaks that ratio by more than an order of magnitude.
## Fail when the read, write or malformed-input path stops being linear.
## Two rounds running, a fix that was correct made bulk writes 4.5x slower
## and absent-path defaults 140x slower, and both reached dev because
## nothing had a number to fail on. A third round found a plain text file
## parsing in quadratic time. Each workload is timed against the same
## binding's parse-only baseline on the same machine, so the gate carries
## no wall-clock constant and does not care how fast the runner is:
## applying the ops, or refusing every line, must stay small beside
## reading a well-formed document of the same size. A per-op index
## rebuild, a scan of every sibling, or a rewalk of the retained lines
## breaks that ratio by more than an order of magnitude.
## Syntax:
## perf-gate.bash [--keys N] [--factor F] NAME|CLI [NAME|CLI ...]
## --keys N flat keys in the generated document (default 40000)
Expand Down Expand Up @@ -53,14 +55,23 @@ awk 'BEGIN{ for (i = 0; i < 1000; i++) printf "int-default\tnew%d\t%d\n", i, i }
## drive one lookup per key through a single process. Without an index that
## outlives the parse this is a sibling scan per key, so it goes quadratic.
awk -v n="${keys}" 'BEGIN{ for (i = 0; i < n; i++) printf "int-default\tk%d\t0\n", i }' > "${tmpDir}/reads.ops"
## The same line count with no colon on any line. Every line is refused and
## retained as trivia, and the retained list must not be rewalked per line.
badDoc="${tmpDir}/bad.shcl"
awk -v n="${keys}" 'BEGIN{ for (i = 0; i < n; i++) print "no colon here" }' > "${badDoc}"

## Milliseconds for one run of $2 (an ops file) through CLI $1, best of two so
## a scheduling hiccup does not fail the gate.
## Milliseconds for one run of $2 (an ops file, or a document when $3 is
## "check") through CLI $1, best of two so a scheduling hiccup does not fail
## the gate.
fTimeMs(){
local cli="$1" ops="$2" best=0 ms start end
local cli="$1" input="$2" mode="${3:-set}" best=0 ms start end
for _ in 1 2; do
start="$(date +%s%N)"
"${cli}" set "${doc}" < "${ops}" > /dev/null 2>&1 || true
if [[ "${mode}" == check ]]; then
"${cli}" check "${input}" > /dev/null 2>&1 || true
else
"${cli}" set "${doc}" < "${input}" > /dev/null 2>&1 || true
fi
end="$(date +%s%N)"
ms=$(( (end - start) / 1000000 ))
if ((best == 0 || ms < best)); then best="${ms}"; fi
Expand All @@ -77,8 +88,12 @@ for b in "${bindings[@]}"; do
budget=$(( baseMs * factor ))
floor=$(( baseMs + 250 ))
if ((budget < floor)); then budget="${floor}"; fi
for w in writes defaults reads; do
ms="$(fTimeMs "${cli}" "${tmpDir}/${w}.ops")"
for w in writes defaults reads badlines; do
if [[ "${w}" == badlines ]]; then
ms="$(fTimeMs "${cli}" "${badDoc}" check)"
else
ms="$(fTimeMs "${cli}" "${tmpDir}/${w}.ops")"
fi
if ((ms > budget)); then
echo "perf-gate: ${name}: ${w} took ${ms} ms against a ${budget} ms budget (parse-only baseline ${baseMs} ms)" >&2
nBad+=1
Expand All @@ -97,3 +112,5 @@ echo "perf-gate: OK: ${keys} keys, ${#bindings[@]} binding(s) within ${factor}x
## History:
## 2026-08-30 Created after two superlinear write regressions reached dev
## in consecutive rounds with no numeric gate to catch them.
## 2026-09-01 badlines workload: a document of refused lines, which went
## quadratic through the retained-trivia list.
5 changes: 4 additions & 1 deletion cicd/utility/sanitize-c.bash
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ ASAN_OPTIONS="${ASAN_OPTIONS}:detect_leaks=0" "${work}/oom_hook" || { echo "sani
## everything the half-built document held is half of what that is worth.
fBuild cc c11 source/c/tests/oom_recover.c "${work}/oom_recover"
"${work}/oom_recover" || { echo "sanitize-c: oom_recover: exit $?" >&2; rc=1; }
## The allocation bounds, under the same instrumentation.
fBuild cc c11 source/c/tests/mem_bounds.c "${work}/mem_bounds"
"${work}/mem_bounds" || { echo "sanitize-c: mem_bounds: exit $?" >&2; rc=1; }
## The C++ veneer owns the C handle by hand (rule of five over a raw pointer),
## which is exactly the kind of code a leak or double free hides in.
fBuild g++ c++17 source/c/tests/veneer_smoke.cpp "${work}/veneer_smoke"
Expand Down Expand Up @@ -141,7 +144,7 @@ done
if ((nBad)); then
echo "sanitize-c: ${nBad}/${nRuns} CLI run(s) stopped by a sanitizer" >&2; rc=1
else
echo "sanitize-c: OK: runner, oom_hook, oom_recover, veneer_smoke and ${nRuns} CLI run(s) clean under ASan+UBSan"
echo "sanitize-c: OK: runner, oom_hook, oom_recover, mem_bounds, veneer_smoke and ${nRuns} CLI run(s) clean under ASan+UBSan"
fi
exit "${rc}"

Expand Down
Loading