From b43c0d3861b38e2a1246333febe73a378d8ff231 Mon Sep 17 00:00:00 2001 From: jfacemyer <1776340+jfacemyer@users.noreply.github.com> Date: Fri, 11 Sep 2026 12:12:40 -0400 Subject: [PATCH] WineFix: keep libplugins.dll loaded so a stale call still lands on code A crash dump taken on a JPEG import has the main thread faulting with 0xC0000005 trying to *execute* 0x6fffc524d6c0. That address is inside libplugins.dll, and by the time of the dump libplugins.dll is in the unloaded-module list with the memory at that address marked FREE/NOACCESS: UnloadedModuleList 0x6fffc5230000 +0x00408000 libplugins.dll (twice) MemoryInfoList 0x6fffc5220000 +0x2b0000 FREE NOACCESS The call came in through Serif.Interop.Persona.dll -> libscripting.dll, so the scripting engine was still holding an entry point into a module the loader had released. Two entries at the same base address say this is a load/free cycle during the session rather than teardown, which fits crashes that arrive at no particular moment and blame whatever operation was in flight -- here an image import, which had nothing to do with it. Hold one reference that is never released. The refcount cannot then reach zero, the pages stay mapped, and the stale pointer stays valid. This does not fix whatever drops the last reference; it removes the consequence. Something on Windows presumably keeps this module up in a way Wine's loader does not, since the same build does not crash there, but the dump shows the use-after-unload and not the reason for it. Not a Harmony patch -- the caller is native, with no managed frame to intercept -- so it sits in OnLoad, before Affinity has had a chance to run the cycle even once. Defaults on, next to the font-enumeration fix, and can be turned off from the Crash Fixes section. --- WineFix/Patches/PluginHostPin.cs | 86 ++++++++++++++++++++++++++++++++ WineFix/README.md | 1 + WineFix/WineFixPlugin.cs | 19 ++++++- 3 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 WineFix/Patches/PluginHostPin.cs diff --git a/WineFix/Patches/PluginHostPin.cs b/WineFix/Patches/PluginHostPin.cs new file mode 100644 index 0000000..cfebc58 --- /dev/null +++ b/WineFix/Patches/PluginHostPin.cs @@ -0,0 +1,86 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using AffinityPluginLoader.Core; + +namespace WineFix.Patches +{ + /// + /// Keeps libplugins.dll loaded for the lifetime of the process. + /// + /// Affinity loads and frees its plugin host more than once per session, and + /// something keeps calling into it after the last reference is dropped. A + /// crash dump taken on a JPEG import showed the main thread taking an + /// access violation trying to *execute* 0x6fffc524d6c0 -- an address inside + /// libplugins.dll, which by then was in the dump's unloaded-module list with + /// the memory marked FREE/NOACCESS. The call arrived from libscripting.dll + /// via Serif.Interop.Persona.dll, so the scripting engine was still holding + /// an entry point into a module the loader had already released. The module + /// appeared in that list twice at the same base address, so this is a + /// load/free cycle rather than a one-off teardown. + /// + /// Taking one reference that is never released makes the refcount unable to + /// reach zero, so the pages stay mapped and the stale pointer stays valid. + /// It does not fix whatever drops the last reference -- it removes the + /// consequence. Presumably something on Windows holds this module up in a + /// way Wine's loader does not, since the same build does not crash there. + /// + /// This is not a Harmony patch: the caller is native code with no managed + /// frame to intercept. + /// + public static class PluginHostPin + { + private const string ModuleName = "libplugins.dll"; + + // Held for the lifetime of the process. Never freed -- that is the point. + private static IntPtr _handle = IntPtr.Zero; + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr LoadLibraryW(string lpLibFileName); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + private static extern IntPtr GetModuleHandleW(string lpModuleName); + + public static void Apply() + { + if (_handle != IntPtr.Zero) + { + Logger.Info("Plugin host already held, nothing to do"); + return; + } + + // Full path rather than the bare name: the bare name would be resolved + // against the search path, and the point is to reference the copy + // sitting beside Affinity.exe -- the one that gets unloaded. + string path = ModuleName; + try + { + var dir = AppDomain.CurrentDomain.BaseDirectory; + if (!string.IsNullOrEmpty(dir)) + { + var candidate = Path.Combine(dir, ModuleName); + if (File.Exists(candidate)) path = candidate; + } + } + catch (Exception ex) + { + Logger.Warning("Could not resolve the application directory, falling back to the module name: " + ex.Message); + } + + bool alreadyLoaded = GetModuleHandleW(ModuleName) != IntPtr.Zero; + + _handle = LoadLibraryW(path); + if (_handle == IntPtr.Zero) + { + Logger.Error("Failed to hold " + ModuleName + " (error " + Marshal.GetLastWin32Error() + ")"); + return; + } + + // When it was already loaded this only raises the refcount and no + // DllMain runs; when it was not, we have just loaded it earlier than + // Affinity would have. + Logger.Info("Holding a reference to " + ModuleName + " (" + + (alreadyLoaded ? "was already loaded" : "loaded early") + ")"); + } + } +} diff --git a/WineFix/README.md b/WineFix/README.md index a1fb431..db31e18 100644 --- a/WineFix/README.md +++ b/WineFix/README.md @@ -23,6 +23,7 @@ For detailed instructions, see the [WineFix Installation Guide](https://apl.ncur - **Font enumeration fix** — Intermittent startup crash from parallel font enumeration. Fixed by forcing synchronous font loading. - **Canva sign-in helper** — Canva sign-in helper to allow copy/paste of the authorization URL to complete sign-in, no protocol handler required. - **Command-line file opening fix** — Opening files from the desktop or command line crashes or silently fails due to a missing WinRT type. Fixed by bypassing `ProcessCommandLineArguments` and opening files directly via `IDocumentViewService`. +- **Plugin host pin** — Affinity frees its plugin host (`libplugins.dll`) during a session and later calls back into it, crashing with an access violation. Fixed by holding a reference to the module so the released code stays mapped. ## Configuration diff --git a/WineFix/WineFixPlugin.cs b/WineFix/WineFixPlugin.cs index d05ea2c..299da3f 100644 --- a/WineFix/WineFixPlugin.cs +++ b/WineFix/WineFixPlugin.cs @@ -22,6 +22,7 @@ public class WineFixPlugin : AffinityPlugin public const string SettingWidenStubFix = "widen_stub_fix"; public const string SettingBezierSplitGuard = "bezier_split_guard"; public const string SettingCommandLineFileOpen = "command_line_file_open"; + public const string SettingPinPluginHost = "pin_plugin_host"; public override PluginSettingsDefinition DefineSettings() { @@ -73,7 +74,23 @@ public override PluginSettingsDefinition DefineSettings() .AddBool(SettingForceSyncFontEnum, "Force synchronous font enumeration", defaultValue: true, restartRequired: true, - description: "Disable parallel font enumeration to significantly reduce frequency of startup crashes. May increase application startup time on systems with lots of fonts."); + description: "Disable parallel font enumeration to significantly reduce frequency of startup crashes. May increase application startup time on systems with lots of fonts.") + .AddBool(SettingPinPluginHost, "Keep the plugin host loaded", + defaultValue: true, + restartRequired: true, + description: "Hold a reference to libplugins.dll so it is never unloaded. Affinity frees its plugin host during a session and then calls back into it, which crashes the application with an access violation. Holding the module keeps the released code mapped so the stale call still lands on real instructions."); + } + + public override void OnLoad(IPluginContext context) + { + if (context.Settings.GetEffectiveValue(SettingPinPluginHost)) + { + Patches.PluginHostPin.Apply(); + } + else + { + Logger.Info("Skipping plugin host hold (setting: disabled)"); + } } public override void OnPatch(Harmony harmony, IPluginContext context)