From 7c16c1f0d341e1d78d93ab1892b90ee9ff5acdb2 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Mon, 15 Jun 2026 07:59:34 +0200 Subject: [PATCH 1/5] halion in worker --- CMakeLists.txt | 1 + DEVELOPMENT.md | 11 +- README.md | 4 +- cli/Main.cpp | 32 ++++- converters/sfz/src/SfzConverter.cpp | 2 +- include/halionbridge/Bridge.h | 4 + src/Bridge.cpp | 175 +++++++++++++++++++++++ src/BuildWorker.cpp | 209 ++++++++++++++++++++++++++++ src/BuildWorker.h | 22 +++ src/ChildProcessOutput.cpp | 32 +++++ src/ChildProcessOutput.h | 2 + tests/Tests.cpp | 108 +++++++++++++- 12 files changed, 590 insertions(+), 12 deletions(-) create mode 100644 src/BuildWorker.cpp create mode 100644 src/BuildWorker.h diff --git a/CMakeLists.txt b/CMakeLists.txt index d823480..ffda59a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -94,6 +94,7 @@ set(HALIONBRIDGE_DIAGNOSTIC_DEFINITIONS set(HALIONBRIDGE_LIBRARY_SOURCES src/Bridge.cpp + src/BuildWorker.cpp src/ChildProcessOutput.cpp src/CrashDiagnostics.cpp src/Log.cpp diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 99b8294..104510d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -198,15 +198,16 @@ Private or release-only converters are build-time drop-ins. A private converter - `converters/private`: Gitignored location for optional private converter drop-ins used by local or release-only builds. - `include/halionbridge/Bridge.h`: JUCE-free public library API for command-line option parsing, HALion plugin location, VST preset inspection, runtime module generation, status marker paths, and bridge execution. `Bridge::getDefaultHalionPluginPath()` reports the platform default (`C:\Program Files\Common Files\VST3\Steinberg\HALion 7.vst3` on Windows, `/Library/Audio/Plug-Ins/VST3/Steinberg/HALion 7.vst3` on macOS), and `Bridge::findHalionPlugin()` applies the optional `--plugin` override before checking that default path exists. `Bridge::runDetailed()` returns a `RunResult` for embedding applications; `Bridge::run()` remains the bool convenience wrapper. A moved-from `Bridge` returns `RunResult::invalidBridge` so API misuse is distinguishable from invalid user options. - `include/halionbridge/CrashDiagnostics.h`: JUCE-free public crash-diagnostics entry points used by the CLI before JUCE/HALion startup. -- `src/Bridge.cpp`: Core bridge orchestration for preparing the embedded bootstrap preset, hosting the HALion 7 VST3 format, applying preset data, and driving the processing loop. +- `src/Bridge.cpp`: Core bridge orchestration for preparing the embedded bootstrap preset, supervising headless build workers, hosting the HALion 7 VST3 format inside a worker or in-process inspection run, applying preset data, and driving the processing loop. +- `src/BuildWorker.cpp`: Hidden build-worker argument parsing, command construction, and worker exit-code mapping used by the CLI and bridge chunk supervisor. - `src/CrashDiagnostics.cpp`: Windows crash-dump support for failures that occur inside JUCE or a hosted VST3 before normal error reporting can run. -- `src/ChildProcessOutput.cpp`: Byte-buffered subprocess output forwarding. It preserves split UTF-8 sequences across reads before logging scan-worker output. +- `src/ChildProcessOutput.cpp`: Byte-buffered subprocess output forwarding. It preserves split UTF-8 sequences across reads before logging scan-worker output or forwarding build-worker logs unchanged to the parent console. - `src/Log.cpp`: Private spdlog setup and log-level parsing. spdlog must not appear in the public `include/halionbridge` API. - `src/BuildFile.cpp`: Host-side build file inspection for the top-level string list returned by `halionbridge_build.lua`, plus deterministic generation of a simple build file for `halionbridge init`. - `src/PathUtils.cpp`: Private JUCE/std filesystem conversion and CLI path normalization helpers shared by the library, CLI, and tests. - `src/PluginScan.cpp`: HALion plugin-description construction, in-process plugin scanning, and isolated scan-worker implementation. - `src/ProgressMarkers.cpp`: Host-side progress marker decoding, logging, cleanup, and filename codec behavior shared by the processing loop and tests. -- `cli/Main.cpp`: Thin command-line frontend, usage text, console logging, process entry point, and dispatch into the private scan-worker mode. +- `cli/Main.cpp`: Thin command-line frontend, usage text, console logging, process entry point, and dispatch into private scan-worker and build-worker modes. - `tests/Tests.cpp`: Unit tests executable to verify argument parsing, HALion default plugin paths, build-status paths, runtime module generation, build file parsing, progress marker decoding, subprocess output buffering, and VST3 preset-container inspection. - `HALION-LUA.md`: Contract for the generic HALion Lua build script runner, build script modules, progress reporting, and status reporting. @@ -279,8 +280,8 @@ cd halion-lua - The compile-time assets are `halion-lua/builder_bootstrap.vstpreset` and `halion-lua/builder.lua`, embedded through the `halionbridge_assets` binary-data target. The checked-in `halion-lua/builder_bootstrap.lua` is the canonical source text for the inline Lua saved inside `builder_bootstrap.vstpreset`; it should call `require("halionbridge_runtime")`. - For builder runs, the bridge writes temporary `halionbridge_runtime.lua` and `halionbridge_builder.lua` files into the HALion user scripts directory under `Documents/Steinberg/HALion/Library/scripts`. HALion Lua does not reliably expose the host process environment or current working directory to `require()`, so this generated runtime module is the authoritative handoff. It sets the Lua global `HALIONBRIDGE_RUNTIME_ROOT`, optional build-slice globals, prepends the build directory to `package.path` if needed, clears cached runtime/builder modules, and then loads the temporary embedded builder module. Previous files with the same names are restored after the run; otherwise the temporary files are deleted. Because these filenames and the working directory/environment handoff are shared process/user state, halionbridge acquires a fail-fast inter-process lock for the whole runtime-staging lifetime and rejects overlapping runs. - Running `halionbridge` without arguments is equivalent to `halionbridge --help` and exits successfully after printing usage. -- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after writing the final status marker. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the host runs the list in chunks of up to 15 scripts per HALion process by default. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. Because current build script entrypoints run as synchronous HALion global/module code, the host may observe progress markers in batches after HALion returns control rather than exactly when `ctx.progress()` was called inside a long script. -- The CLI installs Ctrl+C/termination handlers before init, convert, scan-worker, and build commands. Handlers set a shared stop flag. SFZ conversion checks the flag during directory traversal, between file conversions, and before writing generated files. HALion builds check the flag while waiting for markers and between chunks; a synchronous global Lua build script cannot be preempted mid-function, so stop is observed when HALion returns control to the host. +- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after writing the final status marker. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the CLI parent runs normal headless chunks in hidden `--halionbridge-build-worker` child processes, up to 15 scripts per worker by default. The parent polls the build directory for progress markers while the worker is running, so progress can be printed even when the worker is blocked inside HALion, provided HALion has actually flushed the marker file to disk. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. GUI runs, `--nokill` inspection runs, embedded callers without `AppOptions::executableFile`, and dynamic/unparseable build files run in-process. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. Because current build script entrypoints run as synchronous HALion global/module code, marker visibility still depends on HALion's `savePreset()` behavior; if HALion defers marker file creation internally, the host will still observe those markers in batches. +- The CLI installs Ctrl+C/termination handlers before init, convert, scan-worker, build-worker, and build commands. Handlers set a shared stop flag. SFZ conversion checks the flag during directory traversal, between file conversions, and before writing generated files. In normal headless builds, the parent process does not load HALion directly; it supervises the active worker, lets it exit for up to 5 seconds after Ctrl+C, then kills it if HALion is still stuck and returns `RunResult::stopped`. A forced worker kill may skip worker-side marker cleanup, so stale marker cleanup on the next run remains part of the recovery path. GUI and `--nokill` inspection runs stay cooperative and in-process so HALion can release through normal C++ cleanup. - Build script modules own all HALion-specific build behavior: program presets, layer presets, zones, samples, and output filenames. Keep the build script API documented in `HALION-LUA.md` when changing builder or build script behavior. - Generic build script modules are arbitrary code, so `halionbridge_build.lua` entries are not treated as expected output preset names. Build completion is reported only through HALion-written `.vstpreset` marker files. - The default build timeout is `0`, which waits forever and emits a startup warning. Set `--timeout-seconds ` only when a finite build timeout is desired. diff --git a/README.md b/README.md index 371fc49..0f179cc 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,7 @@ halionbridge prints timestamped console logs. The default log level is `info`, w Only one halionbridge build can run at a time for a user account. HALion resolves temporary runtime modules from the shared HALion user script directory, so a second overlapping run exits with a clear error instead of corrupting build output. -Press Ctrl+C to request a graceful stop. Conversion commands stop at converter checkpoints before writing more generated files. HALion builds stop as soon as control returns from the currently running HALion script or between chunks, then normal cleanup runs. +Press Ctrl+C to stop a run. Conversion commands stop at converter checkpoints before writing more generated files. Normal headless HALion builds stop the active worker process and do not start later chunks. GUI and `--nokill` inspection runs remain cooperative so HALion can clean up normally. ## Features - **Headless Execution:** Runs as a standalone console app. @@ -115,6 +115,6 @@ Press Ctrl+C to request a graceful stop. Conversion commands stop at converter c - **Offline Processing Loop:** Runs a manual processing loop without opening an audio device, to keep HALion alive while LUA instrument scripts execute. - **Generic HALion Lua Build Scripts:** The embedded builder loads build script modules listed in `halionbridge_build.lua`; the build script modules decide what to build. - **Converter Setup Commands:** `halionbridge convert sfz` creates a normal halionbridge build directory from `.sfz` files without launching HALion. When no output directory is provided, generated Lua/build files are written flat into the source directory; an explicit output directory can still be passed. The generated Lua build scripts can be reviewed or edited before running `halionbridge `. Converter-generated Lua filenames are kept inside the build root; unsafe generated paths are rejected. Converter-generated helper modules may be written beside build scripts without being listed in `halionbridge_build.lua`. -- **Build Completion Detection:** Runs static `halionbridge_build.lua` lists in chunks of up to 15 scripts per HALion process by default, relaunching HALion for each chunk so later chunks can continue after a failed chunk. Waits for HALion-written `.vstpreset` status marker presets in the build directory in order to know when each chunk is finished. Temporary progress marker presets are cleaned after logging; failed status markers may remain after failed builds for diagnostics. +- **Build Completion Detection:** Runs static `halionbridge_build.lua` lists in chunks of up to 15 scripts per HALion worker process by default, relaunching HALion for each chunk so later chunks can continue after a failed chunk and Ctrl+C can interrupt stuck headless builds. Waits for HALion-written `.vstpreset` status marker presets in the build directory in order to know when each chunk is finished. Temporary progress marker presets are cleaned after logging; failed status markers may remain after failed builds for diagnostics. See `HALION-LUA.md` for the Lua build script API used by the generic builder workflow. diff --git a/cli/Main.cpp b/cli/Main.cpp index 45fb981..4fac1a0 100644 --- a/cli/Main.cpp +++ b/cli/Main.cpp @@ -1,6 +1,7 @@ #include "halionbridge/Bridge.h" #include "halionbridge/BuildInfo.h" #include "halionbridge/CrashDiagnostics.h" +#include "BuildWorker.h" #include "BuildFile.h" #include "Log.h" #include "PathUtils.h" @@ -114,6 +115,11 @@ void printVersion() << "source: " << (buildInfo.isDirty ? "modified" : "clean") << "\n"; } +bool isInternalWorkerCommand(const juce::StringArray& args) +{ + return args.size() > 0 && (args[0] == halionbridge::detail::kBuildWorkerArgument || args[0] == "--halionbridge-scan-plugin"); +} + #if HALIONBRIDGE_ENABLE_CONVERTERS void printConvertHelp() { @@ -257,14 +263,13 @@ int main(int argc, char* argv[]) args.emplace_back(argv[i]); } - if ((juceArgs.isEmpty() || juceArgs[0] == "--help" || juceArgs[0] == "-h") && - (juceArgs.size() == 0 || juceArgs[0] != "--halionbridge-scan-plugin")) + if ((juceArgs.isEmpty() || juceArgs[0] == "--help" || juceArgs[0] == "-h") && !isInternalWorkerCommand(juceArgs)) { printHelp(); return 0; } - if (juceArgs[0] == "--version" && (juceArgs.size() == 1 || juceArgs[0] != "--halionbridge-scan-plugin")) + if (juceArgs[0] == "--version" && !isInternalWorkerCommand(juceArgs)) { printVersion(); return 0; @@ -316,9 +321,30 @@ int main(int argc, char* argv[]) { const auto exitCode = halionbridge::detail::runPluginScanWorker(juceArgs); juce::Logger::setCurrentLogger(nullptr); + halionbridge::log::flush(); return exitCode; } + if (juceArgs.size() > 0 && juceArgs[0] == halionbridge::detail::kBuildWorkerArgument) + { + auto options = halionbridge::detail::parseBuildWorkerArguments(args); + if (!options) + { + juce::Logger::setCurrentLogger(nullptr); + halionbridge::log::flush(); + return halionbridge::detail::runResultToBuildWorkerExitCode(halionbridge::RunResult::invalidOptions); + } + + const auto executableFile = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + options->executableFile = halionbridge::detail::toStdPath(executableFile); + + halionbridge::Bridge app; + const auto runResult = app.runDetailed(*options); + juce::Logger::setCurrentLogger(nullptr); + halionbridge::log::flush(); + return halionbridge::detail::runResultToBuildWorkerExitCode(runResult); + } + auto options = halionbridge::Bridge::parseArguments(args); if (!options) { diff --git a/converters/sfz/src/SfzConverter.cpp b/converters/sfz/src/SfzConverter.cpp index 822f8d2..d8465ec 100644 --- a/converters/sfz/src/SfzConverter.cpp +++ b/converters/sfz/src/SfzConverter.cpp @@ -1140,7 +1140,7 @@ std::string buildLuaSource(const ConvertedSfz& converted) lua << "local hb = require(\"halionbridge-sfz\")\n\n" << "local layerName = " << luaQuotedString(converted.layerName) << "\n" << "local outputFile = " << luaQuotedString(converted.presetFileName) << "\n" - << "local progressInterval = 25\n" + << "local progressInterval = 5\n" << "\n" << "-- This file was generated by halionbridge convert sfz. Region data is\n" << "-- grouped by source concern so converter authors can inspect the SFZ\n" diff --git a/include/halionbridge/Bridge.h b/include/halionbridge/Bridge.h index 0ee26a5..8a294be 100644 --- a/include/halionbridge/Bridge.h +++ b/include/halionbridge/Bridge.h @@ -25,6 +25,10 @@ struct AppOptions bool noKill = false; bool forceScan = false; bool failFast = false; + bool buildWorkerMode = false; + int buildSliceStart = 0; + int buildSliceCount = 0; + int buildSliceTotal = 0; }; struct BuildStatusMarkerFiles diff --git a/src/Bridge.cpp b/src/Bridge.cpp index fbe0e16..741205b 100644 --- a/src/Bridge.cpp +++ b/src/Bridge.cpp @@ -3,6 +3,8 @@ #include "halionbridge/BuildInfo.h" #include "halionbridge_assets.h" #include "BuildFile.h" +#include "BuildWorker.h" +#include "ChildProcessOutput.h" #include "Log.h" #include "PathUtils.h" #include "PluginScan.h" @@ -42,6 +44,8 @@ constexpr int kTerminalProgressDrainMaxMs = 1500; constexpr int kTerminalProgressDrainQuietMs = 300; constexpr double kBuildWaitProgressLogIntervalSeconds = 5.0; constexpr double kNoKillHeartbeatIntervalSeconds = 30.0; +constexpr double kBuildWorkerStopGraceMs = 5000.0; +constexpr double kBuildWorkerProgressPollMs = 100.0; constexpr int kDefaultBuildChunkSize = 15; constexpr const char* kBuildStatusOkPresetFileName = "halionbridge_status_ok.vstpreset"; constexpr const char* kBuildStatusFailedPresetFileName = "halionbridge_status_failed.vstpreset"; @@ -753,6 +757,11 @@ bool isRecoverableChunkFailure(const RunResult result) noexcept { return result == RunResult::buildFailed || result == RunResult::timedOut; } + +bool isInfrastructureChunkFailure(const RunResult result) noexcept +{ + return !isRecoverableChunkFailure(result) && result != RunResult::success; +} } // namespace void requestStop() noexcept @@ -774,6 +783,9 @@ struct Bridge::Impl { RunResult runDetailed(const AppOptions& options); RunResult runSingleInvocation(const AppOptions& options, const juce::File& runtimeRoot, const BuildSlice& slice); + RunResult runChunkedInProcess(const AppOptions& options, const juce::File& runtimeRoot, std::span slices); + RunResult runChunkedInWorkers(const AppOptions& options, std::span slices); + RunResult runWorkerInvocation(const AppOptions& options, const BuildSlice& slice); bool loadPlugin(const juce::File& pluginFile, const AppOptions& options); bool applyVstPresetData(const juce::MemoryBlock& presetData); RunResult runProcessingLoop(const AppOptions& options, const juce::File& builderRoot); @@ -1025,6 +1037,20 @@ RunResult Bridge::Impl::runDetailed(const AppOptions& options) const auto chunkSize = options.buildChunkSize > 0 ? options.buildChunkSize : kDefaultBuildChunkSize; const auto slices = makeBuildSlices(static_cast(moduleNames.size()), chunkSize); + if (options.buildWorkerMode) + { + if (options.buildSliceStart <= 0 || options.buildSliceCount <= 0 || options.buildSliceTotal <= 0 || + options.buildSliceStart > options.buildSliceTotal || + options.buildSliceCount > (options.buildSliceTotal - options.buildSliceStart + 1)) + { + log::error("Invalid build-worker slice configuration."); + return RunResult::invalidOptions; + } + + return runSingleInvocation(options, runtimeRoot, + BuildSlice{options.buildSliceStart, options.buildSliceCount, options.buildSliceTotal}); + } + if (slices.empty()) { log::warn("Could not statically split {} into build chunks; running it as one HALion Lua invocation.", kBuildFileName); @@ -1034,6 +1060,19 @@ RunResult Bridge::Impl::runDetailed(const AppOptions& options) log::info("Running {} Lua build script file(s) in {} chunk(s) of up to {}.", static_cast(moduleNames.size()), static_cast(slices.size()), chunkSize); + if (!options.showGui && !options.noKill) + { + if (options.executableFile) + return runChunkedInWorkers(options, slices); + + log::warn("No executable path is available; running HALion chunks in-process without hard Ctrl+C isolation."); + } + + return runChunkedInProcess(options, runtimeRoot, slices); +} + +RunResult Bridge::Impl::runChunkedInProcess(const AppOptions& options, const juce::File& runtimeRoot, std::span slices) +{ auto failedChunks = 0; auto lastFailure = RunResult::success; @@ -1086,6 +1125,142 @@ RunResult Bridge::Impl::runDetailed(const AppOptions& options) return RunResult::success; } +RunResult Bridge::Impl::runChunkedInWorkers(const AppOptions& options, std::span slices) +{ + auto failedChunks = 0; + auto lastFailure = RunResult::success; + + for (size_t i = 0; i < slices.size(); ++i) + { + if (isStopRequested()) + { + log::warn("HALion Lua build stopped by user request before starting build chunk {}/{}.", static_cast(i + 1), + static_cast(slices.size())); + return RunResult::stopped; + } + + const auto& slice = slices[i]; + log::info("Starting build chunk {}/{}: entries {}-{} of {}.", static_cast(i + 1), static_cast(slices.size()), slice.start, + slice.end(), slice.total); + + const auto result = runWorkerInvocation(options, slice); + if (result == RunResult::success) + { + if (isStopRequested()) + { + log::warn("HALion Lua build stopped by user request after build chunk {}/{}.", static_cast(i + 1), + static_cast(slices.size())); + return RunResult::stopped; + } + + log::info("Build chunk {}/{} completed.", static_cast(i + 1), static_cast(slices.size())); + continue; + } + + if (result == RunResult::stopped) + return RunResult::stopped; + + ++failedChunks; + lastFailure = result; + log::error("Build chunk {}/{} failed; entries {}-{} were not completed successfully.", static_cast(i + 1), + static_cast(slices.size()), slice.start, slice.end()); + + if (options.failFast || isInfrastructureChunkFailure(result)) + { + log::error("Stopping after failed build chunk."); + return result; + } + } + + if (failedChunks > 0) + { + log::error("HALion Lua build completed with {} failed chunk(s).", failedChunks); + return lastFailure == RunResult::success ? RunResult::buildFailed : lastFailure; + } + + log::info("HALion Lua build completed."); + return RunResult::success; +} + +RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const BuildSlice& slice) +{ + const auto command = detail::makeBuildWorkerCommand(options, slice.start, slice.count, slice.total); + if (command.isEmpty()) + { + log::error("Could not create HALion build worker command."); + return RunResult::runtimeSetupFailed; + } + + juce::ChildProcess process; + if (!process.start(command, juce::ChildProcess::wantStdOut | juce::ChildProcess::wantStdErr)) + { + log::error("Failed to launch HALion build worker."); + return RunResult::runtimeSetupFailed; + } + + detail::ChildProcessOutputBuffer childOutput; + auto stopLogged = false; + auto stopDeadline = 0.0; + auto nextProgressPoll = 0.0; + auto seenProgressMarkers = std::set(); + + if (options.buildDirectory) + { + const auto builderRoot = toJuceFile(*options.buildDirectory); + const auto staleMarkers = detail::deleteProgressMarkers(builderRoot, "stale HALion Lua progress marker before worker run"); + seenProgressMarkers = std::move(staleMarkers.remainingNames); + } + + while (process.isRunning()) + { + detail::forwardChildOutputToConsole(process, childOutput); + + const auto now = juce::Time::getMillisecondCounterHiRes(); + if (options.buildDirectory && now >= nextProgressPoll) + { + detail::logNewProgressMarkers(toJuceFile(*options.buildDirectory), seenProgressMarkers); + nextProgressPoll = now + kBuildWorkerProgressPollMs; + } + + if (isStopRequested()) + { + if (!stopLogged) + { + stopLogged = true; + stopDeadline = now + kBuildWorkerStopGraceMs; + log::warn("Stop requested; waiting up to {} seconds for current HALion worker to exit.", + static_cast(kBuildWorkerStopGraceMs / 1000.0)); + } + + if (now >= stopDeadline) + { + process.kill(); + detail::forwardChildOutputToConsole(process, childOutput); + detail::flushChildOutputToConsole(childOutput); + log::warn("HALion worker killed after Ctrl+C grace period."); + return RunResult::stopped; + } + } + + juce::Thread::sleep(10); + } + + detail::forwardChildOutputToConsole(process, childOutput); + detail::flushChildOutputToConsole(childOutput); + if (options.buildDirectory) + detail::logNewProgressMarkers(toJuceFile(*options.buildDirectory), seenProgressMarkers); + + const auto exitCode = static_cast(process.getExitCode()); + const auto result = detail::buildWorkerExitCodeToRunResult(exitCode); + if (!result) + { + log::error("HALion build worker exited with unexpected code {}.", exitCode); + return RunResult::buildFailed; + } + + return *result; +} + RunResult Bridge::Impl::runSingleInvocation(const AppOptions& options, const juce::File& runtimeRoot, const BuildSlice& slice) { pluginInstance = nullptr; diff --git a/src/BuildWorker.cpp b/src/BuildWorker.cpp new file mode 100644 index 0000000..4c601f5 --- /dev/null +++ b/src/BuildWorker.cpp @@ -0,0 +1,209 @@ +#include "BuildWorker.h" + +#include "Log.h" +#include "PathUtils.h" + +#include +#include +#include + +namespace halionbridge::detail +{ +namespace +{ + +constexpr auto kRunResultValues = std::array{ + RunResult::success, + RunResult::invalidOptions, + RunResult::invalidBridge, + RunResult::runtimeSetupFailed, + RunResult::anotherInstanceRunning, + RunResult::pluginNotFound, + RunResult::pluginLoadFailed, + RunResult::startupStopped, + RunResult::presetApplyFailed, + RunResult::buildFailed, + RunResult::stopped, + RunResult::timedOut, + RunResult::cleanupFailed, +}; + +std::optional parsePositiveInt(const std::string& text) +{ + if (text.empty()) + return std::nullopt; + + for (const auto c : text) + if (c < '0' || c > '9') + return std::nullopt; + + try + { + const auto value = std::stoll(text); + if (value <= 0 || value > std::numeric_limits::max()) + return std::nullopt; + + return static_cast(value); + } + catch (...) + { + return std::nullopt; + } +} + +} // namespace + +int runResultToBuildWorkerExitCode(const RunResult result) noexcept +{ + return static_cast(result); +} + +std::optional buildWorkerExitCodeToRunResult(const int exitCode) noexcept +{ + for (const auto result : kRunResultValues) + if (exitCode == runResultToBuildWorkerExitCode(result)) + return result; + + return std::nullopt; +} + +std::optional parseBuildWorkerArguments(std::span args) +{ + if (args.empty() || args.front() != kBuildWorkerArgument) + { + log::error("{} must be the first build-worker argument.", kBuildWorkerArgument); + return std::nullopt; + } + + if (args.size() < 2) + { + log::error("{} requires a build directory.", kBuildWorkerArgument); + return std::nullopt; + } + + std::vector bridgeArgs; + bridgeArgs.push_back(args[1]); + + std::optional sliceStart; + std::optional sliceCount; + std::optional sliceTotal; + + for (size_t i = 2; i < args.size(); ++i) + { + const auto& arg = args[i]; + + const auto parseSliceValue = [&](std::optional& target, const char* optionName) -> bool + { + if (i + 1 >= args.size()) + { + log::error("{} requires a value.", optionName); + return false; + } + + auto parsed = parsePositiveInt(args[++i]); + if (!parsed) + { + log::error("{} must be a positive integer.", optionName); + return false; + } + + target = *parsed; + return true; + }; + + if (arg == "--build-slice-start") + { + if (!parseSliceValue(sliceStart, "--build-slice-start")) + return std::nullopt; + } + else if (arg == "--build-slice-count") + { + if (!parseSliceValue(sliceCount, "--build-slice-count")) + return std::nullopt; + } + else if (arg == "--build-slice-total") + { + if (!parseSliceValue(sliceTotal, "--build-slice-total")) + return std::nullopt; + } + else if (arg == "--plugin" || arg == "--timeout-seconds") + { + if (i + 1 >= args.size()) + { + log::error("{} requires a value.", arg); + return std::nullopt; + } + + bridgeArgs.push_back(arg); + bridgeArgs.push_back(args[++i]); + } + else if (arg == "--force-scan") + { + bridgeArgs.push_back(arg); + } + else + { + log::error("Unknown build-worker argument: {}", arg); + return std::nullopt; + } + } + + if (!sliceStart || !sliceCount || !sliceTotal) + { + log::error("{} requires --build-slice-start, --build-slice-count, and --build-slice-total.", kBuildWorkerArgument); + return std::nullopt; + } + + if (*sliceStart > *sliceTotal || *sliceCount > (*sliceTotal - *sliceStart + 1)) + { + log::error("Build-worker slice {}-{} is outside the total script count {}.", *sliceStart, *sliceStart + *sliceCount - 1, + *sliceTotal); + return std::nullopt; + } + + auto options = Bridge::parseArguments(bridgeArgs); + if (!options) + return std::nullopt; + + options->buildWorkerMode = true; + options->buildSliceStart = *sliceStart; + options->buildSliceCount = *sliceCount; + options->buildSliceTotal = *sliceTotal; + return options; +} + +juce::StringArray makeBuildWorkerCommand(const AppOptions& options, const int sliceStart, const int sliceCount, const int totalScripts) +{ + juce::StringArray command; + if (!options.executableFile || !options.buildDirectory) + return command; + + command.add(toJuceString(*options.executableFile)); + command.add(kBuildWorkerArgument); + command.add(toJuceString(*options.buildDirectory)); + command.add("--build-slice-start"); + command.add(juce::String(sliceStart)); + command.add("--build-slice-count"); + command.add(juce::String(sliceCount)); + command.add("--build-slice-total"); + command.add(juce::String(totalScripts)); + + if (options.pluginPathOverride) + { + command.add("--plugin"); + command.add(toJuceString(*options.pluginPathOverride)); + } + + if (options.timeoutSeconds > 0) + { + command.add("--timeout-seconds"); + command.add(juce::String(options.timeoutSeconds)); + } + + if (options.forceScan) + command.add("--force-scan"); + + return command; +} + +} // namespace halionbridge::detail diff --git a/src/BuildWorker.h b/src/BuildWorker.h new file mode 100644 index 0000000..999ca5e --- /dev/null +++ b/src/BuildWorker.h @@ -0,0 +1,22 @@ +#pragma once + +#include "halionbridge/Bridge.h" + +#include +#include +#include + +#include + +namespace halionbridge::detail +{ + +constexpr const char* kBuildWorkerArgument = "--halionbridge-build-worker"; + +std::optional parseBuildWorkerArguments(std::span args); +juce::StringArray makeBuildWorkerCommand(const AppOptions& options, int sliceStart, int sliceCount, int totalScripts); + +int runResultToBuildWorkerExitCode(RunResult result) noexcept; +std::optional buildWorkerExitCodeToRunResult(int exitCode) noexcept; + +} // namespace halionbridge::detail diff --git a/src/ChildProcessOutput.cpp b/src/ChildProcessOutput.cpp index 6ebeb84..b0f2c57 100644 --- a/src/ChildProcessOutput.cpp +++ b/src/ChildProcessOutput.cpp @@ -2,6 +2,8 @@ #include "Log.h" +#include + namespace halionbridge::detail { namespace @@ -18,6 +20,15 @@ void logLine(const std::string& line) log::debug("{}", line); } +void printLine(const std::string& line) +{ + if (!line.empty()) + { + std::cout << line << '\n'; + std::cout.flush(); + } +} + } // namespace std::vector ChildProcessOutputBuffer::append(std::string_view bytes) @@ -77,4 +88,25 @@ void flushChildOutput(ChildProcessOutputBuffer& output) logLine(*line); } +void forwardChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output) +{ + char buffer[1024]{}; + + for (;;) + { + const auto bytesRead = process.readProcessOutput(buffer, static_cast(sizeof(buffer))); + if (bytesRead <= 0) + break; + + for (const auto& line : output.append(std::string_view(buffer, static_cast(bytesRead)))) + printLine(line); + } +} + +void flushChildOutputToConsole(ChildProcessOutputBuffer& output) +{ + if (auto line = output.flush()) + printLine(*line); +} + } // namespace halionbridge::detail diff --git a/src/ChildProcessOutput.h b/src/ChildProcessOutput.h index cb35006..2f4859b 100644 --- a/src/ChildProcessOutput.h +++ b/src/ChildProcessOutput.h @@ -22,5 +22,7 @@ class ChildProcessOutputBuffer void forwardChildOutput(juce::ChildProcess& process, ChildProcessOutputBuffer& output); void flushChildOutput(ChildProcessOutputBuffer& output); +void forwardChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output); +void flushChildOutputToConsole(ChildProcessOutputBuffer& output); } // namespace halionbridge::detail diff --git a/tests/Tests.cpp b/tests/Tests.cpp index 3257f54..8a3441c 100644 --- a/tests/Tests.cpp +++ b/tests/Tests.cpp @@ -1,6 +1,7 @@ #include "halionbridge/Bridge.h" #include "halionbridge/BuildInfo.h" #include "BuildFile.h" +#include "BuildWorker.h" #include "Log.h" #include "ChildProcessOutput.h" #include "PathUtils.h" @@ -314,6 +315,111 @@ class BridgeTests : public juce::UnitTest tempDir.deleteRecursively(); } + beginTest("Build Worker - argument parsing and command construction"); + { + auto tempDir = cleanTempDirectory("halionbridge_worker_build_dir"); + tempDir.createDirectory(); + tempDir.getChildFile("halionbridge_build.lua").replaceWithText("return { \"one\", \"two\", \"three\" }"); + + auto pluginFile = tempDir.getChildFile("HALion 7.vst3"); + expect(pluginFile.replaceWithText("plugin")); + + auto workerArgs = std::vector{halionbridge::detail::kBuildWorkerArgument, + tempDir.getFullPathName().toStdString(), + "--build-slice-start", + "2", + "--build-slice-count", + "1", + "--build-slice-total", + "3", + "--plugin", + pluginFile.getFullPathName().toStdString(), + "--timeout-seconds", + "5", + "--force-scan"}; + auto options = halionbridge::detail::parseBuildWorkerArguments(workerArgs); + + expect(options.has_value()); + if (options) + { + expect(options->buildWorkerMode); + expectEquals(options->buildSliceStart, 2); + expectEquals(options->buildSliceCount, 1); + expectEquals(options->buildSliceTotal, 3); + expectEquals(options->timeoutSeconds, 5); + expect(options->forceScan); + expect(options->pluginPathOverride.has_value()); + + const auto executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + options->executableFile = halionbridge::detail::toStdPath(executable); + const auto command = halionbridge::detail::makeBuildWorkerCommand(*options, 2, 1, 3); + expect(command.size() >= 12); + expectEquals(command[0], executable.getFullPathName()); + expectEquals(command[1], juce::String(halionbridge::detail::kBuildWorkerArgument)); + expectEquals(command[2], tempDir.getFullPathName()); + expect(command.contains("--build-slice-start")); + expect(command.contains("--build-slice-count")); + expect(command.contains("--build-slice-total")); + expect(command.contains("--plugin")); + expect(command.contains("--timeout-seconds")); + expect(command.contains("--force-scan")); + expect(!command.contains("--build-chunk-size")); + expect(!command.contains("--fail-fast")); + expect(!command.contains("--gui")); + expect(!command.contains("--nokill")); + } + + auto invalidZeroStart = std::vector{halionbridge::detail::kBuildWorkerArgument, + tempDir.getFullPathName().toStdString(), + "--build-slice-start", + "0", + "--build-slice-count", + "1", + "--build-slice-total", + "3"}; + expect(!halionbridge::detail::parseBuildWorkerArguments(invalidZeroStart).has_value()); + + auto invalidOutsideTotal = std::vector{halionbridge::detail::kBuildWorkerArgument, + tempDir.getFullPathName().toStdString(), + "--build-slice-start", + "3", + "--build-slice-count", + "2", + "--build-slice-total", + "3"}; + expect(!halionbridge::detail::parseBuildWorkerArguments(invalidOutsideTotal).has_value()); + + auto invalidGui = std::vector{halionbridge::detail::kBuildWorkerArgument, + tempDir.getFullPathName().toStdString(), + "--build-slice-start", + "1", + "--build-slice-count", + "1", + "--build-slice-total", + "3", + "--gui"}; + expect(!halionbridge::detail::parseBuildWorkerArguments(invalidGui).has_value()); + + tempDir.deleteRecursively(); + } + + beginTest("Build Worker - RunResult exit-code mapping"); + { + for (const auto result : + {halionbridge::RunResult::success, halionbridge::RunResult::invalidOptions, halionbridge::RunResult::runtimeSetupFailed, + halionbridge::RunResult::buildFailed, halionbridge::RunResult::stopped, halionbridge::RunResult::timedOut, + halionbridge::RunResult::cleanupFailed}) + { + const auto exitCode = halionbridge::detail::runResultToBuildWorkerExitCode(result); + const auto mapped = halionbridge::detail::buildWorkerExitCodeToRunResult(exitCode); + expect(mapped.has_value()); + if (mapped) + expect(*mapped == result); + } + + expect(!halionbridge::detail::buildWorkerExitCodeToRunResult(9999).has_value()); + } + beginTest("Argument Parsing - No-kill inspection hold"); { auto tempDir = cleanTempDirectory("halionbridge_nokill_build_dir"); @@ -1555,7 +1661,7 @@ class BridgeTests : public juce::UnitTest expect(firstLua.contains("hb.create_layer(ctx, layerName)")); expect(firstLua.contains("hb.append_sample_zone(ctx, layer, region)")); expect(firstLua.contains("hb.save_layer_preset(ctx, layer, outputFile)")); - expect(firstLua.contains("local progressInterval = 25")); + expect(firstLua.contains("local progressInterval = 5")); expect(firstLua.contains("if i == 1 then")); expect(!firstLua.contains("((i - 1) % progressInterval)")); expect(!firstLua.contains("ctx.yield")); From 2af3c1544bfc323299beccb7832fc7d100d22ea2 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Mon, 15 Jun 2026 08:10:57 +0200 Subject: [PATCH 2/5] better progress --- DEVELOPMENT.md | 2 +- src/Bridge.cpp | 25 ++++++++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 104510d..06c5f9e 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -280,7 +280,7 @@ cd halion-lua - The compile-time assets are `halion-lua/builder_bootstrap.vstpreset` and `halion-lua/builder.lua`, embedded through the `halionbridge_assets` binary-data target. The checked-in `halion-lua/builder_bootstrap.lua` is the canonical source text for the inline Lua saved inside `builder_bootstrap.vstpreset`; it should call `require("halionbridge_runtime")`. - For builder runs, the bridge writes temporary `halionbridge_runtime.lua` and `halionbridge_builder.lua` files into the HALion user scripts directory under `Documents/Steinberg/HALion/Library/scripts`. HALion Lua does not reliably expose the host process environment or current working directory to `require()`, so this generated runtime module is the authoritative handoff. It sets the Lua global `HALIONBRIDGE_RUNTIME_ROOT`, optional build-slice globals, prepends the build directory to `package.path` if needed, clears cached runtime/builder modules, and then loads the temporary embedded builder module. Previous files with the same names are restored after the run; otherwise the temporary files are deleted. Because these filenames and the working directory/environment handoff are shared process/user state, halionbridge acquires a fail-fast inter-process lock for the whole runtime-staging lifetime and rejects overlapping runs. - Running `halionbridge` without arguments is equivalent to `halionbridge --help` and exits successfully after printing usage. -- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after writing the final status marker. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the CLI parent runs normal headless chunks in hidden `--halionbridge-build-worker` child processes, up to 15 scripts per worker by default. The parent polls the build directory for progress markers while the worker is running, so progress can be printed even when the worker is blocked inside HALion, provided HALion has actually flushed the marker file to disk. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. GUI runs, `--nokill` inspection runs, embedded callers without `AppOptions::executableFile`, and dynamic/unparseable build files run in-process. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. Because current build script entrypoints run as synchronous HALion global/module code, marker visibility still depends on HALion's `savePreset()` behavior; if HALion defers marker file creation internally, the host will still observe those markers in batches. +- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after writing the final status marker. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the CLI parent runs normal headless chunks in hidden `--halionbridge-build-worker` child processes, up to 15 scripts per worker by default. The parent polls the build directory for progress markers while the worker is running, so progress can be printed even when the worker is blocked inside HALion, provided HALion has actually flushed the marker file to disk. The parent also emits a 5-second build-worker heartbeat for long chunks so large synchronous HALion invocations do not look stalled. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. GUI runs, `--nokill` inspection runs, embedded callers without `AppOptions::executableFile`, and dynamic/unparseable build files run in-process. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. Because current build script entrypoints run as synchronous HALion global/module code, marker visibility still depends on HALion's `savePreset()` behavior; if HALion defers marker file creation internally, the host will still observe those markers in batches. - The CLI installs Ctrl+C/termination handlers before init, convert, scan-worker, build-worker, and build commands. Handlers set a shared stop flag. SFZ conversion checks the flag during directory traversal, between file conversions, and before writing generated files. In normal headless builds, the parent process does not load HALion directly; it supervises the active worker, lets it exit for up to 5 seconds after Ctrl+C, then kills it if HALion is still stuck and returns `RunResult::stopped`. A forced worker kill may skip worker-side marker cleanup, so stale marker cleanup on the next run remains part of the recovery path. GUI and `--nokill` inspection runs stay cooperative and in-process so HALion can release through normal C++ cleanup. - Build script modules own all HALion-specific build behavior: program presets, layer presets, zones, samples, and output filenames. Keep the build script API documented in `HALION-LUA.md` when changing builder or build script behavior. - Generic build script modules are arbitrary code, so `halionbridge_build.lua` entries are not treated as expected output preset names. Build completion is reported only through HALion-written `.vstpreset` marker files. diff --git a/src/Bridge.cpp b/src/Bridge.cpp index 741205b..90cc0ce 100644 --- a/src/Bridge.cpp +++ b/src/Bridge.cpp @@ -24,6 +24,7 @@ #include #include #include +#include #include namespace halionbridge @@ -46,6 +47,7 @@ constexpr double kBuildWaitProgressLogIntervalSeconds = 5.0; constexpr double kNoKillHeartbeatIntervalSeconds = 30.0; constexpr double kBuildWorkerStopGraceMs = 5000.0; constexpr double kBuildWorkerProgressPollMs = 100.0; +constexpr double kBuildWorkerHeartbeatIntervalMs = 5000.0; constexpr int kDefaultBuildChunkSize = 15; constexpr const char* kBuildStatusOkPresetFileName = "halionbridge_status_ok.vstpreset"; constexpr const char* kBuildStatusFailedPresetFileName = "halionbridge_status_failed.vstpreset"; @@ -1198,11 +1200,14 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui return RunResult::runtimeSetupFailed; } - detail::ChildProcessOutputBuffer childOutput; auto stopLogged = false; auto stopDeadline = 0.0; auto nextProgressPoll = 0.0; + const auto workerStartTime = juce::Time::getMillisecondCounterHiRes(); + auto nextHeartbeat = workerStartTime + kBuildWorkerHeartbeatIntervalMs; auto seenProgressMarkers = std::set(); + detail::ChildProcessOutputBuffer childOutput; + auto outputThread = std::thread([&process, &childOutput] { detail::forwardChildOutputToConsole(process, childOutput); }); if (options.buildDirectory) { @@ -1213,8 +1218,6 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui while (process.isRunning()) { - detail::forwardChildOutputToConsole(process, childOutput); - const auto now = juce::Time::getMillisecondCounterHiRes(); if (options.buildDirectory && now >= nextProgressPoll) { @@ -1222,6 +1225,14 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui nextProgressPoll = now + kBuildWorkerProgressPollMs; } + if (now >= nextHeartbeat) + { + const auto elapsedSeconds = static_cast((now - workerStartTime) / 1000.0); + log::info("Build worker still running for chunk entries {}-{} of {} ({}s elapsed).", slice.start, slice.end(), slice.total, + elapsedSeconds); + nextHeartbeat = now + kBuildWorkerHeartbeatIntervalMs; + } + if (isStopRequested()) { if (!stopLogged) @@ -1235,7 +1246,9 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui if (now >= stopDeadline) { process.kill(); - detail::forwardChildOutputToConsole(process, childOutput); + if (outputThread.joinable()) + outputThread.join(); + detail::flushChildOutputToConsole(childOutput); log::warn("HALion worker killed after Ctrl+C grace period."); return RunResult::stopped; @@ -1245,7 +1258,9 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui juce::Thread::sleep(10); } - detail::forwardChildOutputToConsole(process, childOutput); + if (outputThread.joinable()) + outputThread.join(); + detail::flushChildOutputToConsole(childOutput); if (options.buildDirectory) detail::logNewProgressMarkers(toJuceFile(*options.buildDirectory), seenProgressMarkers); From 3a396e0bcc34429ee1477501e88d63f7b4f09f86 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Mon, 15 Jun 2026 08:13:37 +0200 Subject: [PATCH 3/5] updates --- src/ChildProcessOutput.cpp | 21 ++++++++++++++------- src/ChildProcessOutput.h | 1 + 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/src/ChildProcessOutput.cpp b/src/ChildProcessOutput.cpp index b0f2c57..5ab370b 100644 --- a/src/ChildProcessOutput.cpp +++ b/src/ChildProcessOutput.cpp @@ -88,18 +88,25 @@ void flushChildOutput(ChildProcessOutputBuffer& output) logLine(*line); } -void forwardChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output) +void forwardAvailableChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output) { - char buffer[1024]{}; + char buffer[1]{}; + + const auto bytesRead = process.readProcessOutput(buffer, static_cast(sizeof(buffer))); + if (bytesRead <= 0) + return; + for (const auto& line : output.append(std::string_view(buffer, static_cast(bytesRead)))) + printLine(line); +} + +void forwardChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output) +{ for (;;) { - const auto bytesRead = process.readProcessOutput(buffer, static_cast(sizeof(buffer))); - if (bytesRead <= 0) + forwardAvailableChildOutputToConsole(process, output); + if (!process.isRunning()) break; - - for (const auto& line : output.append(std::string_view(buffer, static_cast(bytesRead)))) - printLine(line); } } diff --git a/src/ChildProcessOutput.h b/src/ChildProcessOutput.h index 2f4859b..3279995 100644 --- a/src/ChildProcessOutput.h +++ b/src/ChildProcessOutput.h @@ -22,6 +22,7 @@ class ChildProcessOutputBuffer void forwardChildOutput(juce::ChildProcess& process, ChildProcessOutputBuffer& output); void flushChildOutput(ChildProcessOutputBuffer& output); +void forwardAvailableChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output); void forwardChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output); void flushChildOutputToConsole(ChildProcessOutputBuffer& output); From 7c1d1eecb0a6522162795cddb4531b58b8cf7f51 Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Mon, 15 Jun 2026 08:37:31 +0200 Subject: [PATCH 4/5] code review cleanups --- DEVELOPMENT.md | 2 +- include/halionbridge/Bridge.h | 42 ++++++++++++++++ src/Bridge.cpp | 72 ++++++++++++++++++--------- src/BuildWorker.cpp | 5 +- src/ChildProcessOutput.cpp | 94 +++++++++++++++++++++++++++-------- src/ChildProcessOutput.h | 1 - tests/Tests.cpp | 8 +-- 7 files changed, 169 insertions(+), 55 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 06c5f9e..9a77f49 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -201,7 +201,7 @@ Private or release-only converters are build-time drop-ins. A private converter - `src/Bridge.cpp`: Core bridge orchestration for preparing the embedded bootstrap preset, supervising headless build workers, hosting the HALion 7 VST3 format inside a worker or in-process inspection run, applying preset data, and driving the processing loop. - `src/BuildWorker.cpp`: Hidden build-worker argument parsing, command construction, and worker exit-code mapping used by the CLI and bridge chunk supervisor. - `src/CrashDiagnostics.cpp`: Windows crash-dump support for failures that occur inside JUCE or a hosted VST3 before normal error reporting can run. -- `src/ChildProcessOutput.cpp`: Byte-buffered subprocess output forwarding. It preserves split UTF-8 sequences across reads before logging scan-worker output or forwarding build-worker logs unchanged to the parent console. +- `src/ChildProcessOutput.cpp`: Byte-buffered subprocess output forwarding. It preserves split UTF-8 sequences across reads before logging scan-worker output or forwarding build-worker log lines through the parent logger. - `src/Log.cpp`: Private spdlog setup and log-level parsing. spdlog must not appear in the public `include/halionbridge` API. - `src/BuildFile.cpp`: Host-side build file inspection for the top-level string list returned by `halionbridge_build.lua`, plus deterministic generation of a simple build file for `halionbridge init`. - `src/PathUtils.cpp`: Private JUCE/std filesystem conversion and CLI path normalization helpers shared by the library, CLI, and tests. diff --git a/include/halionbridge/Bridge.h b/include/halionbridge/Bridge.h index 8a294be..40d6187 100644 --- a/include/halionbridge/Bridge.h +++ b/include/halionbridge/Bridge.h @@ -14,6 +14,11 @@ namespace halionbridge { +namespace detail +{ +struct AppOptionsAccess; +} + struct AppOptions { std::optional buildDirectory; @@ -25,12 +30,49 @@ struct AppOptions bool noKill = false; bool forceScan = false; bool failFast = false; + + private: + friend struct detail::AppOptionsAccess; + bool buildWorkerMode = false; int buildSliceStart = 0; int buildSliceCount = 0; int buildSliceTotal = 0; }; +namespace detail +{ + +struct AppOptionsAccess +{ + static void setBuildWorkerSlice(AppOptions& options, const int start, const int count, const int total) noexcept + { + options.buildWorkerMode = true; + options.buildSliceStart = start; + options.buildSliceCount = count; + options.buildSliceTotal = total; + } + + static bool isBuildWorkerMode(const AppOptions& options) noexcept + { + return options.buildWorkerMode; + } + static int buildSliceStart(const AppOptions& options) noexcept + { + return options.buildSliceStart; + } + static int buildSliceCount(const AppOptions& options) noexcept + { + return options.buildSliceCount; + } + static int buildSliceTotal(const AppOptions& options) noexcept + { + return options.buildSliceTotal; + } +}; + +} // namespace detail + struct BuildStatusMarkerFiles { std::filesystem::path okFile; diff --git a/src/Bridge.cpp b/src/Bridge.cpp index 90cc0ce..d725111 100644 --- a/src/Bridge.cpp +++ b/src/Bridge.cpp @@ -1039,18 +1039,25 @@ RunResult Bridge::Impl::runDetailed(const AppOptions& options) const auto chunkSize = options.buildChunkSize > 0 ? options.buildChunkSize : kDefaultBuildChunkSize; const auto slices = makeBuildSlices(static_cast(moduleNames.size()), chunkSize); - if (options.buildWorkerMode) + if (detail::AppOptionsAccess::isBuildWorkerMode(options)) { - if (options.buildSliceStart <= 0 || options.buildSliceCount <= 0 || options.buildSliceTotal <= 0 || - options.buildSliceStart > options.buildSliceTotal || - options.buildSliceCount > (options.buildSliceTotal - options.buildSliceStart + 1)) + const auto sliceStart = detail::AppOptionsAccess::buildSliceStart(options); + const auto sliceCount = detail::AppOptionsAccess::buildSliceCount(options); + const auto sliceTotal = detail::AppOptionsAccess::buildSliceTotal(options); + if (sliceStart <= 0 || sliceCount <= 0 || sliceTotal <= 0 || sliceStart > sliceTotal || sliceCount > (sliceTotal - sliceStart + 1)) { log::error("Invalid build-worker slice configuration."); return RunResult::invalidOptions; } - return runSingleInvocation(options, runtimeRoot, - BuildSlice{options.buildSliceStart, options.buildSliceCount, options.buildSliceTotal}); + if (sliceTotal != static_cast(moduleNames.size())) + { + log::error("Build-worker slice total {} does not match {} entries parsed from {}.", sliceTotal, + static_cast(moduleNames.size()), kBuildFileName); + return RunResult::invalidOptions; + } + + return runSingleInvocation(options, runtimeRoot, BuildSlice{sliceStart, sliceCount, sliceTotal}); } if (slices.empty()) @@ -1193,8 +1200,16 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui return RunResult::runtimeSetupFailed; } - juce::ChildProcess process; - if (!process.start(command, juce::ChildProcess::wantStdOut | juce::ChildProcess::wantStdErr)) + auto seenProgressMarkers = std::set(); + if (options.buildDirectory) + { + const auto builderRoot = toJuceFile(*options.buildDirectory); + const auto staleMarkers = detail::deleteProgressMarkers(builderRoot, "stale HALion Lua progress marker before worker run"); + seenProgressMarkers = std::move(staleMarkers.remainingNames); + } + + auto process = std::make_shared(); + if (!process->start(command, juce::ChildProcess::wantStdOut | juce::ChildProcess::wantStdErr)) { log::error("Failed to launch HALion build worker."); return RunResult::runtimeSetupFailed; @@ -1205,18 +1220,10 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui auto nextProgressPoll = 0.0; const auto workerStartTime = juce::Time::getMillisecondCounterHiRes(); auto nextHeartbeat = workerStartTime + kBuildWorkerHeartbeatIntervalMs; - auto seenProgressMarkers = std::set(); - detail::ChildProcessOutputBuffer childOutput; - auto outputThread = std::thread([&process, &childOutput] { detail::forwardChildOutputToConsole(process, childOutput); }); - - if (options.buildDirectory) - { - const auto builderRoot = toJuceFile(*options.buildDirectory); - const auto staleMarkers = detail::deleteProgressMarkers(builderRoot, "stale HALion Lua progress marker before worker run"); - seenProgressMarkers = std::move(staleMarkers.remainingNames); - } + auto childOutput = std::make_shared(); + auto outputThread = std::thread([process, childOutput] { detail::forwardChildOutputToConsole(*process, *childOutput); }); - while (process.isRunning()) + while (process->isRunning()) { const auto now = juce::Time::getMillisecondCounterHiRes(); if (options.buildDirectory && now >= nextProgressPoll) @@ -1245,11 +1252,30 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui if (now >= stopDeadline) { - process.kill(); + const auto killed = process->kill(); + if (!killed && process->isRunning()) + { + log::error("Failed to terminate HALion build worker after Ctrl+C grace period; leaving worker output reader detached."); + if (outputThread.joinable()) + outputThread.detach(); + + return RunResult::stopped; + } + + if (!process->waitForProcessToFinish(2000)) + { + log::error( + "HALion build worker did not exit within 2 seconds after termination request; leaving output reader detached."); + if (outputThread.joinable()) + outputThread.detach(); + + return RunResult::stopped; + } + if (outputThread.joinable()) outputThread.join(); - detail::flushChildOutputToConsole(childOutput); + detail::flushChildOutputToConsole(*childOutput); log::warn("HALion worker killed after Ctrl+C grace period."); return RunResult::stopped; } @@ -1261,11 +1287,11 @@ RunResult Bridge::Impl::runWorkerInvocation(const AppOptions& options, const Bui if (outputThread.joinable()) outputThread.join(); - detail::flushChildOutputToConsole(childOutput); + detail::flushChildOutputToConsole(*childOutput); if (options.buildDirectory) detail::logNewProgressMarkers(toJuceFile(*options.buildDirectory), seenProgressMarkers); - const auto exitCode = static_cast(process.getExitCode()); + const auto exitCode = static_cast(process->getExitCode()); const auto result = detail::buildWorkerExitCodeToRunResult(exitCode); if (!result) { diff --git a/src/BuildWorker.cpp b/src/BuildWorker.cpp index 4c601f5..d8091b3 100644 --- a/src/BuildWorker.cpp +++ b/src/BuildWorker.cpp @@ -165,10 +165,7 @@ std::optional parseBuildWorkerArguments(std::span if (!options) return std::nullopt; - options->buildWorkerMode = true; - options->buildSliceStart = *sliceStart; - options->buildSliceCount = *sliceCount; - options->buildSliceTotal = *sliceTotal; + AppOptionsAccess::setBuildWorkerSlice(*options, *sliceStart, *sliceCount, *sliceTotal); return options; } diff --git a/src/ChildProcessOutput.cpp b/src/ChildProcessOutput.cpp index 5ab370b..3e33bd6 100644 --- a/src/ChildProcessOutput.cpp +++ b/src/ChildProcessOutput.cpp @@ -2,7 +2,7 @@ #include "Log.h" -#include +#include namespace halionbridge::detail { @@ -20,15 +20,77 @@ void logLine(const std::string& line) log::debug("{}", line); } -void printLine(const std::string& line) +struct ParsedLogLine { - if (!line.empty()) + log::Level level = log::Level::info; + std::string message; +}; + +std::optional parseChildLogLine(const std::string& line) +{ + if (line.empty() || line.front() != '[') + return std::nullopt; + + const auto timestampEnd = line.find("] ["); + if (timestampEnd == std::string::npos) + return std::nullopt; + + const auto levelStart = timestampEnd + 3; + const auto levelEnd = line.find("] ", levelStart); + if (levelEnd == std::string::npos) + return std::nullopt; + + const auto parsedLevel = log::parseLevel(std::string_view(line).substr(levelStart, levelEnd - levelStart)); + if (!parsedLevel.valid) + return std::nullopt; + + return ParsedLogLine{parsedLevel.level, line.substr(levelEnd + 2)}; +} + +void forwardConsoleLine(const std::string& line) +{ + if (line.empty()) + return; + + const auto parsed = parseChildLogLine(line); + const auto level = parsed ? parsed->level : log::Level::info; + const auto& message = parsed ? parsed->message : line; + + switch (level) { - std::cout << line << '\n'; - std::cout.flush(); + case log::Level::trace: + log::trace("{}", message); + break; + case log::Level::debug: + log::debug("{}", message); + break; + case log::Level::info: + log::info("{}", message); + break; + case log::Level::warn: + log::warn("{}", message); + break; + case log::Level::error: + log::error("{}", message); + break; + case log::Level::off: + break; } } +bool forwardChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output, const int bufferSize) +{ + std::vector buffer(static_cast(bufferSize)); + const auto bytesRead = process.readProcessOutput(buffer.data(), static_cast(buffer.size())); + if (bytesRead <= 0) + return false; + + for (const auto& line : output.append(std::string_view(buffer.data(), static_cast(bytesRead)))) + forwardConsoleLine(line); + + return true; +} + } // namespace std::vector ChildProcessOutputBuffer::append(std::string_view bytes) @@ -88,32 +150,20 @@ void flushChildOutput(ChildProcessOutputBuffer& output) logLine(*line); } -void forwardAvailableChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output) -{ - char buffer[1]{}; - - const auto bytesRead = process.readProcessOutput(buffer, static_cast(sizeof(buffer))); - if (bytesRead <= 0) - return; - - for (const auto& line : output.append(std::string_view(buffer, static_cast(bytesRead)))) - printLine(line); -} - void forwardChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output) { - for (;;) + while (process.isRunning()) + forwardChildOutputToConsole(process, output, 1); + + while (forwardChildOutputToConsole(process, output, 1024)) { - forwardAvailableChildOutputToConsole(process, output); - if (!process.isRunning()) - break; } } void flushChildOutputToConsole(ChildProcessOutputBuffer& output) { if (auto line = output.flush()) - printLine(*line); + forwardConsoleLine(*line); } } // namespace halionbridge::detail diff --git a/src/ChildProcessOutput.h b/src/ChildProcessOutput.h index 3279995..2f4859b 100644 --- a/src/ChildProcessOutput.h +++ b/src/ChildProcessOutput.h @@ -22,7 +22,6 @@ class ChildProcessOutputBuffer void forwardChildOutput(juce::ChildProcess& process, ChildProcessOutputBuffer& output); void flushChildOutput(ChildProcessOutputBuffer& output); -void forwardAvailableChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output); void forwardChildOutputToConsole(juce::ChildProcess& process, ChildProcessOutputBuffer& output); void flushChildOutputToConsole(ChildProcessOutputBuffer& output); diff --git a/tests/Tests.cpp b/tests/Tests.cpp index 8a3441c..266ca2b 100644 --- a/tests/Tests.cpp +++ b/tests/Tests.cpp @@ -342,10 +342,10 @@ class BridgeTests : public juce::UnitTest expect(options.has_value()); if (options) { - expect(options->buildWorkerMode); - expectEquals(options->buildSliceStart, 2); - expectEquals(options->buildSliceCount, 1); - expectEquals(options->buildSliceTotal, 3); + expect(halionbridge::detail::AppOptionsAccess::isBuildWorkerMode(*options)); + expectEquals(halionbridge::detail::AppOptionsAccess::buildSliceStart(*options), 2); + expectEquals(halionbridge::detail::AppOptionsAccess::buildSliceCount(*options), 1); + expectEquals(halionbridge::detail::AppOptionsAccess::buildSliceTotal(*options), 3); expectEquals(options->timeoutSeconds, 5); expect(options->forceScan); expect(options->pluginPathOverride.has_value()); From 7d044e1f1280b4bc46516bc223939d1a4756bb6b Mon Sep 17 00:00:00 2001 From: "B. Zeiss" Date: Mon, 15 Jun 2026 09:17:08 +0200 Subject: [PATCH 5/5] code review corrections. --- DEVELOPMENT.md | 6 +- HALION-LUA.md | 4 +- README.md | 42 ++--- cli/Main.cpp | 3 +- .../common/src/BuildDirectoryEmitter.cpp | 158 +++++++++++++++++- halion-lua/builder.lua | 46 +++-- include/halionbridge/Bridge.h | 2 +- src/Bridge.cpp | 34 ++++ src/BuildWorker.cpp | 8 + tests/Tests.cpp | 86 +++++++++- 10 files changed, 340 insertions(+), 49 deletions(-) diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 9a77f49..d066a5a 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -160,7 +160,7 @@ The built-in `sfz` converter scans a source directory for `.sfz` files. It proce Converter CLI diagnostics can be streamed through `ConverterRunContext` while still returning the full diagnostic vector for tests and embedded callers. Use the streaming path for long-running converters so scan, per-file conversion, warnings, and final summaries are visible immediately. -`converters/common` owns reusable converter infrastructure: converter registration, build-directory writing, Lua string escaping, overwrite checks, and deterministic LF output. Format-specific converter modules should pass generated Lua files to this common emitter instead of rewriting build-file logic. Generated Lua files have a role: `buildEntrypoint` files are written and listed in `halionbridge_build.lua`, while `helperModule` files are written but not listed. The emitter accepts only flat relative `.lua` filenames, rejects absolute paths, root paths, `.`/`..`, nested paths, non-`.lua` names, duplicate filenames, duplicate build-entrypoint module names, helper-only output, and reserved helper filenames used as build entrypoints. Validation and overwrite checks run before writing; later filesystem write failures can still leave partial output. +`converters/common` owns reusable converter infrastructure: converter registration, build-directory writing, Lua string escaping, overwrite checks, and deterministic LF output. Format-specific converter modules should pass generated Lua files to this common emitter instead of rewriting build-file logic. Generated Lua files have a role: `buildEntrypoint` files are written and listed in `halionbridge_build.lua`, while `helperModule` files are written but not listed. The emitter accepts only flat relative `.lua` filenames, rejects absolute paths, root paths, `.`/`..`, nested paths, non-`.lua` names, duplicate filenames, duplicate build-entrypoint module names, helper-only output, and reserved helper filenames used as build entrypoints. Validation and overwrite checks run before writing. File contents are written to same-directory transaction files first, then committed with backups for overwritten targets so a late filesystem failure can roll back without leaving a half-updated build directory. `converters/sfz` owns all sfizz interaction. sfizz is an implementation dependency only; sfizz headers and types must not appear in public converter APIs. The v1 SFZ mapping creates one HALion layer preset per source SFZ and maps sample paths, sample playback range, key ranges, velocity ranges, root key, sustain-loop fields, static amp-envelope fields, static pitch/tuning fields, static gain/velocity/pan fields, and simple tone fields into the tested HALion sample-zone Lua pattern. Generated Lua treats zone type, sample filename, key range, velocity range, root key assignment, and amp-envelope assignment as required; failure returns an unsuccessful build result before saving the preset. Optional naming, sample playback range, sustain-loop parameter assignment, sample oscillator level, amp velocity-to-level, amp pan, filter fields, and static pitch/tuning parameter assignment remain non-fatal and are logged by the generated build script when HALion rejects them. SFZ `offset` maps to HALion `SampleOsc.SampleStart`; SFZ inclusive `end=N` maps to HALion `SampleOsc.SampleEnd=N+1`, and the helper writes sample end before sample start so HALion does not clamp start markers against an unset end marker. For WAV-backed regions without explicit `end=`, the converter emits `sample_playback.natural_end` from the WAV frame count so the helper can initialize `SampleOsc.SampleEnd` before optional start or loop marker writes without treating the file-derived length as an authored SFZ end. SFZ `loop_mode=loop_continuous` maps to HALion sustain loop mode `1`, `loop_mode=loop_sustain` maps to HALion sustain loop mode `4`, and generated loop data writes `loop_end + 1` only to `SampleOsc.SustainLoopEndA`; loop assignment no longer truncates `SampleOsc.SampleEnd` to the loop end. Generated SFZ scripts write region progress only periodically so large SFZ files avoid thousands of progress-marker `savePreset()` calls. Static gain conversion maps SFZ `volume` in dB plus the verified `+7.8 dB` HALion compensation to per-zone `SampleOsc.Level`; generated layers disable velocity-setting inheritance and set `VelocityToLevelCurve=1`, which probe `030` verified as HALion's squared Main velocity curve for SFZ default `amp_veltrack=100`. `amp_veltrack` is clamped to SFZ's -100..100 percent range with a converter warning before being written to HALion `Amp Env.VelocityToLevel`; probe `032` keeps `amp_veltrack=200` as an out-of-range clamp regression. SFZ `pan` maps to zone `Amp.Pan`; probe `033` verified center, half-left/right, and full-left/right against sforzando, and changing HALion pan law made the match worse, so HALion's default pan law is intentionally left unchanged. Static pitch conversion maps `pitch_keycenter` to `SampleOsc.Rootkey`, combines `transpose * 100 + tune` into `SampleOsc.Tune` with a -1200..1200 cent clamp, and maps `pitch_keytrack` to `Pitch.CenterKey` plus `Pitch.KeyFollow` with a -200..200 percent clamp. Static filter conversion is intentionally rough: supported sforzando-recognized LPF, HPF, BPF, BRF, `lsh`, `hsh`, and `peq` families map to HALion Classic filter type/mode/shape/cutoff/resonance values selected in probes `059`/`060`; `lpf_2p` keeps its earlier piecewise calibrated resonance table from probes `056`/`057`. State-variable `_sv` filter names and `pink` remain unsupported warnings rather than silent approximations. @@ -280,9 +280,9 @@ cd halion-lua - The compile-time assets are `halion-lua/builder_bootstrap.vstpreset` and `halion-lua/builder.lua`, embedded through the `halionbridge_assets` binary-data target. The checked-in `halion-lua/builder_bootstrap.lua` is the canonical source text for the inline Lua saved inside `builder_bootstrap.vstpreset`; it should call `require("halionbridge_runtime")`. - For builder runs, the bridge writes temporary `halionbridge_runtime.lua` and `halionbridge_builder.lua` files into the HALion user scripts directory under `Documents/Steinberg/HALion/Library/scripts`. HALion Lua does not reliably expose the host process environment or current working directory to `require()`, so this generated runtime module is the authoritative handoff. It sets the Lua global `HALIONBRIDGE_RUNTIME_ROOT`, optional build-slice globals, prepends the build directory to `package.path` if needed, clears cached runtime/builder modules, and then loads the temporary embedded builder module. Previous files with the same names are restored after the run; otherwise the temporary files are deleted. Because these filenames and the working directory/environment handoff are shared process/user state, halionbridge acquires a fail-fast inter-process lock for the whole runtime-staging lifetime and rejects overlapping runs. - Running `halionbridge` without arguments is equivalent to `halionbridge --help` and exits successfully after printing usage. -- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after writing the final status marker. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the CLI parent runs normal headless chunks in hidden `--halionbridge-build-worker` child processes, up to 15 scripts per worker by default. The parent polls the build directory for progress markers while the worker is running, so progress can be printed even when the worker is blocked inside HALion, provided HALion has actually flushed the marker file to disk. The parent also emits a 5-second build-worker heartbeat for long chunks so large synchronous HALion invocations do not look stalled. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. GUI runs, `--nokill` inspection runs, embedded callers without `AppOptions::executableFile`, and dynamic/unparseable build files run in-process. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. Because current build script entrypoints run as synchronous HALion global/module code, marker visibility still depends on HALion's `savePreset()` behavior; if HALion defers marker file creation internally, the host will still observe those markers in batches. +- For HALion batch builds, `halion-lua/builder.lua` is a neutral build script runner. It loads build script modules listed in `halionbridge_build.lua`, passes each module a documented `ctx` API, forwards file-level progress through temporary `hbp_*.vstpreset` marker presets, aggregates build script results, and writes `halionbridge_status_ok.vstpreset` or `halionbridge_status_failed.vstpreset` in the build directory using HALion's `savePreset()` API. Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms with `setScriptExecTimeOut()` when the API is available, then restores the previous value after the final status-marker write attempt even if marker writing fails. This is used instead of HALion `wait()`, because `wait()` is only valid inside callbacks while build script entrypoints run as controller global/module code. For statically parseable build files, the CLI parent runs normal headless chunks in hidden `--halionbridge-build-worker` child processes, up to 15 scripts per worker by default. The parent polls the build directory for progress markers while the worker is running, so progress can be printed even when the worker is blocked inside HALion, provided HALion has actually flushed the marker file to disk. The parent also emits a 5-second build-worker heartbeat for long chunks so large synchronous HALion invocations do not look stalled. `--build-chunk-size ` changes the chunk size, and `--fail-fast` stops after the first failed chunk. Lua build failures and per-chunk timeouts are recorded but later chunks continue by default, with a final nonzero exit if any chunk failed; setup, plugin, preset-apply, stop, and cleanup failures stop immediately. GUI runs, `--nokill` inspection runs, embedded callers without `AppOptions::executableFile`, and dynamic/unparseable build files run in-process. The bridge deletes stale status/progress files before applying the preset, deletes each consumed progress marker immediately after logging it, drains late progress markers when a terminal status marker appears, and performs one final progress-marker sweep after releasing HALion plugin resources. Successful OK status markers are deleted; failed status markers remain after failed runs for diagnostics. Because current build script entrypoints run as synchronous HALion global/module code, marker visibility still depends on HALion's `savePreset()` behavior; if HALion defers marker file creation internally, the host will still observe those markers in batches. - The CLI installs Ctrl+C/termination handlers before init, convert, scan-worker, build-worker, and build commands. Handlers set a shared stop flag. SFZ conversion checks the flag during directory traversal, between file conversions, and before writing generated files. In normal headless builds, the parent process does not load HALion directly; it supervises the active worker, lets it exit for up to 5 seconds after Ctrl+C, then kills it if HALion is still stuck and returns `RunResult::stopped`. A forced worker kill may skip worker-side marker cleanup, so stale marker cleanup on the next run remains part of the recovery path. GUI and `--nokill` inspection runs stay cooperative and in-process so HALion can release through normal C++ cleanup. - Build script modules own all HALion-specific build behavior: program presets, layer presets, zones, samples, and output filenames. Keep the build script API documented in `HALION-LUA.md` when changing builder or build script behavior. - Generic build script modules are arbitrary code, so `halionbridge_build.lua` entries are not treated as expected output preset names. Build completion is reported only through HALion-written `.vstpreset` marker files. -- The default build timeout is `0`, which waits forever and emits a startup warning. Set `--timeout-seconds ` only when a finite build timeout is desired. +- The default build timeout is 3600 seconds. Use `--timeout-seconds ` to choose another finite timeout, or `--no-timeout` / `--timeout-seconds 0` only for explicit manual inspection runs that should wait indefinitely. - `--nokill` is a diagnostics switch for inspecting HALion after the runtime and builder pipeline has run. When the bridge observes an OK marker, failed marker, timeout, or another processing-loop exit, it keeps the plugin instance alive instead of immediately hiding the GUI and releasing plugin resources. Terminal progress-marker draining still happens before the inspection hold, and final status/progress cleanup happens after the hold exits and HALion resources are released. In headless mode the process remains alive until Ctrl+C requests a graceful stop. With `--gui`, closing the GUI window leaves the inspection hold and then the bridge shuts down normally. In both cases normal C++ cleanup still runs, so temporary HALion runtime files are restored or deleted. diff --git a/HALION-LUA.md b/HALION-LUA.md index 399bc22..594961b 100644 --- a/HALION-LUA.md +++ b/HALION-LUA.md @@ -15,7 +15,7 @@ return { Module names may be listed with or without `.lua`. They are loaded with `require`, so they must be resolvable from the build directory passed to halionbridge. The host-side helper `Bridge::parseBuildFileModuleNames()` inspects the common top-level list shape `return { "module_a", "module_b" }`; it is not a full Lua parser, and it ignores quoted strings in line comments, Lua long comments, nested tables, local variables, and metadata fields. -For statically parseable build files, halionbridge runs the list in host-controlled chunks of one module by default. Each chunk launches a fresh HALion instance and the generated runtime module sets `HALIONBRIDGE_BUILD_SLICE_START`, `HALIONBRIDGE_BUILD_SLICE_COUNT`, and `HALIONBRIDGE_BUILD_TOTAL` before loading this builder. The builder uses those globals only to select the requested list slice and to report file-level progress against the full list. Build script modules and the `ctx` API are unchanged. If the host cannot statically parse the build file, it falls back to one full-list invocation. +For statically parseable build files, halionbridge runs the list in host-controlled chunks of up to 15 modules by default. Each chunk launches a fresh HALion instance and the generated runtime module sets `HALIONBRIDGE_BUILD_SLICE_START`, `HALIONBRIDGE_BUILD_SLICE_COUNT`, and `HALIONBRIDGE_BUILD_TOTAL` before loading this builder. The builder uses those globals only to select the requested list slice and to report file-level progress against the full list. Build script modules and the `ctx` API are unchanged. If the host cannot statically parse the build file, it falls back to one full-list invocation. `halionbridge init ` can generate a simple `halionbridge_build.lua` from top-level `.lua` files. It sorts filenames, keeps the `.lua` suffix in each entry, and excludes halionbridge infrastructure files such as `halionbridge_runtime.lua`, `halionbridge_builder.lua`, `halionbridge_build.lua`, and `builder_bootstrap.lua`; converter-owned infrastructure helpers may also be excluded. It does not recurse into subdirectories and does not launch HALion. Review the generated list before building: ordinary helper modules such as `helpers.lua` or `shared_mapping.lua` are still top-level Lua files, but they should be removed from `halionbridge_build.lua` unless they return a valid build script entrypoint. @@ -92,7 +92,7 @@ ctx.progress(done, total, message) `ctx.progress` is build-script-defined progress. `done` and `total` can represent files, zones, presets, phases, or any other work unit meaningful to that build script. -Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms when HALion exposes `getScriptExecTimeOut()` and `setScriptExecTimeOut()`, then restores the previous value after writing the final status marker. Build scripts should not call HALion `wait()` for this purpose; HALion only allows `wait()` inside callbacks, while build script entrypoints run as controller global/module code. +Before running the batch, the builder raises HALion's controller script execution timeout to 600000 ms when HALion exposes `getScriptExecTimeOut()` and `setScriptExecTimeOut()`, then restores the previous value after the final status-marker write attempt even if marker writing fails. Build scripts should not call HALion `wait()` for this purpose; HALion only allows `wait()` inside callbacks, while build script entrypoints run as controller global/module code. ## Build Script Result diff --git a/README.md b/README.md index 0f179cc..e3d7c98 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ For source formats such as SFZ, halionbridge can also generate the Lua build dir ## What This Is - A batch automation host for HALion Lua build scripts. -- A JUCE-free C++23 library interface with a command-line frontend for running the same builder workflow. +- A C++23 library interface with a command-line frontend for running the same builder workflow. - Infrastructure for converters and scripted HALion instrument builds. - A way to produce real HALion `.vstpreset` artifacts through HALion itself. The original HALion plugin is required and must be installed. @@ -34,15 +34,21 @@ For source formats such as SFZ, halionbridge can also generate the Lua build dir - A graphical preset authoring tool. - A direct writer for undocumented HALion preset file internals. -## Use Cases +### Use Cases -- **Batch-generate HALion presets from structured instrument data:** Generate Lua build scripts from another format or database (e.g. CSV files), run them through halionbridge, and let HALion save the resulting `.vstpreset` files. -- **Build sample-mapped instruments programmatically:** Create layers, zones, key ranges, velocity ranges, sample assignments, loop settings, and HALion parameters from Lua instead of assembling them manually in the GUI. -- **Converter workflows:** Generate Lua from source formats such as SFZ, run that Lua inside HALion with halionbridge, and let HALion handle the preset-writing step. -- **SFZ-to-HALion Lua conversion:** Convert a directory of `.sfz` files into a halionbridge build directory, then run that build directory to create layer `.vstpreset` files. -- **Regenerate preset libraries reproducibly:** Treat HALion presets as build artifacts that can be recreated from source Lua build scripts and sample files. -- **Run HALion Lua build scripts in CI-like or headless workflows:** Execute scripted HALion builds without opening an audio device, with success/failure reported through marker presets (requires the licensed and activated HALion plugin on the build server). -- **Debug scripted HALion builds with optional GUI inspection:** Use `--gui` or `--nokill` when a build script needs visual inspection after running. +* **Programmatic Instrument Creation:** Build sample-mapped instruments—including layers, zones, key ranges, velocity ranges, sample assignments, and parameters—directly via Lua instead of manually assembling them in the HALion GUI. +* **Automated Converter Workflows:** Generate `.vstpreset` files automatically from structured data (e.g., CSV databases) or existing source formats (like SFZ) by bridging them through Lua build scripts. +* **Reproducible Preset Libraries:** Treat HALion presets as reliable build artifacts that can be entirely regenerated from source Lua scripts and sample files. +* **CI and Headless Automation:** Execute scripted HALion builds seamlessly on build servers or CI pipelines without opening an audio device. +* **Script Debugging:** Visually inspect scripted builds by using `--gui` or `--nokill` flags to keep the HALion GUI open after a run. + +### Features + +* **Headless & Offline Processing Loop:** Runs as an embeddable standalone console app. It utilizes an offline manual processing loop to keep the HALion plugin alive while Lua scripts execute, completely bypassing the need for an active audio device. +* **Native Format Conversion Setup:** Commands like `halionbridge convert sfz` parse source directories and generate a flat or explicitly routed `halionbridge` build directory. Users can review or edit these generated Lua scripts before triggering the final build. +* **Marker-Based Status Detection:** Tracks build progress and completion by waiting for HALion to write `.vstpreset` status markers into the build directory. Temporary progress markers are automatically cleaned up, while failure markers are preserved for diagnostics. +* **Embedded Bootstrap:** Automatically applies the bundled HALion bootstrap `.vstpreset` internally, meaning users only ever need to pass their target build directory to the CLI. +* **Generic Script Delegation:** The embedded builder acts as a blank slate; it simply loads the modules listed in `halionbridge_build.lua`, leaving the actual decision of *what* to build entirely to the Lua scripts. ## Usage @@ -80,8 +86,11 @@ The build directory must contain `halionbridge_build.lua` and the Lua build scri # Run the generic HALion Lua builder against a build directory ./halionbridge /path/to/build-directory -# By default, halionbridge waits forever and prints a warning. Set a timeout when desired. -./halionbridge /path/to/build-directory --timeout-seconds 3600 +# Builds time out after 3600 seconds by default. +./halionbridge /path/to/build-directory --timeout-seconds 1800 + +# Explicitly wait forever when manually inspecting a problematic build. +./halionbridge /path/to/build-directory --no-timeout # Build scripts run up to 15 scripts per HALion process by default and continue after failed chunks. # Use --build-chunk-size 1 for maximum isolation, or tune chunk size when desired. @@ -99,7 +108,7 @@ The build directory must contain `halionbridge_build.lua` and the Lua build scri ./halionbridge /path/to/build-directory --gui --nokill ``` -The SFZ converter is a setup step: it generates normal Lua build files and does not launch HALion. When no output directory is passed, generated files are written flat beside the source `.sfz` files; otherwise they are written to the requested build directory. Existing generated files are refused unless `--overwrite` is supplied, and conversion fails before writing if two inputs would produce the same output preset name. +The SFZ converter is a setup step: it generates normal Lua build files and does not launch HALion. When no output directory is passed, generated files are written flat beside the source `.sfz` files; otherwise they are written to the requested build directory. Existing generated files are refused unless `--overwrite` is supplied, and conversion fails before writing if two inputs would produce the same output preset name. Generated build files are staged before commit so a write failure does not leave a half-updated build directory. Generated SFZ output includes an inspectable helper module, `halionbridge-sfz.lua`, plus one build entrypoint script per source `.sfz`. The current converter covers the common sample-mapping path: sample filenames, key/velocity ranges, root keys, playback ranges, sustain loops, gain, pan, static amplitude envelopes, static pitch/tuning, a simple static pitch-envelope subset, and rough static filter approximations. Unsupported or unverified SFZ features are reported as warnings instead of being silently treated as exact conversions. Detailed mapping notes and current parity limits live in `DEVELOPMENT.md`. @@ -109,12 +118,3 @@ Only one halionbridge build can run at a time for a user account. HALion resolve Press Ctrl+C to stop a run. Conversion commands stop at converter checkpoints before writing more generated files. Normal headless HALion builds stop the active worker process and do not start later chunks. GUI and `--nokill` inspection runs remain cooperative so HALion can clean up normally. -## Features -- **Headless Execution:** Runs as a standalone console app. -- **Embedded Bootstrap VSTPreset:** Applies the bundled HALion bootstrap preset internally; users only pass a build directory. -- **Offline Processing Loop:** Runs a manual processing loop without opening an audio device, to keep HALion alive while LUA instrument scripts execute. -- **Generic HALion Lua Build Scripts:** The embedded builder loads build script modules listed in `halionbridge_build.lua`; the build script modules decide what to build. -- **Converter Setup Commands:** `halionbridge convert sfz` creates a normal halionbridge build directory from `.sfz` files without launching HALion. When no output directory is provided, generated Lua/build files are written flat into the source directory; an explicit output directory can still be passed. The generated Lua build scripts can be reviewed or edited before running `halionbridge `. Converter-generated Lua filenames are kept inside the build root; unsafe generated paths are rejected. Converter-generated helper modules may be written beside build scripts without being listed in `halionbridge_build.lua`. -- **Build Completion Detection:** Runs static `halionbridge_build.lua` lists in chunks of up to 15 scripts per HALion worker process by default, relaunching HALion for each chunk so later chunks can continue after a failed chunk and Ctrl+C can interrupt stuck headless builds. Waits for HALion-written `.vstpreset` status marker presets in the build directory in order to know when each chunk is finished. Temporary progress marker presets are cleaned after logging; failed status markers may remain after failed builds for diagnostics. - -See `HALION-LUA.md` for the Lua build script API used by the generic builder workflow. diff --git a/cli/Main.cpp b/cli/Main.cpp index 4fac1a0..b100fc0 100644 --- a/cli/Main.cpp +++ b/cli/Main.cpp @@ -86,7 +86,8 @@ void printHelp() << " --plugin Override the HALion 7 VST3 path.\n" << "\n" << "Runtime options:\n" - << " --timeout-seconds Build completion timeout. Defaults to 0, which waits forever.\n" + << " --timeout-seconds Build completion timeout. Defaults to 3600 seconds.\n" + << " --no-timeout Wait indefinitely. Equivalent to --timeout-seconds 0.\n" << " --build-chunk-size Number of Lua build scripts per HALion run. Defaults to 15.\n" << " --fail-fast Stop after the first failed Lua build chunk.\n" << " --gui Use JUCE's GUI-capable VST3 host format and show HALion's editor.\n" diff --git a/converters/common/src/BuildDirectoryEmitter.cpp b/converters/common/src/BuildDirectoryEmitter.cpp index 8e7d2e1..4bb2a65 100644 --- a/converters/common/src/BuildDirectoryEmitter.cpp +++ b/converters/common/src/BuildDirectoryEmitter.cpp @@ -1,9 +1,11 @@ #include "halionbridge_converters/BuildDirectoryEmitter.h" #include +#include #include #include #include +#include #include #include @@ -30,6 +32,25 @@ bool writeTextFile(const std::filesystem::path& path, const std::string& text) return stream.good(); } +std::filesystem::path makeTransactionPath(const std::filesystem::path& directory, const char* label, const size_t index, + const std::filesystem::path& targetFileName) +{ + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + for (auto attempt = 0; attempt < 1000; ++attempt) + { + auto name = std::ostringstream{}; + name << ".halionbridge-" << label << "-" << now << "-" << index << "-" << attempt << "-" + << targetFileName.filename().generic_string(); + + auto path = directory / name.str(); + std::error_code error; + if (!std::filesystem::exists(path, error) && !error) + return path; + } + + return {}; +} + std::string normalizedKey(std::string text) { std::replace(text.begin(), text.end(), '\\', '/'); @@ -78,6 +99,49 @@ bool isReservedHelperEntrypointName(const GeneratedLuaScript& script) return normalizedKey(script.fileName) == kSfzHelperFileName || normalizedModuleNameKey(script.moduleName) == "halionbridge-sfz"; } +struct PendingWrite +{ + std::filesystem::path target; + std::filesystem::path temporary; + std::filesystem::path backup; + std::string text; + bool includeInGeneratedFiles = false; + bool hadExistingTarget = false; + bool committed = false; +}; + +void cleanupTransactionFiles(std::span writes) +{ + for (const auto& write : writes) + { + std::error_code error; + if (!write.temporary.empty()) + std::filesystem::remove(write.temporary, error); + if (!write.backup.empty()) + std::filesystem::remove(write.backup, error); + } +} + +void rollbackWrites(std::span writes) +{ + for (auto it = writes.rbegin(); it != writes.rend(); ++it) + { + std::error_code error; + if (it->committed) + std::filesystem::remove(it->target, error); + + if (it->hadExistingTarget && !it->backup.empty() && std::filesystem::exists(it->backup, error)) + { + error.clear(); + std::filesystem::rename(it->backup, it->target, error); + } + + error.clear(); + if (!it->temporary.empty()) + std::filesystem::remove(it->temporary, error); + } +} + } // namespace std::string luaQuotedString(const std::string_view text) @@ -212,6 +276,18 @@ BuildDirectoryResult writeBuildDirectory(const BuildDirectoryRequest& request) } } } + else + { + for (const auto& outputFile : outputFiles) + { + if (std::filesystem::exists(outputFile, error) && !std::filesystem::is_regular_file(outputFile, error)) + { + result.diagnostics.push_back( + makeError(outputFile, "not-regular-file", outputFile.string() + " exists but is not a regular file.")); + return result; + } + } + } std::ostringstream buildFileText; buildFileText << "return {\n"; @@ -222,22 +298,88 @@ BuildDirectoryResult writeBuildDirectory(const BuildDirectoryRequest& request) } buildFileText << "}\n"; - if (!writeTextFile(result.buildFile, buildFileText.str())) + auto pendingWrites = std::vector(); + pendingWrites.reserve(request.scripts.size() + 1); + pendingWrites.push_back(PendingWrite{result.buildFile, {}, {}, buildFileText.str(), false}); + for (const auto& script : request.scripts) + pendingWrites.push_back(PendingWrite{request.outputDirectory / script.fileName, {}, {}, script.luaSource, true}); + + for (size_t i = 0; i < pendingWrites.size(); ++i) { - result.diagnostics.push_back(makeError(result.buildFile, "write-failed", "Failed to write " + result.buildFile.string())); - return result; + auto& write = pendingWrites[i]; + write.temporary = makeTransactionPath(request.outputDirectory, "tmp", i, write.target.filename()); + if (write.temporary.empty()) + { + result.diagnostics.push_back( + makeError(write.target, "write-failed", "Failed to reserve temporary path for " + write.target.string())); + cleanupTransactionFiles(pendingWrites); + return result; + } + + if (!writeTextFile(write.temporary, write.text)) + { + result.diagnostics.push_back(makeError(write.target, "write-failed", "Failed to write " + write.target.string())); + cleanupTransactionFiles(pendingWrites); + return result; + } } - for (const auto& script : request.scripts) + for (size_t i = 0; i < pendingWrites.size(); ++i) { - const auto path = request.outputDirectory / script.fileName; - if (!writeTextFile(path, script.luaSource)) + auto& write = pendingWrites[i]; + if (std::filesystem::exists(write.target, error)) + { + if (error) + { + result.diagnostics.push_back(makeError(write.target, "write-failed", "Failed to inspect " + write.target.string())); + rollbackWrites(pendingWrites); + return result; + } + + write.hadExistingTarget = true; + write.backup = makeTransactionPath(request.outputDirectory, "bak", i, write.target.filename()); + if (write.backup.empty()) + { + result.diagnostics.push_back( + makeError(write.target, "write-failed", "Failed to reserve backup path for " + write.target.string())); + rollbackWrites(pendingWrites); + return result; + } + + error.clear(); + std::filesystem::rename(write.target, write.backup, error); + if (error) + { + result.diagnostics.push_back(makeError(write.target, "write-failed", "Failed to back up " + write.target.string())); + rollbackWrites(pendingWrites); + return result; + } + } + else if (error) { - result.diagnostics.push_back(makeError(path, "write-failed", "Failed to write " + path.string())); + result.diagnostics.push_back(makeError(write.target, "write-failed", "Failed to inspect " + write.target.string())); + rollbackWrites(pendingWrites); return result; } - result.generatedLuaFiles.push_back(path); + error.clear(); + std::filesystem::rename(write.temporary, write.target, error); + if (error) + { + result.diagnostics.push_back(makeError(write.target, "write-failed", "Failed to write " + write.target.string())); + rollbackWrites(pendingWrites); + return result; + } + + write.committed = true; + } + + cleanupTransactionFiles(pendingWrites); + + for (const auto& write : pendingWrites) + { + if (write.includeInGeneratedFiles) + result.generatedLuaFiles.push_back(write.target); } result.succeeded = true; diff --git a/halion-lua/builder.lua b/halion-lua/builder.lua index ae465c7..3d203c0 100644 --- a/halion-lua/builder.lua +++ b/halion-lua/builder.lua @@ -251,6 +251,13 @@ local function emit(kind, message) writeProgress(kind, message) end +local function emitSafely(kind, message) + local ok = pcall(emit, kind, message) + if not ok then + pcall(print, tostring(message or "")) + end +end + local function extendScriptExecutionTimeout() -- HALion's default controller script timeout is short for build-time scripts -- that construct hundreds or thousands of zones. This API is valid from @@ -271,7 +278,7 @@ local function extendScriptExecutionTimeout() return nil end - emit("info", "Raised HALion script execution timeout from " .. current .. " ms to " .. BUILD_SCRIPT_TIMEOUT_MS .. " ms.") + emitSafely("info", "Raised HALion script execution timeout from " .. current .. " ms to " .. BUILD_SCRIPT_TIMEOUT_MS .. " ms.") return current end @@ -287,16 +294,29 @@ local function writeStatus(status) -- halionbridge watches for these marker presets in the build directory. -- Markers are written through savePreset() because HALion scripting may not -- have general filesystem write access in every context. - local markerLayer = Layer() - local markerName = status.ok and "halionbridge_status_ok" or "halionbridge_status_failed" - local markerPath = status.ok and STATUS_OK_PATH or STATUS_FAILED_PATH - markerLayer:setName(markerName) + local ok, success, errorMessage = pcall(function() + local markerLayer = Layer() + local markerName = status.ok and "halionbridge_status_ok" or "halionbridge_status_failed" + local markerPath = status.ok and STATUS_OK_PATH or STATUS_FAILED_PATH + markerLayer:setName(markerName) + + local markerSaved = savePreset(markerPath, markerLayer, "H7") + if markerSaved then + return true, nil + end - local success = savePreset(markerPath, markerLayer, "H7") - if not success then - emit("warning", "Warning: Could not write build status marker: " .. markerPath) + return false, "Could not write build status marker: " .. markerPath + end) + + if not ok then + return false, tostring(success) end - return success + + if success then + return true, nil + end + + return false, tostring(errorMessage) end local function normalizeModuleName(moduleName) @@ -520,7 +540,7 @@ if RUN_BUILD then failed = 1, message = "Unhandled Lua error: " .. tostring(status), } - emit("error", "Error: " .. status.message) + emitSafely("error", "Error: " .. status.message) end status = normalizeResult(status) @@ -528,6 +548,10 @@ if RUN_BUILD then status.message = status.ok and "Batch complete." or "Batch failed." end - writeStatus(status) + local statusWritten, statusError = writeStatus(status) restoreScriptExecutionTimeout(previousScriptTimeout) + + if not statusWritten then + emitSafely("error", "Error: " .. tostring(statusError)) + end end diff --git a/include/halionbridge/Bridge.h b/include/halionbridge/Bridge.h index 40d6187..f7e4baa 100644 --- a/include/halionbridge/Bridge.h +++ b/include/halionbridge/Bridge.h @@ -24,7 +24,7 @@ struct AppOptions std::optional buildDirectory; std::optional pluginPathOverride; std::optional executableFile; - int timeoutSeconds = 0; + int timeoutSeconds = 3600; int buildChunkSize = 15; bool showGui = false; bool noKill = false; diff --git a/src/Bridge.cpp b/src/Bridge.cpp index d725111..3e0a480 100644 --- a/src/Bridge.cpp +++ b/src/Bridge.cpp @@ -809,6 +809,8 @@ std::optional Bridge::parseArguments(const std::vector& { AppOptions options; std::optional positionalBuildDirectory; + auto noTimeoutRequested = false; + auto positiveTimeoutRequested = false; for (int i = 0; i < static_cast(args.size()); ++i) { @@ -840,6 +842,17 @@ std::optional Bridge::parseArguments(const std::vector& { options.failFast = true; } + else if (arg == "--no-timeout") + { + if (positiveTimeoutRequested) + { + log::error("--no-timeout cannot be combined with a positive --timeout-seconds value."); + return std::nullopt; + } + + noTimeoutRequested = true; + options.timeoutSeconds = 0; + } else if (arg == "--timeout-seconds" && i + 1 < static_cast(args.size())) { auto parsed = parseNonNegativeInt(toJuceString(std::string_view(args[static_cast(++i)]))); @@ -849,6 +862,27 @@ std::optional Bridge::parseArguments(const std::vector& return std::nullopt; } + if (*parsed == 0) + { + if (positiveTimeoutRequested) + { + log::error("--timeout-seconds 0 cannot be combined with a positive --timeout-seconds value."); + return std::nullopt; + } + + noTimeoutRequested = true; + } + else + { + if (noTimeoutRequested) + { + log::error("--timeout-seconds cannot be combined with --no-timeout or --timeout-seconds 0."); + return std::nullopt; + } + + positiveTimeoutRequested = true; + } + options.timeoutSeconds = *parsed; } else if (arg == "--build-chunk-size" && i + 1 < static_cast(args.size())) diff --git a/src/BuildWorker.cpp b/src/BuildWorker.cpp index d8091b3..13baad6 100644 --- a/src/BuildWorker.cpp +++ b/src/BuildWorker.cpp @@ -137,6 +137,10 @@ std::optional parseBuildWorkerArguments(std::span bridgeArgs.push_back(arg); bridgeArgs.push_back(args[++i]); } + else if (arg == "--no-timeout") + { + bridgeArgs.push_back(arg); + } else if (arg == "--force-scan") { bridgeArgs.push_back(arg); @@ -196,6 +200,10 @@ juce::StringArray makeBuildWorkerCommand(const AppOptions& options, const int sl command.add("--timeout-seconds"); command.add(juce::String(options.timeoutSeconds)); } + else + { + command.add("--no-timeout"); + } if (options.forceScan) command.add("--force-scan"); diff --git a/tests/Tests.cpp b/tests/Tests.cpp index 266ca2b..de2a89b 100644 --- a/tests/Tests.cpp +++ b/tests/Tests.cpp @@ -196,7 +196,7 @@ class BridgeTests : public juce::UnitTest { expect(options->buildDirectory.has_value()); expect(*options->buildDirectory == halionbridge::detail::toStdPath(tempDir)); - expectEquals(options->timeoutSeconds, 0); + expectEquals(options->timeoutSeconds, 3600); expect(!options->noKill); expect(!options->forceScan); } @@ -240,6 +240,22 @@ class BridgeTests : public juce::UnitTest tempDir.createDirectory(); tempDir.getChildFile("halionbridge_build.lua").replaceWithText("return {}"); + { + std::vector args = {tempDir.getFullPathName().toStdString()}; + auto options = halionbridge::Bridge::parseArguments(args); + expect(options.has_value()); + if (options) + expectEquals(options->timeoutSeconds, 3600); + } + + { + std::vector args = {tempDir.getFullPathName().toStdString(), "--no-timeout"}; + auto options = halionbridge::Bridge::parseArguments(args); + expect(options.has_value()); + if (options) + expectEquals(options->timeoutSeconds, 0); + } + { std::vector args = {tempDir.getFullPathName().toStdString(), "--timeout-seconds", "0"}; auto options = halionbridge::Bridge::parseArguments(args); @@ -274,6 +290,18 @@ class BridgeTests : public juce::UnitTest expect(!options.has_value()); } + { + std::vector args = {tempDir.getFullPathName().toStdString(), "--no-timeout", "--timeout-seconds", "45"}; + auto options = halionbridge::Bridge::parseArguments(args); + expect(!options.has_value()); + } + + { + std::vector args = {tempDir.getFullPathName().toStdString(), "--timeout-seconds", "45", "--no-timeout"}; + auto options = halionbridge::Bridge::parseArguments(args); + expect(!options.has_value()); + } + tempDir.deleteRecursively(); } @@ -363,12 +391,34 @@ class BridgeTests : public juce::UnitTest expect(command.contains("--plugin")); expect(command.contains("--timeout-seconds")); expect(command.contains("--force-scan")); + expect(!command.contains("--no-timeout")); expect(!command.contains("--build-chunk-size")); expect(!command.contains("--fail-fast")); expect(!command.contains("--gui")); expect(!command.contains("--nokill")); } + auto noTimeoutWorkerArgs = std::vector{halionbridge::detail::kBuildWorkerArgument, + tempDir.getFullPathName().toStdString(), + "--build-slice-start", + "1", + "--build-slice-count", + "1", + "--build-slice-total", + "3", + "--no-timeout"}; + auto noTimeoutOptions = halionbridge::detail::parseBuildWorkerArguments(noTimeoutWorkerArgs); + expect(noTimeoutOptions.has_value()); + if (noTimeoutOptions) + { + expectEquals(noTimeoutOptions->timeoutSeconds, 0); + const auto executable = juce::File::getSpecialLocation(juce::File::currentExecutableFile); + noTimeoutOptions->executableFile = halionbridge::detail::toStdPath(executable); + const auto command = halionbridge::detail::makeBuildWorkerCommand(*noTimeoutOptions, 1, 1, 3); + expect(command.contains("--no-timeout")); + expect(!command.contains("--timeout-seconds")); + } + auto invalidZeroStart = std::vector{halionbridge::detail::kBuildWorkerArgument, tempDir.getFullPathName().toStdString(), "--build-slice-start", @@ -774,6 +824,37 @@ class BridgeTests : public juce::UnitTest request.overwrite = true; auto overwritten = halionbridge::converters::writeBuildDirectory(request); expect(overwritten.succeeded); + expect(buildFile.loadFileAsString().contains("\"001_a.lua\"")); + expect(tempDir.getChildFile("001_a.lua").loadFileAsString() == "return {}\n"); + + juce::Array transactionFiles; + tempDir.findChildFiles(transactionFiles, juce::File::findFiles, false, ".halionbridge-*"); + expectEquals(transactionFiles.size(), 0); + + tempDir.deleteRecursively(); + } + + beginTest("Converter Emitter - rejects non-regular overwrite targets before writing"); + { + auto tempDir = cleanTempDirectory("halionbridge_converter_emitter_nonregular"); + expect(tempDir.createDirectory()); + expect(tempDir.getChildFile("halionbridge_build.lua").replaceWithText("return { \"manual.lua\" }\n")); + expect(tempDir.getChildFile("valid.lua").createDirectory()); + + auto request = halionbridge::converters::BuildDirectoryRequest{}; + request.outputDirectory = halionbridge::detail::toStdPath(tempDir); + request.overwrite = true; + request.scripts.push_back(halionbridge::converters::GeneratedLuaScript{"valid.lua", "valid.lua", "return {}\n"}); + + auto result = halionbridge::converters::writeBuildDirectory(request); + expect(!result.succeeded); + expect(containsDiagnosticCode(result.diagnostics, "not-regular-file")); + expect(tempDir.getChildFile("halionbridge_build.lua").loadFileAsString().contains("manual.lua")); + expect(tempDir.getChildFile("valid.lua").isDirectory()); + + juce::Array transactionFiles; + tempDir.findChildFiles(transactionFiles, juce::File::findFiles, false, ".halionbridge-*"); + expectEquals(transactionFiles.size(), 0); tempDir.deleteRecursively(); } @@ -1860,8 +1941,9 @@ class BridgeTests : public juce::UnitTest expect(builderLua.contains("setScriptExecTimeOut")); expect(builderLua.contains("Completed %d/%d files")); expect(builderLua.contains("Processing %d/%d: %s")); - expect(builderLua.contains("TODO: Re-enable numeric progress")); expect(builderLua.contains("emit(\"info\", tostring(message))")); + expect(builderLua.contains("emit(\"info\", \"Progress marker\")")); + expect(!builderLua.contains("emit(\"info\", line)")); expect(!builderLua.contains("function context.yield")); }