Skip to content

fix(rpn): correct the expression evaluator against rrdtool semantics - #788

Merged
somethingwithproof merged 7 commits into
Cacti:developfrom
somethingwithproof:fix/rpn-evaluator
Aug 18, 2026
Merged

fix(rpn): correct the expression evaluator against rrdtool semantics#788
somethingwithproof merged 7 commits into
Cacti:developfrom
somethingwithproof:fix/rpn-evaluator

Conversation

@somethingwithproof

Copy link
Copy Markdown
Member

Fixes #782.

Each defect below was confirmed by running the function, not by reading it. The tests were written first and fail on develop.

Stack and set operators

EXC and REV were both no-ops. Popping a span already reverses it, and both then reversed it back:

// EXC, before
$v1 = pop();  // top
$v2 = pop();  // next
push($v2); push($v1);   // original order restored

[1,2] EXC returned [1,2]; rrdtool gives [2,1]. An expression using either operator to order operands before a non-commutative operator computed the wrong comparison, silently.

AVG raised an uncaught TypeError: Unsupported operand types when 'U' or 'NAN' reached $total += $v. That is a fatal in the poller process, so every threshold after it in the run went unevaluated. It now follows rrdtool: unknown samples are skipped, the average is over the known ones, and an all-unknown span yields UNKN.

Both operators now also stop on stack underflow rather than pushing false back and carrying on.

Expression result

thold_calculate_expression() ended with return $stack[0] — the bottom of the stack. rrdtool yields the top. For a well-formed expression these are the same element, so this only bit when the stack was unbalanced, which the EXC/REV bugs made more likely. 1,2,3,+ returned 1 instead of 5.

It now returns the top, and an expression that does not reduce to a single value is reported as the authoring error it is rather than quietly returning an operand.

Missing dispatch entry

$spectypes = ['CURRENT_DATA_SOURCE', 'CURRENT_GRAPH_MINIMUM_VALUE',
    'CURRENT_GRAPH_MINIMUM_VALUE', ...];

The minimum appeared twice and CURRENT_GRAPH_MAXIMUM_VALUE was absent, although thold_expression_specialtype_rpn() handles it. Expressions using it fell through to Unsupported Field, set $rpn_error, and returned 0 — so a low threshold on such an expression alerted permanently.

CDEF division and modulo by zero

thold_rpn() guarded division but not modulo, so % by zero was fatal on PHP 8. The division guard returned -1, a plausible-looking number that is then compared against the bounds; the function already uses '' as its sentinel for operands it cannot use, so both now do that.

Also

$data_sources = $rrd_reindexed[$thold['local_data_id']] was an unguarded array read that produced null for a data source with no readings this cycle, then a TypeError from array_key_exists(). It now defaults, and the no-op element-by-element copy that followed it is gone.

Tests

43 tests covering the stack, set and CDEF operators plus end-to-end expression evaluation. Every changed line is covered.

The harness (tests/, composer.json, phpunit.xml, the unit-test workflow) is the same one added in #773, byte-identical, so whichever lands first the other merges cleanly. If #773 goes in first this PR's diff shrinks to the thold_functions.php changes and its own test files.

PHP version

The new code uses PHP 8 idioms (typed signatures, ??, short array syntax) to match the 8.1–8.4 CI matrix and Cacti 1.2.31's "php": ">=8.0". Worth noting that the plugin contained no PHP 8-only syntax before this, and INFO still declares compat = 1.2.25 — if running under an older Cacti on PHP 7 is meant to be supported, say so and I will keep these functions 7.4-compatible.

@somethingwithproof

Copy link
Copy Markdown
Member Author

The four Integration Test failures are an upstream break, not this PR. The workflow checks out Cacti/cacti unpinned, and Install Cacti via CLI dies in core:

PHP Fatal error: Uncaught Error: Call to undefined function __()
  in cacti/lib/functions.php:7973

That is format_cacti_version_text() calling __() before the translation layer is loaded, on Cacti develop at 96033267c. Nothing in this PR touches Cacti core.

#776 pins the checkout to release/1.2.31 and its integration run is green, which is the same failure mode it was opened for. The PHPUnit job here passes.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR corrects several RPN-expression evaluation behaviors in thold_functions.php to better match RRDtool semantics (stack/set operators, special token dispatch, expression result selection, and safer CDEF division/modulo handling). It also introduces a PHPUnit-based unit-test harness with Docker + GitHub Actions CI to enforce changed-line coverage for the evaluator.

Changes:

  • Fix stack/set operator behavior (e.g., EXC, REV, AVG) and make expression evaluation return the top-of-stack while rejecting unbalanced expressions.
  • Fix special-token dispatch for CURRENT_GRAPH_MAXIMUM_VALUE and harden expression evaluation when a data source has no readings in the current cycle.
  • Add PHPUnit test suite + Docker runner + GitHub Actions workflow, including a “patch coverage” gate for changed PHP lines.

Reviewed changes

Copilot reviewed 14 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
thold_functions.php Corrects RPN operator semantics, fixes special-token dispatch, hardens expression/CDEF evaluation edge cases.
tests/Unit/TholdRpnCdefTest.php Adds unit coverage for CDEF arithmetic, including division/modulo by zero behavior.
tests/Unit/TholdExpressionStackOpsTest.php Adds unit coverage for stack + set operators against RRDtool-like semantics.
tests/Unit/TholdCalculateExpressionTest.php Adds end-to-end coverage for expression evaluation and special-token dispatch.
tests/TestCase.php Defines shared PHPUnit base test case and plugin source loader helper.
tests/Support/CactiStub.php Adds a recording stub for Cacti framework globals used by the plugin.
tests/fixtures/optional-core-functions.php Adds optional-core function fallbacks to exercise function_exists() branches in isolation.
tests/fixtures/cacti-lib/variables.php Provides a minimal file to satisfy runtime includes during tests.
tests/docker/Dockerfile Provides a reproducible PHP 8.1 + pcov test runner image.
tests/docker/docker-compose.yml Adds a local docker-compose entrypoint mirroring CI.
tests/bootstrap.php Boots the test environment and defines Cacti-function shims.
tests/bin/patch-coverage.php Implements changed-line coverage enforcement using git diff + Clover.
phpunit.xml Configures PHPUnit and scopes coverage to thold_functions.php.
composer.json Adds PHPUnit dependency and test scripts.
.gitignore Ignores Composer/vendor and PHPUnit/coverage artifacts.
.github/workflows/php-unit-tests.yml Adds CI job to run tests in Docker and enforce patch coverage on PRs.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread thold_functions.php

@TheWitness TheWitness left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No composer.jaon in plugins.

@somethingwithproof
somethingwithproof force-pushed the fix/rpn-evaluator branch 2 times, most recently from 87c1ed7 to 17ce93a Compare August 17, 2026 06:19
somethingwithproof added a commit to somethingwithproof/plugin_thold that referenced this pull request Aug 17, 2026
Same harness as Cacti#773 and Cacti#788, so whichever lands first the others merge cleanly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
somethingwithproof added a commit to somethingwithproof/plugin_thold that referenced this pull request Aug 17, 2026
Same harness as Cacti#773 and Cacti#788, with gmp added to the image so the 64-bit
counter arithmetic can be tested exactly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
somethingwithproof added a commit to somethingwithproof/plugin_thold that referenced this pull request Aug 17, 2026
Same harness as Cacti#773 and Cacti#788, with gmp added to the image so the 64-bit
counter arithmetic can be tested exactly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
somethingwithproof added a commit to somethingwithproof/plugin_thold that referenced this pull request Aug 17, 2026
Same harness as Cacti#773 and Cacti#788, so whichever lands first the others merge cleanly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
EXC and REV were both no-ops, because popping already reverses the span and
the code then reversed it back. AVG raised an uncaught TypeError when an
unknown sample reached its running total, killing the poller mid-cycle; it now
skips unknowns and yields UNKN when every sample is unknown, as rrdtool does.

Refs Cacti#782

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
…token

CURRENT_GRAPH_MAXIMUM_VALUE was absent from the dispatch list while
CURRENT_GRAPH_MINIMUM_VALUE appeared twice, so every expression using it fell
through to Unsupported Field and evaluated to zero.

The result was then read from the bottom of the stack rather than the top, so
an unbalanced expression silently returned its first operand instead of
reporting the authoring error.

Refs Cacti#782

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
… zero

Modulo by zero was unguarded and fatal on PHP 8. Division by zero returned -1,
which the caller compares against the threshold bounds as though it were a
reading; the function already uses an empty string for operands it cannot use.

Refs Cacti#782

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers
for the stubs, a phpunit.xml carrying error_reporting -1 and
CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI
runs the same commands a developer does. The dev toolchain is Cacti's, pinned
to the same platform php 8.1.0.

Cacti core runs Pest and this suite does not, because pest ^2 does not resolve
on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to
v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is
blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve,
requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this
plugin's CI matrix targets. The tests are written in the plain PHPUnit class
style that Cacti's tests/Pest.php explicitly supports, so they run unchanged
under Pest wherever it is installable.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
@somethingwithproof

Copy link
Copy Markdown
Member Author

Review follow-up: TheWitness feedback is addressed—the plugin diff contains no composer.json, composer.lock, or vendor directory, and tests run through Cacti’s Composer-managed Pest toolchain. Copilot’s fractional set-count finding is fixed with a regression test, and its review thread is resolved. Current CI is rerunning on the updated branch.

TheWitness pushed a commit that referenced this pull request Aug 18, 2026
…or (#791)

* test: add the PHP 8.1 unit-test harness

Same harness as #773 and #788, with gmp added to the image so the 64-bit
counter arithmetic can be tested exactly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(thold): correct the counter delta and the percent denominator

A previous counter reading of exactly zero was treated as no reading at all,
so the first interval after a device reboot reported a rate of zero. The wrap
modulus was 2^32-1 and 2^64-1 rather than 2^32 and 2^64, losing one count per
wrap, and the 64-bit literal exceeded PHP_INT_MAX so it was parsed as a float
and lost about eleven bits before the subtraction.

The percent-of denominator was cast to int, so a denominator below one
truncated to zero and forced the result to zero, keeping any configured low
threshold in permanent breach.

Refs #785

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(daemon): store the previous reading in oldvalue, not a timestamp

When a data source produced no sample this cycle the daemon wrote
$currenttime - $rrd_step into oldvalue, so the next poll computed a delta
against a Unix timestamp, took the overflow branch and fabricated a rate in
the billions. The non-daemon path already carries the previous oldvalue
forward; this matches it.

Refs #785

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: follow Cacti's test layout and composer scripts

Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers
for the stubs, a phpunit.xml carrying error_reporting -1 and
CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI
runs the same commands a developer does. The dev toolchain is Cacti's, pinned
to the same platform php 8.1.0.

Cacti core runs Pest and this suite does not, because pest ^2 does not resolve
on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to
v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is
blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve,
requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this
plugin's CI matrix targets. The tests are written in the plain PHPUnit class
style that Cacti's tests/Pest.php explicitly supports, so they run unchanged
under Pest wherever it is installable.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: keep plugin PR integration checks on pinned Cacti

* fix(counter): handle non-integer 64-bit readings safely

* ci: bound package index refreshes

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
TheWitness pushed a commit that referenced this pull request Aug 18, 2026
…a source (#790)

* test: add the PHP 8.1 unit-test harness

Same harness as #773 and #788, so whichever lands first the others merge cleanly.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(thold): keep a zero reading through tag substitution

thold_str_replace() treated 0 and '0' as absent, so an alert for a value that
had dropped to zero rendered as "Current value is " with a blank, and a
trigger command invoked as --value <CURRENTVALUE> lost the argument and
shifted the ones after it.

Refs #787

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(thold): return zero when the requested data source is absent

array_search() reports a miss as false, so a guard written against null let it
through and $result['values'][false] read index 0. A lookup for a data source
that does not exist returned the first one's value, which the caller then
compared against the threshold bounds.

Reached today from thold_expression_specialtype_rpn() and the CDEF
substitutions, which pass column names such as upper_limit rather than data
source names. Those call sites still need to read the real column; this only
stops them silently receiving another metric.

Refs #787

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* test: follow Cacti's test layout and composer scripts

Adopts the conventions from Cacti core: tests/bootstrap-unit.php, tests/Helpers
for the stubs, a phpunit.xml carrying error_reporting -1 and
CACTI_TEST_BOOTSTRAP, and composer lint / test / test:coverage scripts so CI
runs the same commands a developer does. The dev toolchain is Cacti's, pinned
to the same platform php 8.1.0.

Cacti core runs Pest and this suite does not, because pest ^2 does not resolve
on PHP 8.1 -- the platform Cacti's own composer.json pins. Releases up to
v2.36.0 conflict with phpunit 10.5.62 and later, every earlier 10.x release is
blocked by advisory PKSA-z3gr-8qht-p93v, and v2.36.1, which does resolve,
requires PHP 8.2. The stack installs on 8.2 and above; 8.1 is the floor this
plugin's CI matrix targets. The tests are written in the plain PHPUnit class
style that Cacti's tests/Pest.php explicitly supports, so they run unchanged
under Pest wherever it is installable.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* ci: keep plugin PR integration checks on pinned Cacti

* test: match the optional Cacti database stub signature

* ci: bound package index refreshes

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
@somethingwithproof
somethingwithproof merged commit c5c6940 into Cacti:develop Aug 18, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

RPN expression evaluator: EXC/REV are no-ops, AVG is fatal on unknown, result read from stack bottom

3 participants