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" "$@"