From 02f7c1d575bc210ca4f38c67cb4f1c94f15eeae6 Mon Sep 17 00:00:00 2001 From: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:26:31 +0200 Subject: [PATCH 1/2] test(autoload): reproduce rubix free-function autoload collision (#1113) Add a regression test that reproduces #1113 in a fresh subprocess: it simulates another app (e.g. recognize, which ships a php-scoped copy of rubix) winning Composer's "files" autoload dedupe race by pre-marking rubix's file identifiers as loaded, then asserts that sigmoid activation - which relies on the string callable 'Rubix\ML\sigmoid' - still works. Without the fix the un-scoped rubix functions.php is skipped, so 'Rubix\ML\sigmoid' is never defined and inference crashes with "Tensor\Matrix::map(): Argument #1 ($callback) must be of type callable, string given". This test currently fails on purpose to demonstrate the bug; the fix follows in a separate commit. Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com> --- tests/Unit/AppInfo/ApplicationTest.php | 36 ++++++++++++++ tests/Unit/AppInfo/rubix-collision-repro.php | 50 ++++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 tests/Unit/AppInfo/ApplicationTest.php create mode 100644 tests/Unit/AppInfo/rubix-collision-repro.php diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php new file mode 100644 index 00000000..cce90401 --- /dev/null +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -0,0 +1,36 @@ +&1', $output, $exitCode); + + $message = implode("\n", $output); + self::assertNotSame(2, $exitCode, "Collision could not be reproduced, the test is stale:\n$message"); + self::assertSame(0, $exitCode, "Sigmoid activation failed despite RubixBootstrap.php:\n$message"); + } +} diff --git a/tests/Unit/AppInfo/rubix-collision-repro.php b/tests/Unit/AppInfo/rubix-collision-repro.php new file mode 100644 index 00000000..a8d0444a --- /dev/null +++ b/tests/Unit/AppInfo/rubix-collision-repro.php @@ -0,0 +1,50 @@ +:") and are therefore identical in every install, +// including the scoped copy shipped by other apps. +$GLOBALS['__composer_autoload_files']['0315e8fd3e479309d097647b8ef2920b'] = true; // rubix/ml src/functions.php +$GLOBALS['__composer_autoload_files']['702239352e6628be5dc71b6fd029e72e'] = true; // rubix/ml src/constants.php +$GLOBALS['__composer_autoload_files']['8f758069bf9eb3411d096c10be343745'] = true; // rubix/tensor src/constants.php + +require $appRoot . '/vendor/autoload.php'; + +// Guard: with the flags pre-set, our files loop must have skipped functions.php. +// If the function is already defined here the identifiers drifted and this test +// no longer reproduces the bug, so fail loudly instead of passing silently. +if (function_exists('Rubix\ML\sigmoid')) { + fwrite(STDERR, "collision not reproduced: Rubix\\ML\\sigmoid already defined\n"); + exit(2); +} + +// Exercise the exact path from the crash report. Without the fix this throws +// the TypeError from #1113 because 'Rubix\ML\sigmoid' no longer resolves. +$result = (new Rubix\ML\NeuralNet\ActivationFunctions\Sigmoid()) + ->activate(Tensor\Matrix::quick([[0.5, -1.0], [2.0, 0.0]])); + +exit($result instanceof Tensor\Matrix ? 0 : 3); From e4d55600206e5a78d72a1096cdb10b860887a8d7 Mon Sep 17 00:00:00 2001 From: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com> Date: Mon, 29 Jun 2026 10:46:29 +0200 Subject: [PATCH 2/2] fix(autoload): define rubix free functions only when missing (#1113) rubix/ml and rubix/tensor expose free functions and constants through Composer's "files" autoloader, which dedupes process-wide on a content-blind identifier md5(":"). Several apps bundle rubix and register the same identifier, so only the first to boot loads its copy: - recognize php-scopes rubix, so when it wins only prefixed symbols exist and our un-scoped Rubix\ML\sigmoid is missing -> login crashes with "Tensor\Matrix::map(): Argument #1 ($callback) must be of type callable"; - mail ships rubix un-scoped, so it defines the same Rubix\ML\* symbols we do. Load our rubix files from Application::register() via RubixBootstrap.php, but only when the symbols are actually missing, guarding on a representative symbol per file. Requiring unconditionally would fatal with "Cannot redeclare function Rubix\ML\argmin()" whenever another un-scoped copy already loaded them (require_once dedupes by realpath, not by symbol). Fixes #1113 Assisted-by: ClaudeCode:claude-opus-4-8 Signed-off-by: Christoph Wurst <1374172+ChristophWurst@users.noreply.github.com> --- lib/AppInfo/Application.php | 3 ++ lib/RubixBootstrap.php | 44 ++++++++++++++++++++ psalm.xml | 4 ++ tests/Unit/AppInfo/ApplicationTest.php | 16 +++++++ tests/Unit/AppInfo/rubix-collision-repro.php | 6 ++- tests/Unit/AppInfo/rubix-redeclare-repro.php | 42 +++++++++++++++++++ 6 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 lib/RubixBootstrap.php create mode 100644 tests/Unit/AppInfo/rubix-redeclare-repro.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index e86da5cd..37f9048e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -30,6 +30,9 @@ public function __construct(array $urlParams = []) { #[\Override] public function register(IRegistrationContext $context): void { include_once __DIR__ . '/../../vendor/autoload.php'; + // Make sure rubix's free functions and constants are defined regardless of + // Composer's "files" autoloader dedupe (see the file for details). + require_once __DIR__ . '/../RubixBootstrap.php'; $context->registerEventListener(SuspiciousLoginEvent::class, LoginNotificationListener::class); $context->registerEventListener(SuspiciousLoginEvent::class, LoginMailListener::class); diff --git a/lib/RubixBootstrap.php b/lib/RubixBootstrap.php new file mode 100644 index 00000000..a72644d0 --- /dev/null +++ b/lib/RubixBootstrap.php @@ -0,0 +1,44 @@ +:"), + * stored process-wide in $GLOBALS['__composer_autoload_files']. Several apps + * bundle rubix and register the very same identifier, so only the first app to + * boot actually loads its copy; every other app's files loop is skipped. The + * catch is that those copies are not interchangeable: + * + * - an app that php-scopes rubix (e.g. recognize) only defines prefixed + * symbols such as OCA\Recognize\Vendor\Rubix\ML\sigmoid, so when it wins the + * race our un-scoped Rubix\ML\sigmoid is never defined and inference crashes + * with "Tensor\Matrix::map(): Argument #1 ($callback) must be of type + * callable, string given" (issue #1113); + * - an app that ships rubix un-scoped (e.g. mail) defines the very same + * Rubix\ML\* symbols we do, from a different path. + * + * So we must make sure our symbols exist, but only load our copy when they are + * actually missing: blindly requiring the files would fatal with "Cannot + * redeclare function Rubix\ML\argmin()" whenever another un-scoped copy already + * loaded them. require_once is not enough here - it dedupes by realpath, not by + * symbol, so it does not protect against another app's copy. Guard on a + * representative symbol from each file instead. + */ +if (!function_exists('Rubix\ML\sigmoid')) { + require_once __DIR__ . '/../vendor/rubix/ml/src/functions.php'; +} +if (!defined('Rubix\ML\HALF_PI')) { + require_once __DIR__ . '/../vendor/rubix/ml/src/constants.php'; +} +if (!defined('Tensor\EPSILON')) { + require_once __DIR__ . '/../vendor/rubix/tensor/src/constants.php'; +} diff --git a/psalm.xml b/psalm.xml index f33e5d8c..76ffb877 100644 --- a/psalm.xml +++ b/psalm.xml @@ -21,6 +21,10 @@ + + + diff --git a/tests/Unit/AppInfo/ApplicationTest.php b/tests/Unit/AppInfo/ApplicationTest.php index cce90401..a10166f3 100644 --- a/tests/Unit/AppInfo/ApplicationTest.php +++ b/tests/Unit/AppInfo/ApplicationTest.php @@ -33,4 +33,20 @@ public function testRubixFunctionsSurviveAutoloadDedupeCollision(): void { self::assertNotSame(2, $exitCode, "Collision could not be reproduced, the test is stale:\n$message"); self::assertSame(0, $exitCode, "Sigmoid activation failed despite RubixBootstrap.php:\n$message"); } + + /** + * Counterpart to the test above: when another app ships rubix un-scoped and + * has already defined the Rubix\ML\* symbols, RubixBootstrap.php must not + * reload its own copy - that would fatal with "Cannot redeclare function + * Rubix\ML\argmin()". Reproduced in a subprocess for a clean symbol table. + */ + public function testRubixBootstrapDoesNotRedeclareExistingSymbols(): void { + $script = __DIR__ . '/rubix-redeclare-repro.php'; + + $output = []; + $exitCode = -1; + exec(escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($script) . ' 2>&1', $output, $exitCode); + + self::assertSame(0, $exitCode, "RubixBootstrap.php redeclared rubix symbols that already existed:\n" . implode("\n", $output)); + } } diff --git a/tests/Unit/AppInfo/rubix-collision-repro.php b/tests/Unit/AppInfo/rubix-collision-repro.php index a8d0444a..048e5d0d 100644 --- a/tests/Unit/AppInfo/rubix-collision-repro.php +++ b/tests/Unit/AppInfo/rubix-collision-repro.php @@ -42,8 +42,10 @@ exit(2); } -// Exercise the exact path from the crash report. Without the fix this throws -// the TypeError from #1113 because 'Rubix\ML\sigmoid' no longer resolves. +// The fix: explicitly load our own copies regardless of the dedupe flag. +require_once $appRoot . '/lib/RubixBootstrap.php'; + +// Exercise the exact path from the crash report. $result = (new Rubix\ML\NeuralNet\ActivationFunctions\Sigmoid()) ->activate(Tensor\Matrix::quick([[0.5, -1.0], [2.0, 0.0]])); diff --git a/tests/Unit/AppInfo/rubix-redeclare-repro.php b/tests/Unit/AppInfo/rubix-redeclare-repro.php new file mode 100644 index 00000000..f5b6def1 --- /dev/null +++ b/tests/Unit/AppInfo/rubix-redeclare-repro.php @@ -0,0 +1,42 @@ +