diff --git a/AGENTS.md b/AGENTS.md index 4b0eb2f..b8759be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,5 +92,10 @@ and [package checks](packaging/yabridge/README.md). that uses it. - Tests that redirect `XDG_CONFIG_HOME` or `XDG_RUNTIME_DIR` join the xUnit collection `xdg-config`. +- A test that needs a fake helper program builds it with + `ExecutableScript.Write`. A program the test process wrote itself + cannot be run while other test classes start helpers: the write + handle reaches their forked children and Linux answers exec with + "Text file busy". - Hardware behaviour is verified on hardware. When you cannot, say so in the pull request instead of describing a test you did not run. diff --git a/src/OpenXLR.Core/Mixing/PluginInstaller.cs b/src/OpenXLR.Core/Mixing/PluginInstaller.cs index 08aa219..f092a77 100644 --- a/src/OpenXLR.Core/Mixing/PluginInstaller.cs +++ b/src/OpenXLR.Core/Mixing/PluginInstaller.cs @@ -905,17 +905,22 @@ public PluginSetup Setup() }; } + /// + /// Null only when there is no controller to run. A controller that cannot + /// be started comes back as a failed run carrying the reason the operating + /// system gave, because that reason is the whole diagnosis. + /// private ProcessResult? Run(params string[] arguments) { if (_yabridgectl is null) return null; try { return ProcessRunner.Run(_yabridgectl, arguments, YabridgeTimeout, cLocale: false, environment: _managed?.ControllerEnvironment()); } - catch (Exception) { return null; } + catch (Exception ex) { return new(-1, [], "it could not be started: " + ex.Message, TimedOut: false, Truncated: false); } } private static string Tail(ProcessResult? result) { - if (result is null) return "it could not be started."; + if (result is null) return "it is not installed."; if (result.TimedOut) return "it did not finish in time."; string text = (result.Stderr.Length > 0 ? result.Stderr : Encoding.UTF8.GetString(result.Stdout)).Trim(); string[] lines = text.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); diff --git a/src/OpenXLR.Tests/ExecutableScript.cs b/src/OpenXLR.Tests/ExecutableScript.cs new file mode 100644 index 0000000..50fbf0f --- /dev/null +++ b/src/OpenXLR.Tests/ExecutableScript.cs @@ -0,0 +1,40 @@ +namespace OpenXLR.Tests; + +/// +/// A fake helper program, for tests that hand one to code which runs it. +/// +/// A test cannot write a program and then run it. Writing holds a write +/// handle on the file, a fork on any other thread copies that handle into +/// its child, and Linux refuses to run a file that any process can still +/// write, so the run fails with "Text file busy" until that child reaches +/// its own exec. Test classes run in parallel here, so the window is open +/// most of the time and the failure looks random. +/// +/// Every fake program is therefore a link to run-script.sh, which the +/// build copies next to the test assembly, and what the program does is a +/// plain text file beside the link. The test process writes no file it runs. +/// +internal static class ExecutableScript +{ + private static readonly string Runner = ShippedRunner(); + + /// Create as a program running . + public static string Write(string path, string body) + { + File.WriteAllText(path + ".body", body); + if (OperatingSystem.IsWindows()) return path; // no fork, and nothing here runs a shell script + File.Delete(path); // a stale link, not its target + File.CreateSymbolicLink(path, Runner); + return path; + } + + private static string ShippedRunner() + { + string runner = Path.Combine(AppContext.BaseDirectory, "run-script.sh"); + if (OperatingSystem.IsWindows()) return runner; + // chmod, never an open for writing, so this cannot make the file busy. + UnixFileMode mode = File.GetUnixFileMode(runner); + if (!mode.HasFlag(UnixFileMode.UserExecute)) File.SetUnixFileMode(runner, mode | UnixFileMode.UserExecute); + return runner; + } +} diff --git a/src/OpenXLR.Tests/ManagedYabridgeTests.cs b/src/OpenXLR.Tests/ManagedYabridgeTests.cs index 9902ae5..c750de2 100644 --- a/src/OpenXLR.Tests/ManagedYabridgeTests.cs +++ b/src/OpenXLR.Tests/ManagedYabridgeTests.cs @@ -124,12 +124,7 @@ public void ProcessEnvironmentOverridesAreConfinedToTheChild() Assert.Equal(original, Environment.GetEnvironmentVariable("OPENXLR_BRIDGE_CHILD_TEST")); } - private void Script(string name, string body) - { - string path = Path.Combine(Package, name); - File.WriteAllText(path, "#!/bin/sh\n" + body); - if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(path, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - } + private void Script(string name, string body) => ExecutableScript.Write(Path.Combine(Package, name), body); public void Dispose() { diff --git a/src/OpenXLR.Tests/OpenXLR.Tests.csproj b/src/OpenXLR.Tests/OpenXLR.Tests.csproj index 10b0f8e..4f73024 100644 --- a/src/OpenXLR.Tests/OpenXLR.Tests.csproj +++ b/src/OpenXLR.Tests/OpenXLR.Tests.csproj @@ -16,6 +16,10 @@ + + + + diff --git a/src/OpenXLR.Tests/PluginInstallerTests.cs b/src/OpenXLR.Tests/PluginInstallerTests.cs index 474e9a4..29adc41 100644 --- a/src/OpenXLR.Tests/PluginInstallerTests.cs +++ b/src/OpenXLR.Tests/PluginInstallerTests.cs @@ -225,9 +225,7 @@ public void WindowsPluginsWithoutYabridgeSaySo() private string FakeYabridgectl(string knownDirectory = "") { string log = Path.Combine(_root, "yabridgectl.log"); - string script = Path.Combine(_root, "yabridgectl"); - File.WriteAllText(script, $$""" - #!/bin/sh + return ExecutableScript.Write(Path.Combine(_root, "yabridgectl"), $$""" echo "$@" >> "{{log}}" case "$1" in --version) echo "yabridgectl 5.1.1" ;; @@ -237,9 +235,16 @@ private string FakeYabridgectl(string knownDirectory = "") esac exit 0 """); - if (!OperatingSystem.IsWindows()) // where these tests run; the analyser wants it said - File.SetUnixFileMode(script, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); - return script; + } + + [Fact] + public void AControllerThatCannotStartKeepsTheReasonTheSystemGave() + { + string missing = Path.Combine(_root, "no-yabridgectl-here"); + WindowsPluginFiles listed = new PluginInstaller(_lv2, _clap, _vst3, missing, null).ListWindowsPlugins(_picked); + Assert.False(listed.Ok); + Assert.Contains("could not be started", listed.Message); + Assert.Contains(missing, listed.Message); // without the reason the failure cannot be diagnosed } private string[] YabridgeCalls() => File.Exists(Path.Combine(_root, "yabridgectl.log")) diff --git a/src/OpenXLR.Tests/PluginScanDiagnosticsTests.cs b/src/OpenXLR.Tests/PluginScanDiagnosticsTests.cs index c72fa52..76a2cea 100644 --- a/src/OpenXLR.Tests/PluginScanDiagnosticsTests.cs +++ b/src/OpenXLR.Tests/PluginScanDiagnosticsTests.cs @@ -15,9 +15,8 @@ public void BridgeDiagnosticsRunsStatusOnlyAndPreservesItsFailure() string dir = Directory.CreateTempSubdirectory("bridge-diagnostics-").FullName; try { - string controller = Path.Combine(dir, "yabridgectl"); - File.WriteAllText(controller, "#!/bin/sh\n[ \"$1\" = status ] || exit 99\necho 'Example.vst3 -> missing wrapper'\necho 'bridge libraries missing' >&2\nexit 17\n"); - File.SetUnixFileMode(controller, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + string controller = ExecutableScript.Write(Path.Combine(dir, "yabridgectl"), + "[ \"$1\" = status ] || exit 99\necho 'Example.vst3 -> missing wrapper'\necho 'bridge libraries missing' >&2\nexit 17\n"); var installer = new PluginInstaller(dir, dir, dir, controller, null, winePrefix: Path.Combine(dir, "prefix")); var data = System.Text.Json.JsonSerializer.SerializeToElement(installer.Diagnostics(), new System.Text.Json.JsonSerializerOptions(System.Text.Json.JsonSerializerDefaults.Web)); Assert.Equal(controller, data.GetProperty("controller").GetString()); diff --git a/src/OpenXLR.Tests/WindowsIndividualPluginTests.cs b/src/OpenXLR.Tests/WindowsIndividualPluginTests.cs index ad12406..10bf926 100644 --- a/src/OpenXLR.Tests/WindowsIndividualPluginTests.cs +++ b/src/OpenXLR.Tests/WindowsIndividualPluginTests.cs @@ -28,9 +28,7 @@ private PluginInstaller Installer(string[] folders, string[]? excluded = null, s { File.WriteAllLines(Registry, folders); File.WriteAllLines(Exclusions, excluded ?? []); - string controller = Path.Combine(_root, "yabridgectl"); - File.WriteAllText(controller, $$""" - #!/bin/sh + string controller = ExecutableScript.Write(Path.Combine(_root, "yabridgectl"), $$""" printf '%s\n' "$*" >> '{{_root}}/calls' stage="$1" if [ "$1" = blacklist ]; then stage="$1 $2"; fi @@ -55,8 +53,6 @@ private PluginInstaller Installer(string[] folders, string[]? excluded = null, s esac exit 0 """); - if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(controller, - UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); return new(Path.Combine(_root, "lv2"), Path.Combine(_root, "clap"), Path.Combine(_root, "vst3"), controller, "/bin/true", windowsImportDirectory: Path.Combine(_root, "imports")); } diff --git a/src/OpenXLR.Tests/WindowsPluginFolderTests.cs b/src/OpenXLR.Tests/WindowsPluginFolderTests.cs index 5fc71ec..7025d27 100644 --- a/src/OpenXLR.Tests/WindowsPluginFolderTests.cs +++ b/src/OpenXLR.Tests/WindowsPluginFolderTests.cs @@ -26,9 +26,7 @@ private string Source(string relative, byte[]? bytes = null) private PluginInstaller Installer(string[] folders, string? fail = null, string? sync = null, bool wine = true) { File.WriteAllLines(Registry, folders); - string controller = Path.Combine(_root, "yabridgectl"); - File.WriteAllText(controller, $$""" - #!/bin/sh + string controller = ExecutableScript.Write(Path.Combine(_root, "yabridgectl"), $$""" printf '%s\n' "$*" >> '{{_root}}/calls' if [ "$1" = '{{fail}}' ]; then printf 'test failure\n' >&2; exit 1; fi case "$1" in @@ -39,7 +37,6 @@ private PluginInstaller Installer(string[] folders, string? fail = null, string? esac exit 0 """); - if (!OperatingSystem.IsWindows()) File.SetUnixFileMode(controller, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); return new(Path.Combine(_root, "lv2"), Path.Combine(_root, "clap"), Path.Combine(_root, "vst3"), controller, wine ? "/bin/true" : null); } diff --git a/src/OpenXLR.Tests/run-script.sh b/src/OpenXLR.Tests/run-script.sh new file mode 100755 index 0000000..c718cce --- /dev/null +++ b/src/OpenXLR.Tests/run-script.sh @@ -0,0 +1,10 @@ +#!/bin/sh +# The one program the test assembly runs that it did not write itself. What a +# fake helper does lives in a plain text file next to the link that started +# this, and this hands that file to the shell. See ExecutableScript.cs. +self=$0 +case $self in + */*) ;; + *) self=$(command -v -- "$self") ;; +esac +exec /bin/sh "$self.body" "$@"