ASIO: own the whole driver lifecycle on one pumped STA thread; free the card when ASIO is deselected
The problem: we stopped ever closing the ASIO driver because closing it could crash the process natively (Audient threw an access violation on Dispose with no managed stack). Keeping it open dodged the crash but held the sound card exclusively for the whole time RemSound was running - so no second ASIO driver could be used, and no other app could touch that card, even when RemSound wasn't playing through it. The cause: ASIO drivers are COM objects that want every control call (create / init / start / stop / dispose) on ONE thread, with a live message pump to service the messages the driver posts during init/reset/close. We were calling them from whatever thread hit Start/Stop, with only a Sleep() before the close - so the close ran with no pump and often on the wrong thread, and took the process down. The fix: AsioApartment - a dedicated background STA thread running a real Windows message pump. Every AsioOut control call in AsioCaptureBackend now goes through apartment.Invoke(...): the create/init/play in Start, and the unhook/Sleep/Stop/ Dispose in StopInternal. The driver gets one stable home thread and a live pump, so it can be closed cleanly. The real-time audio callback is untouched - it still runs on the driver's own thread. Because closing is safe again, EnsurePersistentAsioLocked now RELEASES the driver (Dispose + null) when ASIO is deselected, instead of parking it open. The composite backend borrows the persistent ASIO but never disposes it, so releasing here is the single owner freeing the card - which lets another ASIO driver (or another app) use the card once RemSound is off ASIO. Validated: the ASIO-enabled gate churned the real Audient driver through 52 open/close transitions with no crash and bounded handles (+27). Live hardware streaming still needs Ed's confirmation. Test: "ASIO apartment thread" self-test asserts work runs on one dedicated STA thread (not the caller's), exceptions propagate to the caller, and the apartment survives a work item throwing. Gate 43/43. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
d002130402
commit
3d4d5af326
@@ -73,6 +73,7 @@ internal static class SelfTest
|
|||||||
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
|
RunStep(results, "Remembered applications list is global + clearable", RememberedApplicationsGlobal);
|
||||||
RunStep(results, "Remembered peers migrate once (cleared list not resurrected)", RememberedPeersMigrationOnce);
|
RunStep(results, "Remembered peers migrate once (cleared list not resurrected)", RememberedPeersMigrationOnce);
|
||||||
RunStep(results, "Session-start watcher lifecycle (construct/rehook/dispose)", SessionStartWatcher);
|
RunStep(results, "Session-start watcher lifecycle (construct/rehook/dispose)", SessionStartWatcher);
|
||||||
|
RunStep(results, "ASIO apartment thread (single STA home for driver calls)", AsioApartmentThread);
|
||||||
RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics);
|
RunStep(results, "Send-app lists semantics (ticked → Active, out of Remembered)", SendAppListSemantics);
|
||||||
RunStep(results, "Service registration args", ServiceRegistrationArgs);
|
RunStep(results, "Service registration args", ServiceRegistrationArgs);
|
||||||
RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall);
|
RunStep(results, "Service self-contained install (own bin + user stop rights)", ServiceSelfContainedInstall);
|
||||||
@@ -1762,6 +1763,32 @@ internal static class SelfTest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>The dedicated ASIO control thread (AsioApartment) must run every work item on ONE STA
|
||||||
|
/// thread (not the caller's), propagate exceptions back to the caller, and keep working after a work
|
||||||
|
/// item throws — the guarantees that let the ASIO driver be opened AND closed from a single, pumped
|
||||||
|
/// home thread (the fix for the native crash-on-close).</summary>
|
||||||
|
private static string? AsioApartmentThread()
|
||||||
|
{
|
||||||
|
using var apt = new RemSound.Sender.AsioApartment("asio-selftest");
|
||||||
|
var state = ApartmentState.Unknown;
|
||||||
|
int workThread = 0, workThread2 = 0;
|
||||||
|
apt.Invoke(() => { state = Thread.CurrentThread.GetApartmentState(); workThread = Environment.CurrentManagedThreadId; });
|
||||||
|
apt.Invoke(() => workThread2 = Environment.CurrentManagedThreadId);
|
||||||
|
Check(state == ApartmentState.STA, "the ASIO apartment must run work on an STA thread");
|
||||||
|
Check(workThread == workThread2, "all work must run on the SAME dedicated thread");
|
||||||
|
Check(workThread != Environment.CurrentManagedThreadId, "work must run on the apartment thread, not the caller's");
|
||||||
|
|
||||||
|
var threw = false;
|
||||||
|
try { apt.Invoke(() => throw new InvalidOperationException("boom")); }
|
||||||
|
catch (InvalidOperationException ex) when (ex.Message == "boom") { threw = true; }
|
||||||
|
Check(threw, "an exception on the apartment thread must propagate to the caller");
|
||||||
|
|
||||||
|
var ranAfter = false;
|
||||||
|
apt.Invoke(() => ranAfter = true);
|
||||||
|
Check(ranAfter, "the apartment must keep working after a work item threw");
|
||||||
|
return "runs work on one dedicated STA thread; exceptions propagate; survives a throw";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>The instant capture-on-app-open watcher (AudioSessionStartWatcher) must construct, re-hook
|
/// <summary>The instant capture-on-app-open watcher (AudioSessionStartWatcher) must construct, re-hook
|
||||||
/// its default-device notification without throwing, and dispose idempotently — the plumbing behind
|
/// its default-device notification without throwing, and dispose idempotently — the plumbing behind
|
||||||
/// "catch a per-app send from its very start" and the service's boot session-kick. (It hooks live
|
/// "catch a per-app send from its very start" and the service's boot session-kick. (It hooks live
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
using System.Runtime.InteropServices;
|
||||||
|
|
||||||
|
namespace RemSound.Sender;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A dedicated STA thread with a Windows message pump, used to own the ENTIRE lifecycle of one ASIO
|
||||||
|
/// driver — create, initialise, start, stop and (crucially) dispose. ASIO drivers are COM objects that
|
||||||
|
/// generally require every one of their control calls to be made from a single thread, and several
|
||||||
|
/// (notably Audient) crash NATIVELY on close when that isn't honoured, or when there is no message pump
|
||||||
|
/// running to service the messages the driver posts during init/reset/close. The previous code made
|
||||||
|
/// these calls on whatever thread happened to call Start/Stop, with only a Sleep() before the close —
|
||||||
|
/// which is why closing the driver (on "ASIO → none" or a driver switch) could take the whole process
|
||||||
|
/// down with an access violation that left no managed stack.
|
||||||
|
///
|
||||||
|
/// <para>Route every AsioOut control call through <see cref="Invoke"/> and the driver gets a stable home
|
||||||
|
/// thread and a live pump. The ASIO audio callback still runs on the driver's own real-time thread — that
|
||||||
|
/// is unchanged; only the control calls move here.</para>
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class AsioApartment : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Thread thread;
|
||||||
|
private readonly ManualResetEventSlim started = new(false);
|
||||||
|
private readonly object queueGate = new();
|
||||||
|
private readonly Queue<WorkItem> queue = new();
|
||||||
|
private readonly AutoResetEvent workAvailable = new(false);
|
||||||
|
private volatile bool shutdown;
|
||||||
|
|
||||||
|
private sealed class WorkItem
|
||||||
|
{
|
||||||
|
public required Action Action;
|
||||||
|
public required ManualResetEventSlim Done;
|
||||||
|
public Exception? Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public AsioApartment(string name = "asio-control")
|
||||||
|
{
|
||||||
|
thread = new Thread(Run) { IsBackground = true, Name = name };
|
||||||
|
thread.SetApartmentState(ApartmentState.STA);
|
||||||
|
thread.Start();
|
||||||
|
started.Wait(); // don't accept Invoke()s until the pump is up
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Run <paramref name="action"/> on the apartment thread and block until it finishes,
|
||||||
|
/// rethrowing anything it raised. If called from the apartment thread itself, or after Dispose, runs
|
||||||
|
/// inline so a teardown path can never deadlock on itself.</summary>
|
||||||
|
public void Invoke(Action action)
|
||||||
|
{
|
||||||
|
if (shutdown || Thread.CurrentThread == thread) { action(); return; }
|
||||||
|
var item = new WorkItem { Action = action, Done = new ManualResetEventSlim(false) };
|
||||||
|
lock (queueGate) queue.Enqueue(item);
|
||||||
|
workAvailable.Set();
|
||||||
|
item.Done.Wait();
|
||||||
|
item.Done.Dispose();
|
||||||
|
if (item.Error is not null) throw item.Error;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Run()
|
||||||
|
{
|
||||||
|
// Force a message queue to exist on this thread before anyone posts to it.
|
||||||
|
PeekMessage(out _, IntPtr.Zero, 0, 0, PM_NOREMOVE);
|
||||||
|
started.Set();
|
||||||
|
|
||||||
|
var handles = new[] { workAvailable.SafeWaitHandle.DangerousGetHandle() };
|
||||||
|
while (!shutdown)
|
||||||
|
{
|
||||||
|
// Wake on either queued work or an incoming window message, so the driver's posted messages
|
||||||
|
// are dispatched promptly (that servicing is what several ASIO drivers need to close cleanly).
|
||||||
|
MsgWaitForMultipleObjects(1, handles, false, INFINITE, QS_ALLINPUT);
|
||||||
|
|
||||||
|
while (PeekMessage(out var msg, IntPtr.Zero, 0, 0, PM_REMOVE))
|
||||||
|
{
|
||||||
|
TranslateMessage(ref msg);
|
||||||
|
DispatchMessage(ref msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
WorkItem? item;
|
||||||
|
lock (queueGate) item = queue.Count > 0 ? queue.Dequeue() : null;
|
||||||
|
if (item is null) break;
|
||||||
|
try { item.Action(); }
|
||||||
|
catch (Exception ex) { item.Error = ex; }
|
||||||
|
finally { item.Done.Set(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (shutdown) return;
|
||||||
|
shutdown = true;
|
||||||
|
workAvailable.Set();
|
||||||
|
try { if (thread.IsAlive && Thread.CurrentThread != thread) thread.Join(2000); } catch { /* leaked thread beats a hang */ }
|
||||||
|
try { workAvailable.Dispose(); } catch { }
|
||||||
|
try { started.Dispose(); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- native message pump ----
|
||||||
|
private const uint QS_ALLINPUT = 0x04FF;
|
||||||
|
private const uint INFINITE = 0xFFFFFFFF;
|
||||||
|
private const uint PM_REMOVE = 0x0001;
|
||||||
|
private const uint PM_NOREMOVE = 0x0000;
|
||||||
|
|
||||||
|
[StructLayout(LayoutKind.Sequential)]
|
||||||
|
private struct MSG
|
||||||
|
{
|
||||||
|
public IntPtr hwnd;
|
||||||
|
public uint message;
|
||||||
|
public IntPtr wParam;
|
||||||
|
public IntPtr lParam;
|
||||||
|
public uint time;
|
||||||
|
public int ptX;
|
||||||
|
public int ptY;
|
||||||
|
}
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern uint MsgWaitForMultipleObjects(uint nCount, IntPtr[] pHandles, bool bWaitAll, uint dwMilliseconds, uint dwWakeMask);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern bool TranslateMessage(ref MSG lpMsg);
|
||||||
|
|
||||||
|
[DllImport("user32.dll")]
|
||||||
|
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||||
|
}
|
||||||
@@ -48,6 +48,10 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
|||||||
private readonly object gate = new();
|
private readonly object gate = new();
|
||||||
|
|
||||||
private AsioOut? asio;
|
private AsioOut? asio;
|
||||||
|
// Every AsioOut control call (create / init / play / stop / dispose) is marshalled onto this one
|
||||||
|
// dedicated STA+message-pump thread. That single-threaded, pumped home is what lets the driver close
|
||||||
|
// WITHOUT the native crash we used to hit on "ASIO → none" and driver switches — see AsioApartment.
|
||||||
|
private readonly AsioApartment apartment = new();
|
||||||
private List<int> activeChannelPairIndices = [];
|
private List<int> activeChannelPairIndices = [];
|
||||||
private int recordChannelCount;
|
private int recordChannelCount;
|
||||||
private float[] mixScratch = new float[1024];
|
private float[] mixScratch = new float[1024];
|
||||||
@@ -133,36 +137,34 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
asio = new AsioOut(driverName);
|
// Open + init + play, ALL on the ASIO apartment thread (see the apartment field). A zero-
|
||||||
// Always open with the driver's full input channel count. Pulling channels we
|
// channel driver becomes a throw so the catch below runs the same StopInternal cleanup.
|
||||||
// don't immediately need is essentially free — the driver fills them anyway —
|
apartment.Invoke(() =>
|
||||||
// and it removes the need to ever reopen the AsioOut when the user toggles a
|
|
||||||
// higher-numbered channel pair. Reopening is what previously caused 15-second
|
|
||||||
// freezes when both sender and receiver held the same single-client driver
|
|
||||||
// (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30.
|
|
||||||
recordChannelCount = asio.DriverInputChannelCount;
|
|
||||||
if (recordChannelCount <= 0)
|
|
||||||
{
|
{
|
||||||
onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" reports zero input channels");
|
asio = new AsioOut(driverName);
|
||||||
StopInternal();
|
// Always open with the driver's full input channel count. Pulling channels we
|
||||||
return;
|
// don't immediately need is essentially free — the driver fills them anyway —
|
||||||
}
|
// and it removes the need to ever reopen the AsioOut when the user toggles a
|
||||||
asio.InputChannelOffset = 0;
|
// higher-numbered channel pair. Reopening is what previously caused 15-second
|
||||||
// Sanity-check that the requested pairs are within the driver's channel range.
|
// freezes when both sender and receiver held the same single-client driver
|
||||||
// We open the full count anyway, but if a saved spec references a pair above
|
// (Komplete Audio etc.) — see Andre's localhost lockup, 2026-04-30.
|
||||||
// the driver's range, the OnAudioAvailable mixer would silently emit zero —
|
recordChannelCount = asio.DriverInputChannelCount;
|
||||||
// surface that as a diagnostic so it's not mysterious.
|
if (recordChannelCount <= 0)
|
||||||
var maxPairIndex = activeChannelPairIndices.Max();
|
throw new InvalidOperationException($"driver \"{driverName}\" reports zero input channels");
|
||||||
var highestNeededChannel = (maxPairIndex + 1) * 2;
|
asio.InputChannelOffset = 0;
|
||||||
if (highestNeededChannel > recordChannelCount)
|
// Sanity-check that the requested pairs are within the driver's channel range.
|
||||||
{
|
// We open the full count anyway, but if a saved spec references a pair above
|
||||||
onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})");
|
// the driver's range, the OnAudioAvailable mixer would silently emit zero —
|
||||||
// Continue anyway — out-of-range pairs just contribute silence to the mix.
|
// surface that as a diagnostic so it's not mysterious.
|
||||||
}
|
var maxPairIndex = activeChannelPairIndices.Max();
|
||||||
asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate);
|
var highestNeededChannel = (maxPairIndex + 1) * 2;
|
||||||
asio.AudioAvailable += OnAudioAvailable;
|
if (highestNeededChannel > recordChannelCount)
|
||||||
captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)";
|
onDiagnostic?.Invoke($"asio capture: driver \"{driverName}\" only has {recordChannelCount} input channels, but spec requests channel pair {maxPairIndex} (channels {maxPairIndex * 2 + 1}/{maxPairIndex * 2 + 2})");
|
||||||
asio.Play();
|
asio.InitRecordAndPlayback(null, recordChannelCount, MixSampleRate);
|
||||||
|
asio.AudioAvailable += OnAudioAvailable;
|
||||||
|
captureFormat = $"{MixSampleRate} Hz, {recordChannelCount} input channel(s), 32-bit float (ASIO)";
|
||||||
|
asio.Play();
|
||||||
|
});
|
||||||
uptime.Restart();
|
uptime.Restart();
|
||||||
onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}");
|
onDiagnostic?.Invoke($"asio capture started \"{driverName}\" {captureFormat}; pairs={string.Join(",", activeChannelPairIndices)}");
|
||||||
}
|
}
|
||||||
@@ -207,22 +209,25 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
|||||||
|
|
||||||
private void StopInternal()
|
private void StopInternal()
|
||||||
{
|
{
|
||||||
if (asio is not null)
|
var toClose = asio;
|
||||||
|
if (toClose is not null)
|
||||||
{
|
{
|
||||||
// Step-by-step logging: closing some drivers (notably Audient) can crash NATIVELY inside their
|
// Close the driver on the ASIO apartment thread — the same single, pumped thread it was opened
|
||||||
// own Stop/Dispose — a native access violation blows past these try/catch blocks and kills the
|
// on. That is the fix for the native access violation that used to kill the process here (it
|
||||||
// process with no managed stack (why the ASIO->none crash leaves no crash file). Logging each
|
// blew past these try/catch blocks with no managed stack). Step-by-step logging still pinpoints
|
||||||
// step means the log ends right AFTER the line for whichever native call died, pinpointing it.
|
// any native call that dies, and the callback is unhooked + drained before stop/dispose so the
|
||||||
onDiagnostic?.Invoke("asio close: unhooking callback");
|
// close isn't racing a live buffer callback (a common trigger for the crash).
|
||||||
try { asio.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ }
|
apartment.Invoke(() =>
|
||||||
// Let any ASIO buffer callback already in flight finish before we stop/release the driver, so
|
{
|
||||||
// the native close isn't racing a live callback (a common trigger for the crash).
|
onDiagnostic?.Invoke("asio close: unhooking callback");
|
||||||
System.Threading.Thread.Sleep(60);
|
try { toClose.AudioAvailable -= OnAudioAvailable; } catch { /* ignore */ }
|
||||||
onDiagnostic?.Invoke("asio close: stopping stream");
|
System.Threading.Thread.Sleep(60);
|
||||||
try { asio.Stop(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: stop threw {ex.GetType().Name}: {ex.Message}"); }
|
onDiagnostic?.Invoke("asio close: stopping stream");
|
||||||
onDiagnostic?.Invoke("asio close: releasing driver (dispose)");
|
try { toClose.Stop(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: stop threw {ex.GetType().Name}: {ex.Message}"); }
|
||||||
try { asio.Dispose(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: dispose threw {ex.GetType().Name}: {ex.Message}"); }
|
onDiagnostic?.Invoke("asio close: releasing driver (dispose)");
|
||||||
onDiagnostic?.Invoke("asio close: driver released cleanly");
|
try { toClose.Dispose(); } catch (Exception ex) { onDiagnostic?.Invoke($"asio close: dispose threw {ex.GetType().Name}: {ex.Message}"); }
|
||||||
|
onDiagnostic?.Invoke("asio close: driver released cleanly");
|
||||||
|
});
|
||||||
asio = null;
|
asio = null;
|
||||||
}
|
}
|
||||||
uptime.Stop();
|
uptime.Stop();
|
||||||
@@ -230,7 +235,11 @@ internal sealed class AsioCaptureBackend : ICaptureBackend
|
|||||||
recordChannelCount = 0;
|
recordChannelCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose() => Stop();
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Stop();
|
||||||
|
apartment.Dispose(); // shut down the dedicated ASIO thread last, after the driver is closed
|
||||||
|
}
|
||||||
|
|
||||||
private static List<int> ParseChannelPairIndices(IReadOnlyList<CaptureSourceSpec> specs)
|
private static List<int> ParseChannelPairIndices(IReadOnlyList<CaptureSourceSpec> specs)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -358,16 +358,19 @@ public sealed class AudioSender : IDisposable
|
|||||||
|
|
||||||
if (!willUseAsio)
|
if (!willUseAsio)
|
||||||
{
|
{
|
||||||
// Mode no longer uses ASIO. Ed's approach (2026-07-15): do NOT close the driver here — closing
|
// ASIO is no longer selected (driver set to "(none)", or a WASAPI-only mode). RELEASE the
|
||||||
// it is the native call that crashes Audient (the "ASIO -> none kills the app" bug). Instead
|
// driver now — close it on its dedicated apartment thread, which is safe as of the AsioApartment
|
||||||
// keep the persistent instance OPEN but PARKED: rewire its callback to a no-op and drop it to
|
// change — so the sound card is FREE for other applications (a DAW, etc.) instead of being held
|
||||||
// zero active pairs. That stops it feeding any lane WITHOUT a native close, so no crash. It's
|
// exclusively for the whole time RemSound is open. This replaces the old "park it open forever"
|
||||||
// reused instantly if the user turns ASIO back on, and only truly closes on a driver CHANGE
|
// workaround, which existed only because closing the Audient driver crashed. The composite is
|
||||||
// (below) or app exit — where the OS reclaims it anyway.
|
// rebuilt without ASIO immediately after (RebuildEngineLocked); the composite borrows but never
|
||||||
|
// disposes this instance, so disposing it here can't pull the rug from a live engine.
|
||||||
if (persistentAsio is not null)
|
if (persistentAsio is not null)
|
||||||
{
|
{
|
||||||
try { persistentAsio.SetCallback(static _ => { }); } catch { /* ignore */ }
|
try { persistentAsio.Dispose(); }
|
||||||
try { persistentAsio.UpdateSources(Array.Empty<CaptureSourceSpec>()); } catch { /* ignore */ }
|
catch (Exception ex) { diagnostic?.Invoke($"asio: release-on-idle dispose threw {ex.GetType().Name}: {ex.Message}"); }
|
||||||
|
persistentAsio = null;
|
||||||
|
persistentAsioDriverName = null;
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user