Fix plugin settings lost on cloud metadata refresh

Cloud catalog records never carry [tool.orcaslicer.plugin.settings], so the
metadata merge wiped the locally-parsed settings and plugins silently ran on
their built-in defaults (ctx.params arrived empty).
This commit is contained in:
SoftFever
2026-07-17 02:09:34 +08:00
parent 0a0d59b76b
commit 1c9fda463b
4 changed files with 302 additions and 0 deletions

View File

@@ -157,6 +157,12 @@ inline void apply_plugin_metadata_fallbacks(PluginDescriptor& target, const Plug
target.entry_package = fallback.entry_package;
if (target.dependencies.empty())
target.dependencies = fallback.dependencies;
// [tool.orcaslicer.plugin.settings] lives only in the local package's PEP-723 header;
// cloud catalog records never carry it. Without this, every cloud-metadata merge wipes
// the parsed settings and plugins silently run on their built-in defaults (ctx.params
// arrives empty).
if (target.settings.empty())
target.settings = fallback.settings;
}
// Sanitize a value for use as a filesystem name and as a local plugin_key:

View File

@@ -5,7 +5,10 @@ add_executable(${_TEST_NAME}_tests
test_plugin_install.cpp
test_plugin_lifecycle.cpp
test_slicing_pipeline_bindings.cpp
test_slicing_pipeline_params.cpp
test_plugin_sort.cpp
test_plugin_cloud_metadata.cpp
../fff_print/test_helpers.cpp
)
if (MSVC)

View File

@@ -0,0 +1,133 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginFsUtils.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include <boost/filesystem.hpp>
#include <fstream>
#include <string>
using namespace Slic3r;
namespace fs = boost::filesystem;
namespace {
// Point data_dir() at a throwaway directory for the lifetime of a test and restore the previous
// value afterwards (same pattern as test_plugin_lifecycle.cpp).
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
dir = fs::temp_directory_path() / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
fs::path plugins_dir() const { return dir / "orca_plugins"; }
};
// Shut both singletons down while boost::log is still alive; left to their static
// destructors, shutdown()'s logging runs after boost::log tears down its thread-local
// storage and crashes the process on exit (same reason ScopedPluginManager exists in
// test_plugin_lifecycle.cpp). No initialize() needed: discovery and the cloud-metadata
// merge never touch Python, but manager shutdown instantiates the interpreter singleton.
struct ScopedManagerShutdown
{
~ScopedManagerShutdown()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
// A plugin whose per-plugin settings live in the PEP-723 header — the only place they exist.
const char* const SETTINGS_PLUGIN_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Settings Cloud Plugin"
# type = "slicing-pipeline"
# version = "1.0"
#
# [tool.orcaslicer.plugin.settings]
# twist_deg_per_mm = "1.0"
# taper_per_mm = "0.0"
# ///
print('ok')
)PY";
} // namespace
// Regression: update_cloud_metadata() replaces a matched entry's descriptor with the cloud
// catalog record. Cloud records never carry [tool.orcaslicer.plugin.settings] (it exists only
// in the local package's PEP-723 header, parsed at discovery), so the merge must preserve the
// locally-parsed settings. When it does not, get_plugin_settings() serves an empty map and
// plugins silently fall back to their built-in defaults (found via Twistify running with its
// demo defaults instead of the header values, 2026-07-17).
TEST_CASE("cloud metadata refresh preserves locally-parsed plugin settings", "[PluginCloudMetadata]")
{
ScopedManagerShutdown manager_shutdown_guard; // declared first: destroyed last
ScopedDataDir data_dir_guard("cloud-meta-settings");
// A locally-installed cloud plugin: package .py with a settings header, plus an
// install-state sidecar carrying the cloud identity.
const std::string uuid = "11111111-2222-3333-4444-555555555555";
const fs::path plugin_dir = data_dir_guard.plugins_dir() / uuid;
fs::create_directories(plugin_dir);
{
std::ofstream out((plugin_dir / "cloud_plugin-test.py").string(), std::ios::binary);
out << SETTINGS_PLUGIN_SOURCE;
}
PluginDescriptor sidecar;
sidecar.name = "Settings Cloud Plugin";
sidecar.installed_version = "1.0";
sidecar.cloud = CloudPluginState{uuid, true, false, false, false};
REQUIRE(write_install_state(plugin_dir, sidecar));
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
const auto find_by_uuid = [&manager, &uuid]() -> PluginDescriptor {
for (const PluginDescriptor& d : manager.get_plugin_descriptors(/*include_invalid=*/true))
if (d.cloud_uuid() == uuid)
return d;
return {};
};
// Discovery parsed the header settings (premise).
PluginDescriptor discovered = find_by_uuid();
REQUIRE(discovered.settings.count("twist_deg_per_mm") == 1);
CHECK(discovered.settings.at("twist_deg_per_mm") == "1.0");
// A cloud catalog refresh for the same plugin: the record knows name/version/uuid but has
// no settings, no local paths.
PluginDescriptor cloud_record;
cloud_record.name = "Settings Cloud Plugin";
cloud_record.plugin_key = uuid;
cloud_record.version = "1.1";
cloud_record.cloud = CloudPluginState{uuid, false, false, false, false};
manager.update_cloud_metadata({cloud_record});
const PluginDescriptor refreshed = find_by_uuid();
// Cloud metadata landed...
CHECK(refreshed.version == "1.1");
// ...and the locally-parsed settings survived the merge.
REQUIRE(refreshed.settings.count("twist_deg_per_mm") == 1);
CHECK(refreshed.settings.at("twist_deg_per_mm") == "1.0");
CHECK(refreshed.settings.count("taper_per_mm") == 1);
}

View File

@@ -0,0 +1,160 @@
#include <catch2/catch_all.hpp>
#include <libslic3r/Utils.hpp>
#include <slic3r/plugin/PluginDescriptor.hpp>
#include <slic3r/plugin/PluginManager.hpp>
#include <slic3r/plugin/PythonInterpreter.hpp>
#include "fff_print/test_helpers.hpp"
#include <boost/filesystem.hpp>
#include <pybind11/embed.h>
#include <fstream>
#include <string>
using namespace Slic3r;
using namespace Slic3r::Test;
namespace fs = boost::filesystem;
// End-to-end coverage of ctx.params for slicing-pipeline capabilities: discovery parses
// [tool.orcaslicer.plugin.settings] from the PEP-723 header, and the real dispatch
// (execute_capabilities_from_refs -> hook -> GIL -> trampoline) hands it to the plugin.
// A break anywhere in that chain makes plugins silently run on their built-in defaults,
// which is invisible to the plugin author (Twistify incident, 2026-07-17).
namespace {
struct ScopedDataDir
{
std::string previous;
fs::path dir;
explicit ScopedDataDir(const std::string& tag)
{
previous = data_dir();
// canonical(): the plugin audit canonicalizes its allowed roots, so a path through
// the macOS /var -> /private/var symlink would be rejected as "outside allowed root".
dir = fs::canonical(fs::temp_directory_path()) / fs::unique_path("orca-" + tag + "-%%%%-%%%%");
fs::create_directories(dir);
set_data_dir(dir.string());
}
~ScopedDataDir()
{
set_data_dir(previous);
boost::system::error_code ec;
fs::remove_all(dir, ec);
}
fs::path plugins_dir() const { return dir / "orca_plugins"; }
};
struct ScopedPluginManager
{
bool initialized = false;
ScopedPluginManager() { initialized = PluginManager::instance().initialize(); }
~ScopedPluginManager()
{
PluginManager::instance().shutdown();
PythonInterpreter::instance().shutdown();
}
};
const char* const PARAM_PROBE_SOURCE = R"PY(# /// script
# requires-python = ">=3.12"
#
# [tool.orcaslicer.plugin]
# name = "Param Probe"
# description = "Echoes ctx.params back to the test"
# author = "OrcaSlicer"
# version = "1.0"
# type = "slicing-pipeline"
#
# [tool.orcaslicer.plugin.settings]
# alpha = "1.25"
# beta = "hello"
# ///
import orca
class ParamEcho(orca.slicing.SlicingPipelineCapabilityBase):
def get_name(self):
return "ParamEcho"
def execute(self, ctx):
if ctx.step != orca.slicing.Step.posSlice or ctx.object is None:
return orca.ExecutionResult.success()
try:
text = repr(sorted(dict(ctx.params).items()))
except Exception as e: # what plugins' defaults-fallback code swallows silently
text = "params-error: " + repr(e)
orca._probe_params = text # read back by the test through pybind
return orca.ExecutionResult.success("params probed")
@orca.plugin
class ParamProbePackage(orca.base):
def register_capabilities(self):
orca.register_capability(ParamEcho)
)PY";
fs::path write_plugin(const ScopedDataDir& data_dir_guard, const std::string& stem, const std::string& source)
{
const fs::path plugin_dir = data_dir_guard.plugins_dir() / stem;
fs::create_directories(plugin_dir);
std::ofstream out((plugin_dir / (stem + ".py")).string(), std::ios::binary);
out << source;
out.close();
return plugin_dir;
}
} // namespace
TEST_CASE("slicing-pipeline dispatch delivers PEP-723 settings as ctx.params", "[slicing_pipeline][Python]")
{
ScopedPluginManager plugin_system;
if (!plugin_system.initialized)
SKIP("Bundled Python interpreter unavailable: " + PythonInterpreter::instance().last_error());
ScopedDataDir data_dir_guard("pipeline-params");
write_plugin(data_dir_guard, "ParamProbe", PARAM_PROBE_SOURCE);
PluginManager& manager = PluginManager::instance();
manager.discover_plugins(/*async=*/false, /*clear=*/true);
std::string error;
manager.load_plugin("ParamProbe", /*skip_deps=*/true, {});
REQUIRE(manager.wait_for_plugin_load("ParamProbe", std::chrono::seconds(120), error));
INFO("load error: " << error);
REQUIRE(manager.is_plugin_loaded("ParamProbe"));
// The manager serves the header settings for the key the dispatch resolves by.
const auto settings = manager.get_plugin_settings("ParamProbe");
REQUIRE(settings.count("alpha") == 1);
CHECK(settings.at("alpha") == "1.25");
// Slice with the capability selected, exactly as a preset would reference it.
Print print;
Model model;
auto config = DynamicPrintConfig::full_print_config();
config.set_key_value("slicing_pipeline_plugin", new ConfigOptionStrings({"ParamEcho"}));
config.set_key_value("plugins", new ConfigOptionStrings({"ParamProbe;;ParamEcho"}));
init_print({cube(20)}, print, model, config);
print.process();
std::string observed = "<capability never executed>";
{
PythonGILState gil;
REQUIRE(static_cast<bool>(gil));
pybind11::module_ orca = pybind11::module_::import("orca");
if (pybind11::hasattr(orca, "_probe_params"))
observed = orca.attr("_probe_params").cast<std::string>();
}
INFO("ctx.params observed by Python: " << observed);
CHECK(observed.find("'alpha', '1.25'") != std::string::npos);
CHECK(observed.find("'beta', 'hello'") != std::string::npos);
manager.unload_plugin("ParamProbe");
}