4 Commits
Author SHA1 Message Date
andrew dfa7451c16 ci: download mc from weaselab mirror 2026-11-04 13:35:06 -05:00
andrew 426b2641ff Merge pull request 'parser3: keep the stackSize needed by a document independent of chunking (reserve transient token slots)' (#72) from weaselbot/weaseljson:weaselbot/issue-71 into main
Reviewed-on: weaselab/weaseljson#72
2026-10-30 13:05:50 +00:00
weaselbot bce971fbd2 ci: don't hardcode ~/.local/bin when running pre-commit
The pre-commit job failed with

    /root/.local/bin/pre-commit: No such file or directory

even though `pipx install pre-commit` succeeded: `pipx install` writes the
app to PIPX_BIN_DIR, and the runner image no longer uses ~/.local/bin for
it. Run the binary from PATH when pipx put it there, and fall back to
`pipx run pre-commit` otherwise, so the hook still runs regardless of
where pipx exposes its apps.
2026-10-27 18:53:07 -04:00
weaselbot 507be6f0a2 Address review: keep the transient-token reserve out of the public header
The create() documentation described how the reserve is implemented (six
extra slots for literals and \uXXXX escapes that straddle a chunk
boundary). That is an implementation detail; the header should only
promise the behaviour callers can rely on, i.e. that the accept/reject/
overflow decision depends on the document's bytes and on stackSize, not
on how they are split across parse() calls. The derivation of the upper
bound lives next to kTransientSymbols in src/parser3.h, where it can be
kept in sync with the pushes.

Also make the chunking test cover every split point, not just fixed
strides: each document is split in two at every possible offset, so every
literal, escape and surrogate pair in the fixture straddles a chunk
boundary. Verified that this fails on the pre-fix parser ("true",
stackSize 3, cut 1: whole buffer accepts, two chunks overflow).
2026-10-27 17:31:49 -04:00
4 changed files with 68 additions and 34 deletions

No files matched your search

+10 -2
View File
@@ -12,7 +12,15 @@ jobs:
run: pipx install pre-commit run: pipx install pre-commit
- name: Run pre-commit - name: Run pre-commit
run: ~/.local/bin/pre-commit run --all-files --show-diff-on-failure # `pipx install` writes the app to PIPX_BIN_DIR, which is not
# necessarily ~/.local/bin and not necessarily on PATH, so don't
# hardcode a path that only some runner images happen to use.
run: |
if command -v pre-commit >/dev/null 2>&1; then
pre-commit run --all-files --show-diff-on-failure
else
pipx run pre-commit run --all-files --show-diff-on-failure
fi
build: build:
strategy: strategy:
@@ -76,7 +84,7 @@ jobs:
echo "MinIO credentials not configured; skipping upload" echo "MinIO credentials not configured; skipping upload"
exit 0 exit 0
fi fi
curl -Ls "https://dl.min.io/client/mc/release/linux-$(dpkg --print-architecture)/mc" \ curl -fLs "https://minio.weaselab.dev/public/$(uname -m)/mc.RELEASE.2025-08-13T08-35-41Z" \
-o /tmp/mc && chmod +x /tmp/mc -o /tmp/mc && chmod +x /tmp/mc
dest="minio/jenkins/weaseljson/${{ gitea.run_number }}/${{ matrix.name }}/" dest="minio/jenkins/weaseljson/${{ gitea.run_number }}/${{ matrix.name }}/"
/tmp/mc cp build/*.deb "$dest" /tmp/mc cp build/*.deb "$dest"
+4 -8
View File
@@ -50,14 +50,10 @@ enum WeaselJsonFlags {
}; };
/** Create a parser. Increasing stack size increases memory usage but also /** Create a parser. Increasing stack size increases memory usage but also
* increases the depth of nested json accepted. `stackSize` is an exact budget: * increases the depth of nested json accepted. Whether a document is accepted,
* whether a document is accepted, rejected or reported as too deeply nested * rejected or reported as too deeply nested depends only on its bytes and on
* depends only on its bytes and on `stackSize`, not on how the bytes are split * `stackSize`, not on how the bytes are split across WeaselJsonParser_parse
* across WeaselJsonParser_parse calls. To get that guarantee, the parser * calls. `callbacks` and `userdata` must outlive the returned parser. Returns
* allocates 6 slots beyond `stackSize` for tokenizer progress: literals
* ("true", "false", "null") and \uXXXX escapes that straddle the end of a chunk
* record their unmatched characters there instead of spending the nesting
* budget. `callbacks` and `userdata` must outlive the returned parser. Returns
* null if there's insufficient available memory, or if `stackSize` is negative * null if there's insufficient available memory, or if `stackSize` is negative
* or too small to hold a minimal document. */ * or too small to hold a minimal document. */
WeaselJsonParser *WeaselJsonParser_create(int stackSize, WeaselJsonParser *WeaselJsonParser_create(int stackSize,
+17 -14
View File
@@ -73,24 +73,27 @@ enum Symbol : uint8_t {
N_SYMBOL_COUNT, // Must be last N_SYMBOL_COUNT, // Must be last
}; };
// Symbols that are only on the stack because a literal (`true`/`false`/`null`) // Some symbols are on the stack only because a literal (`true`/`false`/`null`)
// or a `\uXXXX` escape straddles the end of a chunk. The whole-buffer path // or a `\uXXXX` escape straddles the end of a chunk. The whole-buffer path
// consumes those characters inline and pushes nothing, so these symbols must // consumes those characters inline and pushes nothing, so these symbols must
// not spend the caller's nesting budget: otherwise the stackSize a document // not spend the caller's nesting budget: otherwise the stackSize a document
// needs would depend on where the parse() boundaries happen to fall (see issue // needs would depend on where the parse() boundaries happen to fall (issue
// #71). WeaselJsonParser_create() therefore allocates this many extra slots on // #71). WeaselJsonParser_create() allocates this many slots beyond `stackSize`
// top of `stackSize`; see Parser3::pushTransient(). // to hold them, and Parser3::pushTransient() pushes them there.
// //
// These are all the pushes of such symbols, with their sizes: // All the pushes of such symbols, with their sizes:
// n_value() 3, 4, 3 (true, false, null) // n_value() 3, 4, 3 (true, false, null)
// n_string2() 1 (N_STRING_FOLLOWING_ESCAPE) // n_string2() 1 (N_STRING_FOLLOWING_ESCAPE)
// n_string_following_escape() 4 (the four hex digits) // n_string_following_escape() 4 (the four hex digits)
// t_hex2() 6 (utf-16 surrogate pair) // t_hex2() 6 (utf-16 surrogate pair)
// No transient symbol is ever pushed while another one is on the stack (each of //
// these continuations pops its own symbol before pushing, and the literal ones // Upper bound on how many of them are on the stack at once: 6. These are
// run with N_VALUE already popped), so the largest single push - the six // pushdown symbols, so the ones that are on the stack are consumed before
// symbols that start a surrogate pair - bounds the number of transient symbols // anything else is pushed; and none of those continuations pushes while
// on the stack. // another's symbols are still pending, because each pops the symbol it took
// before pushing (the literal ones pop N_VALUE before n_value() pushes, and the
// rest pop themselves). The number of pending symbols is therefore bounded by
// the largest single push, the six symbols that start a surrogate pair.
inline constexpr int kTransientSymbols = 6; inline constexpr int kTransientSymbols = 6;
struct Parser3 { struct Parser3 {
@@ -164,10 +167,10 @@ struct Parser3 {
} }
// Push tokenizer progress symbols for a literal or escape that straddles a // Push tokenizer progress symbols for a literal or escape that straddles a
// chunk boundary. These go in the reserve past `stackEnd`, so they don't // chunk boundary. Unlike push(), these may grow past `stackEnd` into the
// spend the caller's nesting budget and can't make the parser overflow on // reserve, so they don't spend the caller's nesting budget and can never make
// input it would have accepted in one piece. See kTransientSymbols for why // the parser overflow on input it would have accepted in one piece. See
// the reserve is big enough to always succeed. // kTransientSymbols for why the reserve is big enough to always succeed.
[[nodiscard]] WeaselJsonStatus [[nodiscard]] WeaselJsonStatus
pushTransient(std::initializer_list<Symbol> symbols) { pushTransient(std::initializer_list<Symbol> symbols) {
assert(ptrdiff_t(symbols.size()) <= kTransientSymbols); assert(ptrdiff_t(symbols.size()) <= kTransientSymbols);
+37 -10
View File
@@ -154,19 +154,30 @@ void testStreaming(std::string const &json) {
CHECK(streaming.result == batch.result); CHECK(streaming.result == batch.result);
} }
// Feed `doc` to a fresh parser with the given stackSize, in chunks of `stride` // Feed `doc` to a fresh parser with the given stackSize, cutting it into chunks
// bytes (`stride` <= 0 means all at once), and return the final status. // at the offsets in `cuts` (ascending, each > 0 and < doc.size(); an empty
// `cuts` feeds the whole document at once), and return the final status.
WeaselJsonStatus parseChunked(std::string const &doc, int stackSize, WeaselJsonStatus parseChunked(std::string const &doc, int stackSize,
int stride) { std::vector<size_t> const &cuts) {
auto c = noopCallbacks(); auto c = noopCallbacks();
std::string copy = doc; std::string copy = doc;
auto *parser = WeaselJsonParser_create(stackSize, &c, nullptr, 0); auto *parser = WeaselJsonParser_create(stackSize, &c, nullptr, 0);
REQUIRE(parser != nullptr); REQUIRE(parser != nullptr);
int step = stride > 0 ? stride : std::max(1, (int)copy.size());
WeaselJsonStatus status = WeaselJson_AGAIN; WeaselJsonStatus status = WeaselJson_AGAIN;
for (size_t i = 0; i < copy.size() && status == WeaselJson_AGAIN; i += step) { size_t pos = 0;
status = WeaselJsonParser_parse(parser, copy.data() + i, for (size_t cut : cuts) {
std::min<int>(step, copy.size() - i)); if (status != WeaselJson_AGAIN) {
break;
}
if (cut > pos && cut < copy.size()) {
status =
WeaselJsonParser_parse(parser, copy.data() + pos, int(cut - pos));
pos = cut;
}
}
if (status == WeaselJson_AGAIN && pos < copy.size()) {
status = WeaselJsonParser_parse(parser, copy.data() + pos,
int(copy.size() - pos));
} }
if (status == WeaselJson_AGAIN) { if (status == WeaselJson_AGAIN) {
status = WeaselJsonParser_parse(parser, nullptr, 0); status = WeaselJsonParser_parse(parser, nullptr, 0);
@@ -175,6 +186,15 @@ WeaselJsonStatus parseChunked(std::string const &doc, int stackSize,
return status; return status;
} }
// The offsets to cut `doc` into chunks of `stride` bytes.
std::vector<size_t> stridedCuts(std::string const &doc, size_t stride) {
std::vector<size_t> cuts;
for (size_t cut = stride; cut < doc.size(); cut += stride) {
cuts.push_back(cut);
}
return cuts;
}
} // namespace } // namespace
TEST_CASE("parser3") { TEST_CASE("parser3") {
@@ -314,10 +334,17 @@ TEST_CASE("acceptance is independent of the chunking") {
// sweeps from "everything overflows" to "everything fits". // sweeps from "everything overflows" to "everything fits".
for (int stackSize = 3; stackSize <= 16; ++stackSize) { for (int stackSize = 3; stackSize <= 16; ++stackSize) {
CAPTURE(stackSize); CAPTURE(stackSize);
auto whole = parseChunked(doc, stackSize, 0); auto whole = parseChunked(doc, stackSize, {});
for (int stride = 1; stride <= 12; ++stride) { // Every way to split the document in two, so that every literal and
// escape in `docs` straddles a chunk boundary at some point.
for (size_t cut = 1; cut < doc.size(); ++cut) {
CAPTURE(cut);
REQUIRE(parseChunked(doc, stackSize, {cut}) == whole);
}
for (size_t stride = 1; stride <= 12; ++stride) {
CAPTURE(stride); CAPTURE(stride);
REQUIRE(parseChunked(doc, stackSize, stride) == whole); REQUIRE(parseChunked(doc, stackSize, stridedCuts(doc, stride)) ==
whole);
} }
} }
} }