Recursion guard and static klammer checking

A klammer that reaches itself, directly or through a cycle, expanded
until the C++ stack was exhausted: the process died from SIGSEGV with no
message and no location. The former limit guarded only the top-level
fixed-point iteration, never the descent through klammer application. A
depth guard now raises a recursion error naming the klammer and where it
was applied. The same loop's termination test moves from "the katom list
stopped growing" to "a pass applied no klammer", since a klammer whose
body expands to nothing is a reduction that adds no katoms; exceeding the
round limit is now an error rather than a message followed by rendering a
document with live klammers still in it.

ktext --check locates every klammer application written in a document or
in a klammer body and checks name existence, argument count, option
names, and target coverage without applying anything, reporting all
problems at once. This is possible because Klammertext has no catcodes:
katom structure is fixed when a file is read, so a klammer body has a
determinate shape before it is expanded. The check therefore reaches what
the engine cannot -- the branch of a @cond that is not selected, and
bodies a given render never enters.

@cond's set of truth values is an open language question, so its meaning
is unchanged here; an unrecognized predicate now warns, giving its value
and location.

tst/ gains recursion_test.sh (7 cases) and check_test.sh (19 cases), and
this snapshot's test Makefile is generated from the shipped suite list so
the two cannot drift apart.

(from dev c27e63802406)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 15:41:43 +02:00
parent 55a99c7eeb
commit 4306dcd490
11 changed files with 849 additions and 29 deletions

View File

@@ -25,6 +25,56 @@ Machine::Machine()
*/
}
// Klammer application recursion guard.
//
// Applying a klammer expands its body, which is processed and applied in
// turn (apply_klammer -> process_katoms -> apply -> apply_klammer), so a
// klammer that reaches itself -- directly (@@f : x @f@ @@) or through a
// cycle -- descends without bound. Before this guard the descent simply
// exhausted the C++ stack: SIGSEGV, no message, no location.
//
// The counter is a translation-unit static rather than a Machine member for
// two reasons: recursion can cross Machine instances (Eval::eval builds a
// sub-Machine to re-read an @eval result, and that sub-Machine applies
// klammers on the same C++ stack), and keeping it out of Machine avoids
// changing the class layout shared with the dlopened sks/document.so.
//
// The limit bounds the C++ stack, not the language: it is far above any
// plausible nesting depth in a document, and reaching it means a klammer
// does not terminate. NOTE: not thread-safe; if input files are ever
// processed in parallel this needs to become thread_local.
namespace {
constexpr int max_apply_depth = 200;
int apply_depth = 0;
// Rounds of the top-level fixed-point loop (see Machine::apply below). The
// former limit of 5 was a silent truncation; it is now an error, so it is set
// well clear of any legitimate chain of klammers generating klammers.
constexpr int apply_round_limit = 100;
class Depth_guard
{
public:
Depth_guard(const std::string& name, const Locator& loc)
{
if (apply_depth >= max_apply_depth) {
std::stringstream ss {};
ss << "Klammer application nested more than " << max_apply_depth
<< " levels deep while applying " << q_(name) << ".\n"
<< "A klammer that applies itself, directly or through a cycle "
<< "of klammers, does not terminate.";
throw Recursion_error(ss.str(), loc, false);
}
++apply_depth;
}
~Depth_guard() { --apply_depth; }
Depth_guard(const Depth_guard&) = delete;
Depth_guard& operator=(const Depth_guard&) = delete;
};
} // namespace
void Machine::process_eval_katoms(katom_list& katoms)
{
(void)K::log(3);
@@ -88,6 +138,35 @@ bool is_true(const std::string& s)
return s == "True" || s == "true" || s == "1";
}
// @cond's predicate relation is currently partial in effect: is_true()
// recognizes three strings as true and treats EVERYTHING else as false, so a
// misspelled state variable, a "TRUE", a "yes", or a Python traceback all
// silently select the false branch.
//
// What the truth values should be is an open language-policy question (see
// notes/Klammertext_improvements.md, "The @cond predicate relation"), so the
// semantics here is deliberately unchanged. What is added is visibility: a
// predicate outside the provisionally recognized sets below is reported, with
// its value and location, so the cases can be found in real documents while
// the policy is decided. The recognized false set carries no semantics -- it
// exists only to keep the diagnostic quiet for values that plainly mean false.
bool is_recognized_predicate(const std::string& s)
{
return s.empty()
|| s == "True" || s == "true" || s == "1"
|| s == "False" || s == "false" || s == "0";
}
void warn_unrecognized_predicate(const std::string& predicate, const Locator& loc)
{
if (is_recognized_predicate(predicate)) return;
std::stringstream ss {};
ss << "The @cond predicate " << q_(predicate)
<< " is not a recognized truth value, so the false branch was taken.\n"
<< " Recognized: true, True, 1 (true); false, False, 0, empty (false).";
warning(ss.str(), loc);
}
void Machine::process_cond_katoms(katom_list& katoms)
{
if (std::find_if(katoms.begin(), katoms.end(), begin_cond) != katoms.end()) {
@@ -104,6 +183,7 @@ void Machine::process_cond_katoms(katom_list& katoms)
check_bar_count(begin, bars.size());
auto bar_1 = bars[0];
std::string predicate = to_string(begin + 1, bar_1, true);
warn_unrecognized_predicate(predicate, begin->m_loc);
katom_list true_clause {};
katom_list false_clause {};
if (bars.size() == 2) {
@@ -460,6 +540,7 @@ katom_list Machine::apply_klammer(
Klammer& klammer, const std::string& target, katom_iter arguments_begin, katom_iter arguments_end)
{
(void)K::log(3, "argument substitution", *arguments_begin, *(arguments_end - 1));
Depth_guard depth_guard(klammer.m_name, arguments_begin->m_loc);
m_state.replace("K_loc", arguments_begin->m_loc.str(), false);
auto [positional, optional, rest] =
argument_split(arguments_begin + 1, arguments_end - 1, klammer.m_parameters.m_positional.size());
@@ -563,10 +644,11 @@ void Machine::apply_klammer_registry(
katoms.insert(end, applied_katoms.begin(), applied_katoms.end());
}
void Machine::apply(
int Machine::apply(
Klammer_registry& klammer_registry, katom_list& katoms, const std::string& target)
{
(void)K::log(3, "Klammer_registry");
int applied = 0;
for (const auto& [op, cl] : find_spans(
katoms, begin_klammer_apply, end_klammer_apply, true, command_name)) {
auto [begin, end] = find_span_katoms(katoms, op, cl);
@@ -574,7 +656,9 @@ void Machine::apply(
klammer_name_from_katom(begin->m_text, begin->m_loc),
target, begin->m_loc);
apply_klammer_registry(klammer_registry, katoms, target, begin, end);
++applied;
}
return applied;
}
std::string Machine::run_phase_functions()
@@ -622,7 +706,6 @@ void Machine::escape_target_characters(const Target& target, katom_list& katoms)
std::string Machine::apply(const std::string& target_name, bool final_processing, bool escape_characters)
{
(void)K::log(3, "top level");
int recursive_limit = 5;
m_state.set("K_target", target_name);
m_state.subst(m_katoms.begin(), m_katoms.end());
@@ -634,20 +717,26 @@ std::string Machine::apply(const std::string& target_name, bool final_processing
if (escape_characters)
escape_target_characters(target, m_katoms);
// Reduce to a fixed point. A pass reports how many klammers it applied;
// the loop ends when a pass applies none. (It formerly ended when the
// katom list stopped GROWING, which is not the same thing: a klammer whose
// body expands to nothing is a reduction that adds no katoms.) Exceeding
// the round limit is now an error rather than a message followed by
// rendering the unreduced document -- silently emitting a document with
// live klammers still in it is worse than not emitting one. Runaway
// recursion is caught earlier and more precisely by the depth guard in
// apply_klammer(); this limit only bounds the number of ROUNDS, which is
// the length of a chain of klammers that generate further klammers.
int apply_count = 0;
auto katom_size = m_katoms.size();
while (true) {
apply(m_klammers, m_katoms, target_name);
if (m_katoms.size() == katom_size) {
break;
while (apply(m_klammers, m_katoms, target_name) > 0) {
if (++apply_count > apply_round_limit) {
std::stringstream ss {};
ss << "Klammer application did not reach a fixed point after "
<< apply_round_limit << " rounds.\n"
<< "Each round applies every klammer present; a klammer whose "
<< "result contains further klammers starts another round.";
throw Recursion_error(ss.str(), Locator(), false);
}
if (++apply_count > recursive_limit) {
msg() << red << "Error: Recursive limit ("
<< recursive_limit << ") reached\n" << black;
break;
}
katom_size = m_katoms.size();
}
m_result = to_string(m_katoms.begin(), m_katoms.end());