Debugger/SRxDebugger.psm1

<#
================================================================================
  SRxDebugger.psm1 (Monaco Editor - Phase 2: debugger runtime engine) rev 5
  -----------------------------------------------------------------------------
  UI-FREE debugger runtime for Rules Library objects (ts_Rule / ts_Script).
  No WinForms / WebView2 types: imports anywhere, testable headless
  (Tests\SRxDebugger.Tests.ps1, .Busy.Tests.ps1, .Pump.Tests.ps1).
 
  ARCHITECTURE (see 'PowerShell debugger oveview.txt' / '... prototype.txt')
      The debugger runtime is a MANAGED RUNSPACE inside Explorer's process
      (the same model as the Phase 6 IT3.1 runspace tasks - hence
      Initialize-SRxEnv -NoHostUI):
 
        Explorer UI thread debug runspace thread
        ------------------ ---------------------
        SRxDebugSession.Poll() (UI timer) setup script (param = arguments):
          <- C# queues (stops / streams) 1) Initialize-SRxEnv -NoHostUI
        Resume(action) / Stop() 2) $global:SRxEnv.Pipeline = copy
          -> C# request queue 3) Set-PSBreakpoint (first line + user)
                                                4) Start-Rule -Name <rule> (as <start>)
                                              DebuggerStop -> SRxDebug7.DebugHost.OnStop
                                                snapshot, then WAITS for a resume
                                                request - pumping sent messages
                                              end of the run: teardown script
                                                (rev 5), then the runspace is disposed
 
  WHY C# FOR THE STOP HANDLER
      DebuggerStop must be handled SYNCHRONOUSLY on the runspace's pipeline
      thread: the handler sets e.ResumeAction before it returns. A
      Register-ObjectEvent -Action only QUEUES the event, and a PowerShell
      scriptblock delegate on that thread is GOTCHA #4 of SRxTerminalBoxControl.
 
  rev 2 Local variables: Get-Variable -Scope Local in the scope of the stop.
  rev 3 Own minimal host (DebugPSHost): Write-Host is lost on 5.1's DefaultHost.
  rev 4 No SessionStateProxy (arguments = script parameter); the start waits
         for RunspaceAvailability 'Available' (SRxCore keeps it busy at open).
  rev 5 First real debug session:
         * Explorer frozen while stopped (cannot move / activate the window,
           tab pictograms dead, Monaco - another process - responsive; frees
           itself after minutes). The debug runspace thread OWNS WINDOWS
           (created by SRxCore / the rule code on that STA thread). Windows
           delivers activation / z-order / broadcast messages with SendMessage
           to every top-level window of the process and the SENDER WAITS:
           while the stop handler blocked in a plain wait, Explorer's UI
           thread hung inside SendMessage. The stop handler now waits with
           MsgWaitForMultipleObjectsEx and dispatches INCOMING SENT messages
           (PeekMessage PM_QS_SENDMESSAGE) - posted messages are not pumped,
           so no rule code (window event handlers) runs while stopped.
           Diagnostics: 'DBG debug thread ... owns N window(s): <classes>'
           at every stop where the count changed, and 'DBG ... sent
           message(s) dispatched while stopped' on resume.
         * Endless 'onJobsWatchTick / [IPC] PublishAllControl: Object
           reference not set' after the session ended: whatever SRxCore
           registered from inside the debug runspace (event subscriptions,
           jobs, the jobs-child connection) outlived the disposed runspace.
           Before the runspace is disposed a TEARDOWN script runs in it:
           Disconnect-SRxJobsChild (when that command exists), all event
           subscribers unregistered, all PS jobs removed. Its result is
           traced: '[DBG] session ...: teardown: ...'.
         C# namespace SRxDebug5.
  rev 6 r5.1 real run: still frozen; the debug thread owns 0 windows, so
         the r5 sent-message theory is ruled out. Diagnostics instead of a
         guess: UI-THREAD WATCHDOG (SRxDebug7.UiWatchdog). Started by
         StartSession on the UI thread (WindowsForms SynchronizationContext
         only - headless tests are unaffected). A background thread posts a
         heartbeat to the UI thread every 500 ms; when it is not executed
         within HangMs (3 s) the watchdog captures the MANAGED STACKS of the
         UI thread and of the debug thread (thread suspend + StackTrace,
         .NET Framework) and writes them to
             %TEMP%\SRxDebugger-hang-<pid>.log (+ a console line)
         then again every 30 s while the hang lasts, and a 'resumed after N s'
         line. The UI stack names the exact call Explorer is blocked in.
         C# namespace SRxDebug7.
  rev 6.1 Module failed to load: a [SRxDebug7.UiWatchdog] type literal inside
         the SRxDebugger class is resolved at PARSE time, before Add-Type runs.
         All C# types are now looked up at run time inside classes.
  rev 7.1 class parse error: a local '$path' in SRxDebugSession clashed with
         the property 'Path' (class locals may not shadow properties) - renamed.
  rev 7 ROOT CAUSE of the frozen Explorer and of the endless
         'onJobsWatchTick / [IPC] PublishAllControl: Object reference not set'
         after the session: SHARED POWERSHELL CLASSES.
         The debug runspace imports SRxCore from the SAME file as Explorer.
         Windows PowerShell 5.1 caches compiled script files by path + content,
         so the debug runspace gets the very SAME class types (SRxJobsEngine,
         SRxBasePipeParentAPI ...) and re-binds their methods to ITS session
         state (the effect noted in SRxJobsEngine 'PHASE 6 IT3.1 r6').
         From then on every method Explorer's UI thread calls on its own
         engine objects (Pump -> PublishAllControl, the jobs watch ->
         CheckProcesses ...) EXECUTES IN THE DEBUG RUNSPACE'S ENGINE:
           * while that engine is stopped / stepping, the debugger stops the
             UI thread inside SRxCore (frozen Explorer; a resume posted for
             the debug thread sometimes lets it go for a few seconds);
           * after the session the runspace is disposed - the methods run on
             a dead session state -> NullReferenceException forever.
         Fix 1 (cause) MODULE ISOLATION: every module imported into the debug
                        runspace is loaded from a private copy of its module
                        folder (%TEMP%\SRxDebugger\modules\<hash>\<name>\...).
                        Other path = other cache entry = OWN class types; the
                        types of Explorer stay bound to Explorer.
                        Trace: 'module <name> isolated: <copy>'.
                        $engine.IsolateModules = $false switches it off.
         Fix 2 (guard) a debugger stop on a thread that is NOT the debug
                        runspace's thread is never held: it continues at once
                        (stepping mode kept) and is logged once per script:
                        'DBG foreign-thread stop ignored ...'.
         C# namespace SRxDebug7.
 
  PUBLIC API
      $dbg = New-SRxDebugger
      $s = $dbg.StartSession(@{ Path; Kind = Rule|Script; RuleName; RootPath;
                                  Pipeline; Modules; SourceId; Title; Command })
      $dbg.Pump() -> [object[]] events (UI timer)
                                  { Type = opened|stopped|running|output|ended; ... }
      $s.Resume('Continue'|'StepOver'|'StepInto'|'StepOut') / $s.Stop()
      $dbg.ToggleBreakpoint($path, $line) -> [int[]] lines of the file
      $dbg.SetBreakpoints($path, [int[]]$lines) (editor -> line drift sync)
      $dbg.GetBreakpoints($path)
      $dbg.Shutdown($waitMs) (Explorer FormClosed)
      [SRxDebugger]::FirstExecutableLine($path) automatic first breakpoint
 
  LIMITS (documented, by design)
      * Breakpoints toggled while the script RUNS are applied at the next stop.
      * The runspace has no interactive host: Read-Host / Get-Credential /
        choice prompts fail (same as the runspace tasks).
      * While stopped, windows owned by the debug thread do not repaint
        (posted messages are not pumped) - they are not the Explorer UI.
================================================================================
#>

Set-StrictMode -Off

#region ---- native code (C#, compiled once per process) -----------------------
if (-not ('SRxDebug7.DebugHost' -as [type])) {
    Add-Type -ReferencedAssemblies @('System.Core', [psobject].Assembly.Location) -TypeDefinition @'
using System;
using System.Collections;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Management.Automation;
using System.Management.Automation.Host;
using System.Management.Automation.Runspaces;
using System.Runtime.InteropServices;
using System.Security;
using System.Text;
using System.Threading;
 
namespace SRxDebug7
{
    // ---- rev 5: waits that keep cross-thread SendMessage callers alive -------
    public static class NativeWait
    {
        public const uint QS_SENDMESSAGE = 0x0040;
        public const uint PM_NOREMOVE = 0x0000;
        public const uint PM_QS_SENDMESSAGE = QS_SENDMESSAGE << 16;
        public const uint WAIT_TIMEOUT = 0x102;
 
        [StructLayout(LayoutKind.Sequential)]
        public struct MSG { public IntPtr hwnd; public uint message; public IntPtr wParam; public IntPtr lParam; public uint time; public int ptX; public int ptY; }
 
        public delegate bool EnumThreadWndProc(IntPtr hWnd, IntPtr lParam);
 
        [DllImport("user32.dll", SetLastError = true)]
        public static extern uint MsgWaitForMultipleObjectsEx(uint nCount, IntPtr[] pHandles, uint dwMilliseconds, uint dwWakeMask, uint dwFlags);
        [DllImport("user32.dll")]
        public static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
        [DllImport("user32.dll")]
        public static extern bool EnumThreadWindows(uint dwThreadId, EnumThreadWndProc lpfn, IntPtr lParam);
        [DllImport("user32.dll", CharSet = CharSet.Unicode)]
        public static extern int GetClassName(IntPtr hWnd, StringBuilder lpClassName, int nMaxCount);
        [DllImport("kernel32.dll")]
        public static extern uint GetCurrentThreadId();
 
        // Waits up to ms for the handle; incoming SENT messages of this thread
        // are dispatched meanwhile. Returns the number of dispatch passes.
        public static int Wait(WaitHandle h, int ms)
        {
            IntPtr[] handles = new IntPtr[] { h.SafeWaitHandle.DangerousGetHandle() };
            int passes = 0;
            DateTime end = DateTime.UtcNow.AddMilliseconds(ms);
            while (true)
            {
                int left = (int)(end - DateTime.UtcNow).TotalMilliseconds;
                if (left < 0) left = 0;
                uint r = MsgWaitForMultipleObjectsEx(1, handles, (uint)left, QS_SENDMESSAGE, 0);
                GC.KeepAlive(h);
                if (r == 0) return passes; // signalled
                if (r == 1) // sent message(s) waiting
                {
                    MSG m;
                    PeekMessage(out m, IntPtr.Zero, 0, 0, PM_NOREMOVE | PM_QS_SENDMESSAGE); // dispatches them
                    passes++;
                    if (left == 0) return passes;
                    continue;
                }
                return passes; // timeout / failure
            }
        }
 
        public static string[] ThreadWindows()
        {
            List<string> list = new List<string>();
            uint tid = GetCurrentThreadId();
            EnumThreadWindows(tid, delegate (IntPtr hWnd, IntPtr lp)
            {
                StringBuilder sb = new StringBuilder(256);
                GetClassName(hWnd, sb, sb.Capacity);
                list.Add(sb.ToString());
                return true;
            }, IntPtr.Zero);
            return list.ToArray();
        }
    }
 
    // ---- rev 6: UI-thread hang watchdog ------------------------------------
    public static class UiWatchdog
    {
        private static readonly object _lock = new object();
        private static Thread _ui;
        private static SynchronizationContext _ctx;
        private static Thread _watcher;
        private static volatile bool _run;
        private static long _beatTicks; // UTC ticks of the last executed heartbeat
        private static volatile bool _posted;
        private static volatile string _note = "";
        public static int HangMs = 3000;
        public static int RepeatMs = 30000;
        public static int IntervalMs = 500;
        public static string LogPath = "";
        public static volatile int HangCount; // tests / diagnostics
        public static volatile string LastReport = "";
        public static volatile bool EchoToConsole = true;
 
        public static bool IsRunning { get { return _run; } }
        public static void Note(string text) { _note = DateTime.Now.ToString("HH:mm:ss.fff") + " " + (text ?? ""); }
 
        // Call ON the UI thread.
        public static void Start(string logPath)
        {
            lock (_lock)
            {
                SynchronizationContext ctx = SynchronizationContext.Current;
                if (ctx == null) throw new InvalidOperationException("UiWatchdog.Start: no SynchronizationContext on this thread (call it on the UI thread).");
                if (_run && _ui == Thread.CurrentThread) return;
                Stop();
                _ui = Thread.CurrentThread;
                _ctx = ctx;
                LogPath = string.IsNullOrEmpty(logPath)
                    ? System.IO.Path.Combine(System.IO.Path.GetTempPath(), "SRxDebugger-hang-" + System.Diagnostics.Process.GetCurrentProcess().Id + ".log")
                    : logPath;
                Interlocked.Exchange(ref _beatTicks, DateTime.UtcNow.Ticks);
                _posted = false;
                _run = true;
                _watcher = new Thread(Loop);
                _watcher.IsBackground = true;
                _watcher.Name = "SRxDebugger UI watchdog";
                _watcher.Start();
                Write(string.Format("{0:HH:mm:ss.fff} watchdog started for UI thread {1} (hang threshold {2} ms)", DateTime.Now, _ui.ManagedThreadId, HangMs), false);
            }
        }
 
        public static void Stop()
        {
            _run = false;
            Thread w = _watcher;
            _watcher = null;
            if (w != null && w != Thread.CurrentThread) { try { w.Join(2000); } catch { } }
        }
 
        private static void Beat(object state)
        {
            Interlocked.Exchange(ref _beatTicks, DateTime.UtcNow.Ticks);
            _posted = false;
        }
 
        private static void Loop()
        {
            DateTime hangStart = DateTime.MinValue;
            DateTime lastReport = DateTime.MinValue;
            while (_run)
            {
                try
                {
                    if (!_posted) { _posted = true; _ctx.Post(Beat, null); }
                    Thread.Sleep(IntervalMs);
                    DateTime beat = new DateTime(Interlocked.Read(ref _beatTicks), DateTimeKind.Utc);
                    double ms = (DateTime.UtcNow - beat).TotalMilliseconds;
                    if (ms >= HangMs)
                    {
                        if (hangStart == DateTime.MinValue)
                        {
                            hangStart = beat;
                            HangCount++;
                            Report(ms, true);
                            lastReport = DateTime.UtcNow;
                        }
                        else if ((DateTime.UtcNow - lastReport).TotalMilliseconds >= RepeatMs)
                        {
                            Report(ms, false);
                            lastReport = DateTime.UtcNow;
                        }
                    }
                    else if (hangStart != DateTime.MinValue)
                    {
                        Write(string.Format("{0:HH:mm:ss.fff} UI thread resumed after {1:N1} s", DateTime.Now, (DateTime.UtcNow - hangStart).TotalSeconds), true);
                        hangStart = DateTime.MinValue;
                    }
                }
                catch (Exception ex) { try { Write("watchdog error: " + ex.Message, false); } catch { } Thread.Sleep(1000); }
            }
        }
 
        private static void Report(double ms, bool first)
        {
            StringBuilder sb = new StringBuilder();
            sb.AppendLine("==============================================================================");
            sb.AppendFormat("{0:HH:mm:ss.fff} UI THREAD NOT RESPONDING for {1:N1} s{2}\r\n", DateTime.Now, ms / 1000.0, first ? "" : " (still)");
            sb.AppendFormat("last debugger event: {0}\r\n", _note);
            sb.AppendLine("---- UI thread managed stack (innermost first) ----");
            sb.AppendLine(StackOf(_ui));
            Thread d = DebugHost.LastDebugThread;
            if (d != null && d.IsAlive)
            {
                sb.AppendLine("---- debug runspace thread managed stack ----");
                sb.AppendLine(StackOf(d));
            }
            string text = sb.ToString();
            LastReport = text;
            Write(text, false);
            if (EchoToConsole)
            {
                try { Console.WriteLine("[SRxDebugger] UI thread not responding {0:N1} s - stacks in {1}", ms / 1000.0, LogPath); } catch { }
            }
        }
 
#pragma warning disable 618
        public static string StackOf(Thread t)
        {
            if (t == null) return "(no thread)";
            if (t == Thread.CurrentThread) return new System.Diagnostics.StackTrace(true).ToString();
            bool suspended = false;
            try
            {
                t.Suspend();
                suspended = true;
                return new System.Diagnostics.StackTrace(t, false).ToString();
            }
            catch (Exception ex) { return "(stack not available: " + ex.GetType().Name + ": " + ex.Message + ")"; }
            finally { if (suspended) { try { t.Resume(); } catch { } } }
        }
#pragma warning restore 618
 
        private static void Write(string text, bool echo)
        {
            try { lock (_lock) { System.IO.File.AppendAllText(LogPath, text + "\r\n"); } } catch { }
            if (echo && EchoToConsole) { try { Console.WriteLine("[SRxDebugger] " + text); } catch { } }
        }
    }
 
    // ---- minimal host of the debug runspace (rev 3) ---------------------------
    public sealed class DebugPSHost : PSHost
    {
        private readonly Guid _id = Guid.NewGuid();
        private readonly DebugPSHostUI _ui;
        internal readonly ConcurrentQueue<string> Pending = new ConcurrentQueue<string>();
        internal volatile DebugHost Target;
 
        public DebugPSHost() { _ui = new DebugPSHostUI(this); }
 
        internal void Emit(string line)
        {
            DebugHost t = Target;
            if (t != null) t.Log("INF " + line); else Pending.Enqueue("INF " + line);
        }
        public void Flush() { _ui.Flush(); }
 
        public override string Name { get { return "SRxDebuggerHost"; } }
        public override Version Version { get { return new Version(1, 0); } }
        public override Guid InstanceId { get { return _id; } }
        public override PSHostUserInterface UI { get { return _ui; } }
        public override CultureInfo CurrentCulture { get { return Thread.CurrentThread.CurrentCulture; } }
        public override CultureInfo CurrentUICulture { get { return Thread.CurrentThread.CurrentUICulture; } }
        public override void SetShouldExit(int exitCode) { }
        public override void EnterNestedPrompt() { throw new NotSupportedException("Nested prompts are not supported by the SRx debugger host."); }
        public override void ExitNestedPrompt() { throw new NotSupportedException("Nested prompts are not supported by the SRx debugger host."); }
        public override void NotifyBeginApplication() { }
        public override void NotifyEndApplication() { }
    }
 
    internal sealed class DebugPSHostUI : PSHostUserInterface
    {
        private readonly DebugPSHost _host;
        private readonly DebugRawUI _raw = new DebugRawUI();
        private readonly StringBuilder _line = new StringBuilder();
        private readonly object _lock = new object();
 
        public DebugPSHostUI(DebugPSHost host) { _host = host; }
        public override PSHostRawUserInterface RawUI { get { return _raw; } }
 
        private static Exception NoPrompt() { return new PSInvalidOperationException("The SRx debugger has no interactive host: Read-Host / Get-Credential / choice prompts are not supported while debugging."); }
        public override string ReadLine() { throw NoPrompt(); }
        public override SecureString ReadLineAsSecureString() { throw NoPrompt(); }
        public override Dictionary<string, PSObject> Prompt(string caption, string message, Collection<FieldDescription> descriptions) { throw NoPrompt(); }
        public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) { throw NoPrompt(); }
        public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName, PSCredentialTypes allowedCredentialTypes, PSCredentialUIOptions options) { throw NoPrompt(); }
        public override int PromptForChoice(string caption, string message, Collection<ChoiceDescription> choices, int defaultChoice) { throw NoPrompt(); }
 
        public override void Write(string value) { Append(value); }
        public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) { Append(value); }
        public override void WriteLine(string value) { Append((value ?? "") + "\n"); }
        public override void WriteErrorLine(string value) { }
        public override void WriteDebugLine(string message) { }
        public override void WriteVerboseLine(string message) { }
        public override void WriteWarningLine(string message) { }
        public override void WriteProgress(long sourceId, ProgressRecord record) { }
 
        private void Append(string value)
        {
            if (string.IsNullOrEmpty(value)) return;
            List<string> done = new List<string>();
            lock (_lock)
            {
                foreach (char c in value)
                {
                    if (c == '\r') continue;
                    if (c == '\n') { done.Add(_line.ToString()); _line.Length = 0; continue; }
                    _line.Append(c);
                }
            }
            foreach (string l in done) _host.Emit(l);
        }
 
        public void Flush()
        {
            string rest = null;
            lock (_lock) { if (_line.Length > 0) { rest = _line.ToString(); _line.Length = 0; } }
            if (rest != null) _host.Emit(rest);
        }
    }
 
    internal sealed class DebugRawUI : PSHostRawUserInterface
    {
        private Size _buffer = new Size(200, 3000);
        private Size _window = new Size(200, 50);
        private Coordinates _cursor = new Coordinates(0, 0);
        private ConsoleColor _fg = ConsoleColor.Gray;
        private ConsoleColor _bg = ConsoleColor.Black;
        private string _title = "SRx Debugger";
 
        public override ConsoleColor BackgroundColor { get { return _bg; } set { _bg = value; } }
        public override ConsoleColor ForegroundColor { get { return _fg; } set { _fg = value; } }
        public override Size BufferSize { get { return _buffer; } set { _buffer = value; } }
        public override Coordinates CursorPosition { get { return _cursor; } set { _cursor = value; } }
        public override int CursorSize { get { return 25; } set { } }
        public override bool KeyAvailable { get { return false; } }
        public override Size MaxPhysicalWindowSize { get { return _buffer; } }
        public override Size MaxWindowSize { get { return _buffer; } }
        public override Coordinates WindowPosition { get { return new Coordinates(0, 0); } set { } }
        public override Size WindowSize { get { return _window; } set { _window = value; } }
        public override string WindowTitle { get { return _title; } set { _title = value ?? ""; } }
        public override void FlushInputBuffer() { }
        public override BufferCell[,] GetBufferContents(Rectangle rectangle) { throw new NotSupportedException("GetBufferContents is not supported by the SRx debugger host."); }
        public override KeyInfo ReadKey(ReadKeyOptions options) { throw new PSInvalidOperationException("The SRx debugger has no interactive host: ReadKey is not supported while debugging."); }
        public override void ScrollBufferContents(Rectangle source, Coordinates destination, Rectangle clip, BufferCell fill) { }
        public override void SetBufferContents(Coordinates origin, BufferCell[,] contents) { }
        public override void SetBufferContents(Rectangle rectangle, BufferCell fill) { }
    }
 
    public sealed class VarInfo
    {
        public string Name;
        public string Scope;
        public string Type;
        public string Value;
    }
 
    public sealed class StopInfo
    {
        public int Seq;
        public DateTime Utc;
        public string Script;
        public int Line;
        public int Column;
        public string Position;
        public string Reason; // breakpoint | step
        public string[] Breakpoints;
        public string[] CallStack;
        public VarInfo[] Locals;
        public VarInfo[] Globals;
        public string Error;
    }
 
    internal sealed class Req
    {
        public string Kind; // resume | setbp | removebp | refresh | eval
        public int Seq;
        public string Action;
        public string Script;
        public int Line;
        public string Text;
    }
 
    public sealed class DebugHost : IDisposable
    {
        public static readonly string[] DefaultHidden = new string[] {
            "$", "?", "^", "_", "args", "ConfirmPreference", "ConsoleFileName", "DebugPreference",
            "Error", "ErrorActionPreference", "ErrorView", "ExecutionContext", "false", "FormatEnumerationLimit",
            "HOME", "Host", "InformationPreference", "input", "MaximumAliasCount", "MaximumDriveCount",
            "MaximumErrorCount", "MaximumFunctionCount", "MaximumHistoryCount", "MaximumVariableCount",
            "MyInvocation", "NestedPromptLevel", "null", "OutputEncoding", "PID", "PROFILE", "ProgressPreference",
            "PSBoundParameters", "PSCommandPath", "PSCulture", "PSDebugContext", "PSDefaultParameterValues",
            "PSEmailServer", "PSHOME", "PSItem", "PSScriptRoot", "PSSessionApplicationName",
            "PSSessionConfigurationName", "PSSessionOption", "PSUICulture", "PSVersionTable", "PWD", "ShellId",
            "StackTrace", "true", "VerbosePreference", "WarningPreference", "WhatIfPreference", "this", "foreach",
            "LogCommandHealthEvent", "LogCommandLifecycleEvent", "LogEngineHealthEvent", "LogEngineLifecycleEvent",
            "LogProviderHealthEvent", "LogProviderLifecycleEvent", "Transcript", "EnabledExperimentalFeatures"
        };
 
        private readonly Runspace _rs;
        private readonly ConcurrentQueue<Req> _req = new ConcurrentQueue<Req>();
        private readonly AutoResetEvent _signal = new AutoResetEvent(false); // rev 5: request arrived
        private readonly ConcurrentQueue<StopInfo> _stops = new ConcurrentQueue<StopInfo>();
        private readonly ConcurrentQueue<string> _log = new ConcurrentQueue<string>();
        private readonly HashSet<string> _hidden = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        private volatile bool _stopped;
        private volatile bool _disposed;
        private volatile bool _abort;
        private volatile StopInfo _current;
        private volatile DebugPSHost _psHost;
        private int _seq;
        private int _lastWindowCount = -1;
        public static volatile Thread LastDebugThread; // rev 6: watchdog dumps its stack
 
        public int MaxValueLength = 400;
        public int MaxVariables = 500;
        public int MaxLog = 5000;
        public bool HideAutomatic = true;
        public bool SkipScriptless = true;
        public bool PumpSentMessages = true; // rev 5: keep SendMessage callers (Explorer UI) alive while stopped
        public string HiddenPrefix = "__srxdbg";
        public volatile string LastResume = "";
        public volatile string LastError = "";
        public volatile int SentMessagePasses = 0; // rev 5: diagnostics / tests
        public volatile string ThreadWindowClasses = ""; // rev 5: diagnostics / tests
 
        public DebugHost(Runspace rs)
        {
            if (rs == null) throw new ArgumentNullException("rs");
            if (rs.Debugger == null) throw new InvalidOperationException("The runspace has no debugger (open it first).");
            _rs = rs;
            foreach (string n in DefaultHidden) _hidden.Add(n);
            _rs.Debugger.DebuggerStop += OnStop;
        }
 
        public bool IsStopped { get { return _stopped; } }
        public StopInfo Current { get { return _current; } }
        public int StopCount { get { return Thread.VolatileRead(ref _seq); } }
        public int PendingRequests { get { return _req.Count; } }
 
        public void AttachPSHost(DebugPSHost h)
        {
            if (h == null) return;
            _psHost = h;
            string x;
            while (h.Pending.TryDequeue(out x)) Log(x);
            h.Target = this;
        }
        public void FlushHost() { DebugPSHost h = _psHost; if (h != null) h.Flush(); }
 
        public void Hide(string name) { lock (_hidden) { _hidden.Add(name); } }
        public bool TryDequeueStop(out StopInfo s) { return _stops.TryDequeue(out s); }
        public bool TryDequeueLog(out string s) { return _log.TryDequeue(out s); }
        public void Log(string line)
        {
            if (_log.Count >= MaxLog) { string x; _log.TryDequeue(out x); }
            _log.Enqueue(line ?? "");
        }
 
        // ---- UI thread requests ---------------------------------------------
        public bool Resume(string action)
        {
            if (!_stopped) return false;
            StopInfo cur = _current;
            Req r = new Req();
            r.Kind = "resume";
            r.Action = action;
            r.Seq = cur == null ? 0 : cur.Seq;
            Enqueue(r);
            return true;
        }
        public void SetBreakpoint(string script, int line) { Add("setbp", script, line, null); }
        public void RemoveBreakpoint(string script, int line) { Add("removebp", script, line, null); }
        public void Refresh() { Add("refresh", null, 0, null); }
        public void Evaluate(string text) { Add("eval", null, 0, text); }
        public void Abort() { _abort = true; try { _signal.Set(); } catch { } }
 
        private void Add(string kind, string script, int line, string text)
        {
            Req r = new Req();
            r.Kind = kind;
            r.Script = script;
            r.Line = line;
            r.Text = text;
            Enqueue(r);
        }
        private void Enqueue(Req r)
        {
            _req.Enqueue(r);
            try { _signal.Set(); } catch { }
        }
 
        // ---- streams of the PowerShell instance (writer thread, C# only) -----
        public void AttachStreams(PowerShell ps, PSDataCollection<PSObject> output)
        {
            if (output != null)
            {
                output.DataAdded += delegate (object sender, DataAddedEventArgs e)
                {
                    try { PSDataCollection<PSObject> c = (PSDataCollection<PSObject>)sender; Log("OUT " + Text(c[e.Index])); } catch { }
                };
            }
            if (ps == null) return;
            ps.Streams.Error.DataAdded += delegate (object sender, DataAddedEventArgs e)
            {
                try
                {
                    PSDataCollection<ErrorRecord> c = (PSDataCollection<ErrorRecord>)sender;
                    ErrorRecord r = c[e.Index];
                    string pos = "";
                    if (r.InvocationInfo != null && !string.IsNullOrEmpty(r.InvocationInfo.ScriptName))
                        pos = string.Format(" ({0}:{1})", r.InvocationInfo.ScriptName, r.InvocationInfo.ScriptLineNumber);
                    Log("ERR " + r.ToString() + pos);
                }
                catch { }
            };
            ps.Streams.Warning.DataAdded += delegate (object sender, DataAddedEventArgs e)
            {
                try { PSDataCollection<WarningRecord> c = (PSDataCollection<WarningRecord>)sender; Log("WRN " + c[e.Index].Message); } catch { }
            };
            ps.Streams.Verbose.DataAdded += delegate (object sender, DataAddedEventArgs e)
            {
                try { PSDataCollection<VerboseRecord> c = (PSDataCollection<VerboseRecord>)sender; Log("VRB " + c[e.Index].Message); } catch { }
            };
            ps.Streams.Information.DataAdded += delegate (object sender, DataAddedEventArgs e)
            {
                try
                {
                    PSDataCollection<InformationRecord> c = (PSDataCollection<InformationRecord>)sender;
                    InformationRecord rec = c[e.Index];
                    if (_psHost != null && rec.Tags != null && rec.Tags.Contains("PSHOST")) return;
                    object md = rec.MessageData;
                    HostInformationMessage him = md as HostInformationMessage;
                    Log("INF " + (him != null ? him.Message : Text(md)));
                }
                catch (Exception ex) { Log("INF <" + ex.Message + ">"); }
            };
        }
 
        // ---- the stop (pipeline thread) -------------------------------------
        private Req WaitRequest(int ms)
        {
            Req r;
            if (_req.TryDequeue(out r)) return r;
            if (PumpSentMessages)
            {
                try { int p = NativeWait.Wait(_signal, ms); if (p > 0) SentMessagePasses += p; }
                catch (Exception ex) { PumpSentMessages = false; Log("DBG sent-message pump disabled: " + ex.Message); _signal.WaitOne(ms); }
            }
            else _signal.WaitOne(ms);
            if (_req.TryDequeue(out r)) return r;
            return null;
        }
 
        private void ReportThreadWindows(int seq)
        {
            try
            {
                string[] w = NativeWait.ThreadWindows();
                ThreadWindowClasses = string.Join(", ", w);
                if (w.Length == _lastWindowCount) return;
                _lastWindowCount = w.Length;
                Log(string.Format("DBG debug thread {0} ({1}) owns {2} window(s){3}{4}",
                    NativeWait.GetCurrentThreadId(), Thread.CurrentThread.GetApartmentState(), w.Length,
                    w.Length > 0 ? ": " : "", string.Join(", ", w)));
            }
            catch (Exception ex) { Log("DBG thread window check failed: " + ex.Message); }
        }
 
        // rev 7: stops raised on a thread whose runspace is not the debug
        // runspace (UI thread executing a class method bound to this engine)
        public volatile int ForeignStops = 0;
        private readonly HashSet<string> _foreignLogged = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
        public bool HoldForeignStops = false;
 
        private void OnStop(object sender, DebuggerStopEventArgs e)
        {
            if (!HoldForeignStops && !object.ReferenceEquals(Runspace.DefaultRunspace, _rs))
            {
                ForeignStops++;
                DebuggerResumeAction keep = ParseAction(LastResume);
                if (keep == DebuggerResumeAction.Stop) keep = DebuggerResumeAction.Continue;
                e.ResumeAction = keep;
                string where = "?";
                try { if (e.InvocationInfo != null) where = string.Format("{0}:{1}", e.InvocationInfo.ScriptName, e.InvocationInfo.ScriptLineNumber); } catch { }
                string key = where;
                int colon = where.LastIndexOf(':');
                if (colon > 0) key = where.Substring(0, colon);
                bool log;
                lock (_foreignLogged) { log = _foreignLogged.Add(key); }
                if (log) Log(string.Format("DBG foreign-thread stop ignored (managed thread {0}, at {1}) - a class of a module imported into the debug runspace is shared with Explorer", Thread.CurrentThread.ManagedThreadId, where));
                return;
            }
            if (_disposed || _abort) { e.ResumeAction = DebuggerResumeAction.Stop; return; }
            Debugger dbg = sender as Debugger;
            if (dbg == null) dbg = _rs.Debugger;
            InvocationInfo ii = e.InvocationInfo;
            string script = ii == null ? null : ii.ScriptName;
            bool hasBp = e.Breakpoints != null && e.Breakpoints.Count > 0;
            if (SkipScriptless && string.IsNullOrEmpty(script) && !hasBp)
            {
                e.ResumeAction = DebuggerResumeAction.Continue;
                Log("DBG stop outside any script file - continued");
                return;
            }
            int seq = Interlocked.Increment(ref _seq);
            LastDebugThread = Thread.CurrentThread;
            UiWatchdog.Note(string.Format("stop #{0} at {1}:{2}", seq, script, ii == null ? 0 : ii.ScriptLineNumber));
            ReportThreadWindows(seq);
            int passesBefore = SentMessagePasses;
            StopInfo info = Snapshot(dbg, e, seq);
            _current = info;
            _stopped = true;
            _stops.Enqueue(info);
            DebuggerResumeAction act = DebuggerResumeAction.Continue;
            try
            {
                while (true)
                {
                    Req r = WaitRequest(200);
                    if (r == null)
                    {
                        if (_disposed || _abort) { act = DebuggerResumeAction.Stop; break; }
                        continue;
                    }
                    if (r.Kind == "resume")
                    {
                        if (r.Seq != 0 && r.Seq != seq) continue; // stale resume
                        act = ParseAction(r.Action);
                        break;
                    }
                    try { Handle(dbg, r, e, seq); }
                    catch (Exception ex) { LastError = ex.Message; Log("DBG " + r.Kind + " failed: " + ex.Message); }
                }
            }
            finally
            {
                LastResume = act.ToString();
                _stopped = false;
                UiWatchdog.Note("resumed: " + act);
                int n = SentMessagePasses - passesBefore;
                if (n > 0) Log(string.Format("DBG stop #{0}: {1} sent-message dispatch pass(es) while stopped", seq, n));
            }
            e.ResumeAction = act;
        }
 
        private static DebuggerResumeAction ParseAction(string a)
        {
            if (string.IsNullOrEmpty(a)) return DebuggerResumeAction.Continue;
            try { return (DebuggerResumeAction)Enum.Parse(typeof(DebuggerResumeAction), a, true); }
            catch { return DebuggerResumeAction.Continue; }
        }
 
        private void Handle(Debugger dbg, Req r, DebuggerStopEventArgs e, int seq)
        {
            PSDataCollection<PSObject> output = new PSDataCollection<PSObject>();
            PSCommand cmd = null;
            switch (r.Kind)
            {
                case "setbp":
                    cmd = new PSCommand();
                    cmd.AddCommand("Set-PSBreakpoint").AddParameter("Script", r.Script).AddParameter("Line", r.Line);
                    dbg.ProcessCommand(cmd, output);
                    Log(string.Format("DBG breakpoint set {0}:{1}", r.Script, r.Line));
                    break;
                case "removebp":
                    cmd = new PSCommand();
                    cmd.AddScript("param($s, $l) Get-PSBreakpoint -Script $s | Where-Object { $_.Line -eq $l } | Remove-PSBreakpoint").AddArgument(r.Script).AddArgument(r.Line);
                    dbg.ProcessCommand(cmd, output);
                    Log(string.Format("DBG breakpoint removed {0}:{1}", r.Script, r.Line));
                    break;
                case "refresh":
                    StopInfo s = Snapshot(dbg, e, seq);
                    _current = s;
                    _stops.Enqueue(s);
                    break;
                case "eval":
                    cmd = new PSCommand();
                    cmd.AddScript(r.Text ?? "").AddCommand("Out-String");
                    dbg.ProcessCommand(cmd, output);
                    foreach (PSObject o in output) Log("EVL " + Text(o));
                    break;
            }
        }
 
        private StopInfo Snapshot(Debugger dbg, DebuggerStopEventArgs e, int seq)
        {
            StopInfo s = new StopInfo();
            s.Seq = seq;
            s.Utc = DateTime.UtcNow;
            s.Error = "";
            InvocationInfo ii = e.InvocationInfo;
            if (ii != null)
            {
                s.Script = ii.ScriptName;
                s.Line = ii.ScriptLineNumber;
                s.Column = ii.OffsetInLine;
                try { s.Position = ii.PositionMessage; } catch { s.Position = ""; }
            }
            List<string> bps = new List<string>();
            if (e.Breakpoints != null) foreach (Breakpoint b in e.Breakpoints) bps.Add(b.ToString());
            s.Breakpoints = bps.ToArray();
            s.Reason = bps.Count > 0 ? "breakpoint" : "step";
            List<string> stack = new List<string>();
            List<VarInfo> locals = new List<VarInfo>();
            List<VarInfo> globals = new List<VarInfo>();
            HashSet<string> seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
            StringBuilder err = new StringBuilder();
            try
            {
                PSDataCollection<PSObject> frames = new PSDataCollection<PSObject>();
                PSCommand c1 = new PSCommand();
                c1.AddCommand("Get-PSCallStack");
                dbg.ProcessCommand(c1, frames);
                CallStackFrame top = null;
                foreach (PSObject o in frames)
                {
                    CallStackFrame f = o == null ? null : o.BaseObject as CallStackFrame;
                    if (f == null) continue;
                    if (top == null) top = f;
                    stack.Add(f.ToString());
                }
                if (top != null)
                {
                    try
                    {
                        Dictionary<string, PSVariable> fv = top.GetFrameVariables();
                        foreach (KeyValuePair<string, PSVariable> kv in fv)
                        {
                            if (kv.Value == null || seen.Contains(kv.Value.Name)) continue;
                            seen.Add(kv.Value.Name);
                            AddVar(locals, kv.Value, "Local");
                        }
                    }
                    catch { }
                }
            }
            catch (Exception ex) { err.Append("stack: " + ex.Message + " "); }
            try
            {
                PSDataCollection<PSObject> lv = new PSDataCollection<PSObject>();
                PSCommand c3 = new PSCommand();
                c3.AddCommand("Get-Variable").AddParameter("Scope", "Local");
                dbg.ProcessCommand(c3, lv);
                foreach (PSObject o in lv)
                {
                    PSVariable v = o == null ? null : o.BaseObject as PSVariable;
                    if (v == null || seen.Contains(v.Name)) continue;
                    seen.Add(v.Name);
                    AddVar(locals, v, "Local");
                }
            }
            catch (Exception ex) { err.Append("locals: " + ex.Message + " "); }
            try
            {
                PSDataCollection<PSObject> gv = new PSDataCollection<PSObject>();
                PSCommand c2 = new PSCommand();
                c2.AddCommand("Get-Variable").AddParameter("Scope", "Global");
                dbg.ProcessCommand(c2, gv);
                foreach (PSObject o in gv)
                {
                    PSVariable v = o == null ? null : o.BaseObject as PSVariable;
                    if (v != null) AddVar(globals, v, "Global");
                }
            }
            catch (Exception ex) { err.Append("globals: " + ex.Message); }
            locals.Sort(delegate (VarInfo a, VarInfo b) { return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); });
            globals.Sort(delegate (VarInfo a, VarInfo b) { return string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase); });
            s.CallStack = stack.ToArray();
            s.Locals = locals.ToArray();
            s.Globals = globals.ToArray();
            s.Error = err.ToString().Trim();
            return s;
        }
 
        private void AddVar(List<VarInfo> list, PSVariable v, string scope)
        {
            if (v == null || list.Count >= MaxVariables) return;
            string n = v.Name ?? "";
            if (n.StartsWith(HiddenPrefix, StringComparison.OrdinalIgnoreCase)) return;
            if (HideAutomatic) { lock (_hidden) { if (_hidden.Contains(n)) return; } }
            VarInfo x = new VarInfo();
            x.Name = n;
            x.Scope = scope;
            object val = null;
            try { val = v.Value; } catch (Exception ex) { x.Type = "?"; x.Value = "<" + ex.Message + ">"; list.Add(x); return; }
            x.Type = TypeOf(val);
            x.Value = Format(val, MaxValueLength);
            list.Add(x);
        }
 
        public static string TypeOf(object v)
        {
            if (v == null) return "null";
            PSObject p = v as PSObject;
            object b = p != null ? p.BaseObject : v;
            if (b is PSCustomObject) return "PSCustomObject";
            if (p != null && p.TypeNames.Count > 0 && p.TypeNames[0].StartsWith("Deserialized.")) return p.TypeNames[0];
            return b.GetType().Name;
        }
 
        public static string Format(object v, int max)
        {
            string s;
            try { s = FormatCore(v); }
            catch (Exception ex) { s = "<" + ex.GetType().Name + ": " + ex.Message + ">"; }
            if (s == null) s = "";
            s = s.Replace("\r", " ").Replace("\n", " ");
            if (max > 3 && s.Length > max) s = s.Substring(0, max - 3) + "...";
            return s;
        }
 
        private static string FormatCore(object v)
        {
            if (v == null) return "$null";
            PSObject p = v as PSObject;
            object b = p != null ? p.BaseObject : v;
            if (b is string) return "\"" + (string)b + "\"";
            if (b is bool) return ((bool)b) ? "$true" : "$false";
            if (b is ScriptBlock) return "{" + b.ToString() + "}";
            IDictionary d = b as IDictionary;
            if (d != null)
            {
                StringBuilder sb = new StringBuilder();
                sb.Append(b.GetType().Name).Append(" (").Append(d.Count).Append(") {");
                int i = 0;
                foreach (DictionaryEntry de in d)
                {
                    if (i == 5) { sb.Append(" ..."); break; }
                    if (i > 0) sb.Append(";");
                    sb.Append(" ").Append(Text(de.Key)).Append(" = ").Append(Text(de.Value));
                    i++;
                }
                return sb.Append(" }").ToString();
            }
            ICollection c = b as ICollection;
            if (c != null)
            {
                StringBuilder sb = new StringBuilder();
                sb.Append(b.GetType().Name).Append(" (").Append(c.Count).Append(")");
                int i = 0;
                foreach (object o in c)
                {
                    sb.Append(i == 0 ? ": " : ", ");
                    if (i == 5) { sb.Append("..."); break; }
                    sb.Append(Text(o));
                    i++;
                }
                return sb.ToString();
            }
            return Text(v);
        }
 
        public static string Text(object v)
        {
            if (v == null) return "$null";
            try { string s = LanguagePrimitives.ConvertTo<string>(v); return s ?? ""; }
            catch { try { return v.ToString(); } catch { return "?"; } }
        }
 
        public void Dispose()
        {
            if (_disposed) return;
            _disposed = true;
            _abort = true;
            try { _signal.Set(); } catch { }
            try { _rs.Debugger.DebuggerStop -= OnStop; } catch { }
            // _signal is not disposed: a stopped handler may still wait on it.
        }
    }
}
'@

}
#endregion

#region ---- scripts that run in the debug runspace ----------------------------
function Get-SRxDebugSetupScript {
    <#
        .SYNOPSIS
        Script the debug runspace runs. The same three steps as the production
        runtime, then the code fragment is loaded by Start-Rule (= <start>).
        The arguments are a PARAMETER (no SessionStateProxy call).
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    return @'
param($__srxdbg)
# ---- 1) Initialize-SRx: the production runtime environment
if (-not [string]::IsNullOrEmpty([string]$__srxdbg.Bootstrap)) {
    . ([scriptblock]::Create([string]$__srxdbg.Bootstrap))
}
elseif ($null -ne (Get-Command -Name Initialize-SRxEnv -ErrorAction SilentlyContinue)) {
    $__srxdbgInit = @{ NoHostUI = $true }
    if (-not [string]::IsNullOrEmpty([string]$__srxdbg.RootPath)) { $__srxdbgInit['RootPath'] = [string]$__srxdbg.RootPath }
    if (-not [string]::IsNullOrEmpty([string]$__srxdbg.SourceId)) { $__srxdbgInit['SourceID'] = [string]$__srxdbg.SourceId }
    $null = Initialize-SRxEnv @__srxdbgInit
}
else {
    Write-Warning 'SRx debugger: Initialize-SRxEnv not found - the code runs without the SRx environment'
}
if ($null -eq $global:SRxEnv) { $global:SRxEnv = [pscustomobject]@{ Pipeline = $null } }
# ---- 2) copy of the pipeline
$__srxdbgPipe = @()
if (-not [string]::IsNullOrEmpty([string]$__srxdbg.PipelineXml)) {
    $__srxdbgPipe = @([System.Management.Automation.PSSerializer]::Deserialize([string]$__srxdbg.PipelineXml))
}
if ($null -ne $global:SRxEnv.PSObject.Methods['SetCustomProperty']) { $global:SRxEnv.SetCustomProperty('Pipeline', $__srxdbgPipe) }
else { $global:SRxEnv | Add-Member -NotePropertyName Pipeline -NotePropertyValue $__srxdbgPipe -Force }
# ---- 3) breakpoints: automatic first line + the user's
foreach ($__srxdbgBp in @($__srxdbg.Breakpoints)) {
    if ($null -eq $__srxdbgBp) { continue }
    $null = Set-PSBreakpoint -Script ([string]$__srxdbgBp.Path) -Line ([int]$__srxdbgBp.Line)
}
# ---- 4) load the code fragment the way <start> does
if (-not [string]::IsNullOrEmpty([string]$__srxdbg.Command)) {
    . ([scriptblock]::Create([string]$__srxdbg.Command))
}
elseif (-not [string]::IsNullOrEmpty([string]$__srxdbg.RuleName)) {
    Start-Rule -Name ([string]$__srxdbg.RuleName)
}
else {
    . ([string]$__srxdbg.Path)
}
'@

}

function Get-SRxDebugTeardownScript {
    <#
        .SYNOPSIS
        rev 5: runs in the debug runspace before it is disposed. Releases what
        SRxCore / the rule registered from inside the runspace, so nothing in
        Explorer keeps calling into a disposed runspace. Returns one summary line.
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param()
    return @'
$__srxdbgR = New-Object System.Collections.Generic.List[string]
foreach ($__srxdbgCmd in @('Disconnect-SRxJobsChild')) {
    if ($null -eq (Get-Command -Name $__srxdbgCmd -ErrorAction SilentlyContinue)) { $__srxdbgR.Add("$__srxdbgCmd not available"); continue }
    try { $null = & $__srxdbgCmd -ErrorAction Stop; $__srxdbgR.Add("$__srxdbgCmd done") }
    catch { $__srxdbgR.Add("$__srxdbgCmd failed: $($_.Exception.Message)") }
}
$__srxdbgSubs = @(Get-EventSubscriber -Force -ErrorAction SilentlyContinue)
$__srxdbgNames = New-Object System.Collections.Generic.List[string]
foreach ($__srxdbgS in $__srxdbgSubs) {
    $__srxdbgSrc = '?'
    try { if ($null -ne $__srxdbgS.SourceObject) { $__srxdbgSrc = $__srxdbgS.SourceObject.GetType().Name } else { $__srxdbgSrc = 'engine' } } catch { }
    $__srxdbgEvt = [string]$__srxdbgS.EventName
    if ([string]::IsNullOrEmpty($__srxdbgEvt)) { $__srxdbgEvt = [string]$__srxdbgS.SourceIdentifier }
    $__srxdbgNames.Add(('{0}.{1}' -f $__srxdbgSrc, $__srxdbgEvt))
    try { Unregister-Event -SubscriptionId $__srxdbgS.SubscriptionId -Force -ErrorAction Stop } catch { }
}
$__srxdbgR.Add(('{0} event subscriber(s) removed{1}' -f $__srxdbgSubs.Count, $(if ($__srxdbgNames.Count -gt 0) { ': ' + ($__srxdbgNames -join ', ') } else { '' })))
$__srxdbgJobs = @(Get-Job -ErrorAction SilentlyContinue)
foreach ($__srxdbgJ in $__srxdbgJobs) { try { Stop-Job -Job $__srxdbgJ -ErrorAction SilentlyContinue; Remove-Job -Job $__srxdbgJ -Force -ErrorAction SilentlyContinue } catch { } }
$__srxdbgR.Add(('{0} PS job(s) removed' -f $__srxdbgJobs.Count))
$__srxdbgR -join '; '
'@

}
#endregion

#region ---- SRxDebugSession (one run of one file) -----------------------------
class SRxDebugSession {
    [string]   $Id = ''
    [string]   $Path = ''
    [string]   $Kind = ''
    [string]   $RuleName = ''
    [string]   $Title = ''
    [string]   $State = 'Created'      # Created | Opening | Running | Stopped | Stopping | Ended
    [string]   $EndState = ''          # Completed | Failed | Stopped
    [string]   $EndReason = ''
    [string]   $TeardownResult = ''    # rev 5
    [int]      $FirstLine = 1
    [int]      $Stops = 0
    [bool]     $HitFile = $false
    [object]   $LastStop = $null       # SRxDebug7.StopInfo
    [hashtable]$Spec = @{}
    [int]      $StopTimeoutMs = 5000
    [int]      $ReadyTimeoutMs = 30000
    [int]      $TeardownTimeoutMs = 3000   # rev 5
    [int]      $StartAttempts = 0
    [int]      $MaxEventsPerPoll = 300
    [int]      $ErrorCount = 0
    [string]   $LastErrorText = ''
    [System.Collections.Generic.List[string]] $Output
    [scriptblock] $OnTrace = $null
    [object]   $Orphan = $null
    hidden [object]   $_owner = $null
    hidden [object]   $_rs = $null
    hidden [object]   $_ps = $null
    hidden [object]   $_host = $null       # SRxDebug7.DebugHost
    hidden [object]   $_psHost = $null     # SRxDebug7.DebugPSHost
    hidden [object]   $_async = $null
    hidden [object]   $_out = $null
    hidden [hashtable] $_args = $null
    hidden [bool]     $_invoked = $false
    hidden [bool]     $_stopRequested = $false
    hidden [datetime] $_stopRequestedUtc = [datetime]::MinValue
    hidden [datetime] $_openedUtc = [datetime]::MinValue
    hidden [string]   $_lastBusy = ''
    hidden [bool]     $_endReported = $false
    hidden [System.Collections.ArrayList] $_pendingEvents

    SRxDebugSession([object]$owner, [hashtable]$spec) {
        $this._owner = $owner
        $this.Spec = $spec
        $this.Id = [guid]::NewGuid().ToString('N')
        $this.Output = New-Object 'System.Collections.Generic.List[string]'
        $this._pendingEvents = New-Object System.Collections.ArrayList
        $this.Path = [System.IO.Path]::GetFullPath([string]$spec['Path'])
        $this.Kind = [string]$spec['Kind']
        $this.RuleName = [string]$spec['RuleName']
        $this.Title = [string]$spec['Title']
        if ([string]::IsNullOrEmpty($this.Title)) { $this.Title = [System.IO.Path]::GetFileName($this.Path) }
    }

    [void] Start() {
        if ($this.State -ne 'Created') { throw 'SRxDebugSession.Start: already started.' }
        if (-not [System.IO.File]::Exists($this.Path)) { throw ("SRxDebugSession.Start: file not found: {0}" -f $this.Path) }
        $this.FirstLine = [SRxDebugger]::FirstExecutableLine($this.Path)
        $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault()
        try { $iss.ExecutionPolicy = 'Bypass' } catch { }
        $isolate = $true
        if ($null -ne $this._owner) { $isolate = [bool]$this._owner.IsolateModules }
        foreach ($m in @($this.Spec['Modules'])) {
            if ([string]::IsNullOrWhiteSpace([string]$m)) { continue }
            $modPath = [string]$m
            if ($isolate) {
                try { $modPath = [SRxDebugger]::IsolatedModulePath($modPath, [string]$this._owner.ModuleCacheRoot) ; $this.trace(("module {0} isolated: {1}" -f [System.IO.Path]::GetFileNameWithoutExtension([string]$m), $modPath)) }
                catch { $modPath = [string]$m; $this.trace(("module '{0}' NOT isolated ({1}) - Explorer's classes may be shared with the debug runspace" -f $m, $_.Exception.Message)) }
            }
            try { [void]$iss.ImportPSModule([string[]]@($modPath)) } catch { $this.trace("module '$modPath' not queued: $($_.Exception.Message)") }
        }
        $this._psHost = New-Object -TypeName 'SRxDebug7.DebugPSHost'
        $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($this._psHost, $iss)
        try { $rs.ApartmentState = [System.Threading.ApartmentState]::STA } catch { }
        try { $rs.ThreadOptions = [System.Management.Automation.Runspaces.PSThreadOptions]::ReuseThread } catch { }
        try { $rs.Name = 'SRxDebugger' } catch { }
        $this._rs = $rs
        $this.State = 'Opening'
        $rs.OpenAsync()
        $this.trace(("session {0}: opening the debug runspace for '{1}' (first line {2}, {3} module(s))" -f $this.Id, $this.Path, $this.FirstLine, @($this.Spec['Modules']).Count))
    }

    hidden [void] prepare() {
        if ($null -ne $this._host) { return }
        $rs = $this._rs
        try { $rs.Debugger.SetDebugMode([System.Management.Automation.DebugModes]::LocalScript) } catch { }
        $h = New-Object -TypeName 'SRxDebug7.DebugHost' -ArgumentList $rs
        if ($null -ne $this._owner) {
            $h.HideAutomatic = [bool]$this._owner.HideAutomaticVariables
            $h.MaxValueLength = [int]$this._owner.MaxValueLength
            $h.PumpSentMessages = [bool]$this._owner.PumpSentMessages
        }
        $h.AttachPSHost($this._psHost)
        $this._host = $h
        $bps = New-Object System.Collections.ArrayList
        [void]$bps.Add(@{ Path = $this.Path; Line = $this.FirstLine })
        if ($null -ne $this._owner) {
            foreach ($b in @($this._owner.AllBreakpoints())) {
                if ([string]::Equals([string]$b.Path, $this.Path, [System.StringComparison]::OrdinalIgnoreCase) -and [int]$b.Line -eq $this.FirstLine) { continue }
                [void]$bps.Add($b)
            }
        }
        $xml = ''
        if ($null -ne $this.Spec['Pipeline']) {
            $xml = [System.Management.Automation.PSSerializer]::Serialize([object[]]@($this.Spec['Pipeline']), 6)
        }
        $this._args = @{
            Path        = $this.Path
            Kind        = $this.Kind
            RuleName    = $this.RuleName
            RootPath    = [string]$this.Spec['RootPath']
            SourceId    = [string]$this.Spec['SourceId']
            PipelineXml = $xml
            Breakpoints = [object[]]$bps.ToArray()
            Bootstrap   = [string]$this.Spec['Bootstrap']
            Command     = [string]$this.Spec['Command']
        }
    }

    hidden [bool] tryInvoke() {
        $this.prepare()
        $rs = $this._rs
        $avail = ''
        try { $avail = [string]$rs.RunspaceAvailability } catch { $avail = '' }
        if (-not [string]::IsNullOrEmpty($avail) -and $avail -ne 'Available') {
            if ($avail -ne $this._lastBusy) { $this.trace(("session {0}: runspace {1} after open - waiting" -f $this.Id, $avail)) }
            $this._lastBusy = $avail
            return $false
        }
        $this.StartAttempts++
        $ps = [System.Management.Automation.PowerShell]::Create()
        $ps.Runspace = $rs
        [void]$ps.AddScript((Get-SRxDebugSetupScript)).AddArgument($this._args)
        $in = New-Object 'System.Management.Automation.PSDataCollection[psobject]'
        $in.Complete()
        $out = New-Object 'System.Management.Automation.PSDataCollection[psobject]'
        $this._host.AttachStreams($ps, $out)
        try {
            $this._async = $ps.BeginInvoke($in, $out)
        }
        catch {
            $msg = [string]$_.Exception.Message
            if ($null -ne $_.Exception.InnerException) { $msg += ' ' + [string]$_.Exception.InnerException.Message }
            try { $ps.Dispose() } catch { }
            if ($msg -match 'already running|concurrent|cannot be run concurrently|busy') {
                $this.trace(("session {0}: start attempt {1} - runspace busy ({2}) - retrying" -f $this.Id, $this.StartAttempts, $msg))
                return $false
            }
            throw
        }
        $this._ps = $ps
        $this._out = $out
        $this._invoked = $true
        $this.State = 'Running'
        $this.trace(("session {0}: running ({1} breakpoint(s), start attempt {2}, {3:N0} ms after open)" -f $this.Id, @($this._args.Breakpoints).Count, $this.StartAttempts, ([datetime]::UtcNow - $this._openedUtc).TotalMilliseconds))
        return $true
    }

    [object[]] Poll() {
        $ev = New-Object System.Collections.ArrayList
        foreach ($p in @($this._pendingEvents)) { [void]$ev.Add($p) }
        $this._pendingEvents.Clear()
        if ($this._endReported) { return [object[]]$ev.ToArray() }
        if ($this.State -eq 'Opening') {
            $st = [string]$this._rs.RunspaceStateInfo.State
            if ($st -eq 'Opened') {
                if ($this._openedUtc -eq [datetime]::MinValue) { $this._openedUtc = [datetime]::UtcNow }
                $started = $false
                try { $started = $this.tryInvoke() }
                catch {
                    $this.finish('Failed', ("debugger start failed: {0}" -f $_.Exception.Message))
                    [void]$ev.Add($this.endedEvent())
                    return [object[]]$ev.ToArray()
                }
                if ($started) {
                    [void]$ev.Add([pscustomobject]@{ Type = 'opened'; Text = ''; Stream = ''; Stop = $null; State = ''; Reason = '' })
                }
                elseif (([datetime]::UtcNow - $this._openedUtc).TotalMilliseconds -gt $this.ReadyTimeoutMs) {
                    $this.finish('Failed', ("debugger start failed: the debug runspace stayed busy ({0}) for {1} s after it opened - a module imported into it keeps running pipelines" -f $this._lastBusy, [int]($this.ReadyTimeoutMs / 1000)))
                    [void]$ev.Add($this.endedEvent())
                    return [object[]]$ev.ToArray()
                }
                else {
                    $this.drainLog($ev)
                    return [object[]]$ev.ToArray()
                }
            }
            elseif ($st -eq 'Broken' -or $st -eq 'Closed') {
                $why = ''
                try { $why = [string]$this._rs.RunspaceStateInfo.Reason.Message } catch { $why = '' }
                $this.finish('Failed', ("the debug runspace could not be opened: {0}" -f $why))
                [void]$ev.Add($this.endedEvent())
                return [object[]]$ev.ToArray()
            }
            else { return [object[]]$ev.ToArray() }
        }
        if ($null -eq $this._host) { return [object[]]$ev.ToArray() }
        $this.drainLog($ev)
        $s = $null
        $gotStop = $false
        while ($this._host.TryDequeueStop([ref]$s)) {
            $gotStop = $true
            $this.LastStop = $s
            $this.Stops++
            if ([string]::Equals([string]$s.Script, $this.Path, [System.StringComparison]::OrdinalIgnoreCase)) { $this.HitFile = $true }
            $this.State = 'Stopped'
            if ($this._stopRequested) { $this.State = 'Stopping' }
            [void]$ev.Add([pscustomobject]@{ Type = 'stopped'; Text = ''; Stream = ''; Stop = $s; State = 'Stopped'; Reason = [string]$s.Reason })
        }
        if (-not $gotStop -and $this.State -eq 'Stopped' -and -not $this._host.IsStopped) {
            $this.State = 'Running'
            [void]$ev.Add([pscustomobject]@{ Type = 'running'; Text = [string]$this._host.LastResume; Stream = ''; Stop = $null; State = 'Running'; Reason = '' })
        }
        $done = $false
        if ($null -ne $this._async) { try { $done = [bool]$this._async.IsCompleted } catch { $done = $false } }
        if ($done) {
            $err = ''
            try { [void]$this._ps.EndInvoke($this._async) } catch { $err = $_.Exception.Message }
            try { $this._host.FlushHost() } catch { }
            $this.drainLog($ev)
            $inv = ''
            $why = ''
            try { $inv = [string]$this._ps.InvocationStateInfo.State } catch { $inv = '' }
            try { if ($null -ne $this._ps.InvocationStateInfo.Reason) { $why = [string]$this._ps.InvocationStateInfo.Reason.Message } } catch { $why = '' }
            if ([string]::IsNullOrEmpty($why)) { $why = $err }
            $end = 'Completed'
            if ($inv -eq 'Failed') { $end = 'Failed' }
            elseif ($inv -eq 'Stopped' -or $this._stopRequested) { $end = 'Stopped'; $why = 'stopped by the user' }
            $this.finish($end, $why)
            [void]$ev.Add($this.endedEvent())
        }
        elseif ($this._stopRequested -and ([datetime]::UtcNow - $this._stopRequestedUtc).TotalMilliseconds -gt $this.StopTimeoutMs) {
            $this.finish('Stopped', ("the run did not stop within {0} ms - debug runspace closed" -f $this.StopTimeoutMs))
            [void]$ev.Add($this.endedEvent())
        }
        return [object[]]$ev.ToArray()
    }

    hidden [void] drainLog([System.Collections.ArrayList]$ev) {
        if ($null -eq $this._host) { return }
        $line = $null
        $n = 0
        while ($n -lt $this.MaxEventsPerPoll -and $this._host.TryDequeueLog([ref]$line)) {
            $n++
            $text = [string]$line
            $stream = 'OUT'
            if ($text.Length -ge 4 -and $text[3] -eq ' ') { $stream = $text.Substring(0, 3); $text = $text.Substring(4) }
            if ($stream -eq 'ERR') { $this.ErrorCount++; $this.LastErrorText = $text }
            $this.Output.Add(('{0} {1}' -f $stream, $text))
            while ($this.Output.Count -gt 2000) { $this.Output.RemoveAt(0) }
            [void]$ev.Add([pscustomobject]@{ Type = 'output'; Text = $text; Stream = $stream; Stop = $null; State = ''; Reason = '' })
        }
    }

    hidden [object] endedEvent() {
        $this._endReported = $true
        return [pscustomobject]@{ Type = 'ended'; Text = ''; Stream = ''; Stop = $this.LastStop; State = $this.EndState; Reason = $this.EndReason }
    }

    hidden [void] finish([string]$endState, [string]$reason) {
        if ($this.State -eq 'Ended') { return }
        $this.State = 'Ended'
        $this.EndState = $endState
        $this.EndReason = $reason
        $this.TeardownResult = $this.teardown()
        if (-not [string]::IsNullOrEmpty($this.TeardownResult)) { $this.trace(("session {0}: teardown: {1}" -f $this.Id, $this.TeardownResult)) }
        $this.dispose($endState -ne 'Completed' -and -not ($null -ne $this._async -and [bool]$this._async.IsCompleted))
        $this.trace(("session {0}: ended {1} ({2} stop(s)) {3}" -f $this.Id, $endState, $this.Stops, $reason))
    }

    # rev 5: release what was registered from inside the runspace. Only when
    # the runspace is idle (a stopped / busy one is abandoned as before).
    hidden [string] teardown() {
        $rs = $this._rs
        if ($null -eq $rs) { return '' }
        $st = ''
        $avail = ''
        try { $st = [string]$rs.RunspaceStateInfo.State; $avail = [string]$rs.RunspaceAvailability } catch { return '' }
        if ($st -ne 'Opened') { return '' }
        if ($avail -ne 'Available') { return ("skipped - runspace {0}" -f $avail) }
        $ps = $null
        try {
            $ps = [System.Management.Automation.PowerShell]::Create()
            $ps.Runspace = $rs
            [void]$ps.AddScript((Get-SRxDebugTeardownScript))
            $a = $ps.BeginInvoke()
            if (-not $a.AsyncWaitHandle.WaitOne($this.TeardownTimeoutMs)) {
                try { [void]$ps.BeginStop($null, $null) } catch { }
                $ps = $null
                return ("timed out after {0} ms" -f $this.TeardownTimeoutMs)
            }
            return (@($ps.EndInvoke($a)) -join '; ')
        }
        catch { return ("failed: {0}" -f $_.Exception.Message) }
        finally { if ($null -ne $ps) { try { $ps.Dispose() } catch { } } }
    }

    hidden [void] dispose([bool]$abandon) {
        if ($null -ne $this._host) { try { $this._host.Dispose() } catch { } }
        if (-not $abandon -and $null -ne $this._ps) { try { $this._ps.Dispose() } catch { } }
        if ($null -ne $this._rs) {
            if ($abandon) {
                $closing = $false
                try { $this._rs.CloseAsync(); $closing = $true } catch { $closing = $false }
                if (-not $closing) { $this.Orphan = $this._rs }
            }
            else { try { $this._rs.Dispose() } catch { } }
        }
        $this._ps = $null
        $this._rs = $null
    }

    [bool] IsActive() { return ($this.State -ne 'Ended' -and $this.State -ne 'Created') }
    [bool] IsStopped() { return ($this.State -eq 'Stopped') }

    [bool] Resume([string]$action) {
        if ($action -eq 'Stop') { $this.Stop(); return $true }
        if ($this.State -ne 'Stopped' -or $null -eq $this._host) { return $false }
        if ($action -notin @('Continue', 'StepOver', 'StepInto', 'StepOut')) { throw "SRxDebugSession.Resume: unknown action '$action'." }
        $ok = [bool]$this._host.Resume($action)
        if ($ok) { $this.trace(("session {0}: {1}" -f $this.Id, $action)) }
        return $ok
    }

    [void] Stop() {
        if ($this.State -eq 'Ended' -or $this.State -eq 'Created') { return }
        if ($this.State -eq 'Opening') {
            $this._stopRequested = $true
            $this.finish('Stopped', 'cancelled before the code started')
            [void]$this._pendingEvents.Add($this.endedEvent())
            return
        }
        if ($this._stopRequested) { return }
        $this._stopRequested = $true
        $this._stopRequestedUtc = [datetime]::UtcNow
        $this.State = 'Stopping'
        if ($null -ne $this._host) { $this._host.Abort() }
        if ($null -ne $this._ps) { try { [void]$this._ps.BeginStop($null, $null) } catch { } }
        $this.trace(("session {0}: stop requested" -f $this.Id))
    }

    [void] ApplyBreakpoint([string]$path, [int]$line, [bool]$on) {
        if ($this.State -eq 'Ended' -or $null -eq $this._host) { return }
        if (-not $this._invoked) {
            if ($null -ne $this._args) {
                $list = New-Object System.Collections.ArrayList
                foreach ($b in @($this._args.Breakpoints)) {
                    if ([string]::Equals([string]$b.Path, $path, [System.StringComparison]::OrdinalIgnoreCase) -and [int]$b.Line -eq $line) { continue }
                    [void]$list.Add($b)
                }
                if ($on) { [void]$list.Add(@{ Path = $path; Line = $line }) }
                $this._args.Breakpoints = [object[]]$list.ToArray()
            }
            return
        }
        if ($on) { $this._host.SetBreakpoint($path, $line) } else { $this._host.RemoveBreakpoint($path, $line) }
    }

    [void] Refresh() { if ($this.State -eq 'Stopped' -and $null -ne $this._host) { $this._host.Refresh() } }

    # diagnostics / tests (rev 5)
    [int] SentMessagePasses() { if ($null -eq $this._host) { return 0 }; return [int]$this._host.SentMessagePasses }
    [string] ThreadWindowClasses() { if ($null -eq $this._host) { return '' }; return [string]$this._host.ThreadWindowClasses }
    [int] ForeignStops() { if ($null -eq $this._host) { return 0 }; return [int]$this._host.ForeignStops }

    hidden [void] trace([string]$line) {
        if ($null -ne $this.OnTrace) { try { & $this.OnTrace $line } catch { } }
    }
}
#endregion

#region ---- SRxDebugger (engine: one session at a time + breakpoint store) -----
class SRxDebugger {
    [object]      $Session = $null
    [hashtable]   $Breakpoints = @{}
    [scriptblock] $OnTrace = $null
    [bool]        $HideAutomaticVariables = $true
    [bool]        $PumpSentMessages = $true     # rev 5
    [bool]        $WatchUiThread = $true        # rev 6: UI-thread hang watchdog (WinForms UI thread only)
    [bool]        $IsolateModules = $true       # rev 7: modules loaded from a private copy (own class types)
    [string]      $ModuleCacheRoot = ''         # rev 7: '' = %TEMP%\SRxDebugger\modules
    [string]      $WatchdogLog = ''             # rev 6: '' = %TEMP%\SRxDebugger-hang-<pid>.log
    [int]         $MaxValueLength = 400
    [int]         $ReadyTimeoutMs = 30000
    [string]      $Bootstrap = ''
    [string[]]    $KeyCommands = @('Initialize-SRxEnv', 'Start-Rule', 'Set-SRxPipeline')
    hidden [System.Collections.ArrayList] $_orphans = (New-Object System.Collections.ArrayList)

    [bool] IsActive() { return ($null -ne $this.Session -and $this.Session.IsActive()) }

    [object] StartSession([hashtable]$spec) {
        if ($null -eq $spec -or [string]::IsNullOrWhiteSpace([string]$spec['Path'])) { throw 'StartSession: Path is required.' }
        if ($this.IsActive()) { throw ("StartSession: '{0}' is being debugged - stop that session first." -f $this.Session.Title) }
        $s2 = @{}
        foreach ($k in $spec.Keys) { $s2[[string]$k] = $spec[$k] }
        if (-not $s2.ContainsKey('Modules') -or $null -eq $s2['Modules']) { $s2['Modules'] = $this.DefaultModules() }
        if ([string]::IsNullOrEmpty([string]$s2['Bootstrap'])) { $s2['Bootstrap'] = $this.Bootstrap }
        $this.ensureWatchdog()
        $s = [SRxDebugSession]::new($this, $s2)
        $s.OnTrace = $this.OnTrace
        $s.ReadyTimeoutMs = $this.ReadyTimeoutMs
        $this.Session = $s
        try { $s.Start() }
        catch { $this.Session = $null; throw }
        return $s
    }

    [object[]] Pump() {
        $s = $this.Session
        if ($null -eq $s) { $this.closeOrphans(); return @() }
        $ev = @($s.Poll())
        if ($s.State -eq 'Ended') {
            if ($null -ne $s.Orphan) { [void]$this._orphans.Add($s.Orphan); $s.Orphan = $null }
            $this.Session = $null
        }
        $this.closeOrphans()
        return [object[]]$ev
    }

    [void] Shutdown([int]$waitMs) {
        $s = $this.Session
        if ($null -eq $s) { return }
        $s.Stop()
        $sw = [System.Diagnostics.Stopwatch]::StartNew()
        while ($s.State -ne 'Ended' -and $sw.ElapsedMilliseconds -lt $waitMs) {
            [void]$s.Poll()
            Start-Sleep -Milliseconds 50
        }
        if ($s.State -ne 'Ended') { [void]$s.Poll() }
        $this.Session = $null
    }

    # rev 6: the watchdog needs the UI thread's WindowsForms context.
    hidden [void] ensureWatchdog() {
        if (-not $this.WatchUiThread) { return }
        $ctx = [System.Threading.SynchronizationContext]::Current
        if ($null -eq $ctx -or $ctx.GetType().Name -ne 'WindowsFormsSynchronizationContext') { return }
        try {
            # rev 6.1: no [SRxDebug7.*] type literal inside a class - class bodies are
            # resolved when the module is PARSED, before Add-Type has compiled the type.
            $wd = 'SRxDebug7.UiWatchdog' -as [type]
            if ($null -eq $wd) { $this.trace('UI watchdog type not loaded'); return }
            $wd::Start($this.WatchdogLog)
            $this.trace(("UI watchdog active - hang reports go to {0}" -f $wd::LogPath))
        }
        catch { $this.trace(("UI watchdog not started: {0}" -f $_.Exception.Message)) }
    }

    hidden [void] closeOrphans() {
        for ($i = $this._orphans.Count - 1; $i -ge 0; $i--) {
            $rs = $this._orphans[$i]
            $st = ''
            try { $st = [string]$rs.RunspaceStateInfo.State } catch { $st = 'Closed' }
            if ($st -eq 'Opening' -or $st -eq 'BeforeOpen') { continue }
            if ($st -eq 'Opened') { try { $rs.CloseAsync() } catch { } }
            $this._orphans.RemoveAt($i)
        }
    }

    [string[]] DefaultModules() {
        $list = New-Object System.Collections.Generic.List[string]
        foreach ($n in @($this.KeyCommands)) {
            $c = $null
            try { $c = Get-Command -Name $n -ErrorAction SilentlyContinue | Select-Object -First 1 } catch { $c = $null }
            if ($null -eq $c -or $null -eq $c.Module) { continue }
            $m = $c.Module
            $p = ''
            try {
                $psd = [System.IO.Path]::Combine([string]$m.ModuleBase, ([string]$m.Name + '.psd1'))
                if ([System.IO.File]::Exists($psd)) { $p = $psd } else { $p = [string]$m.Path }
            } catch { $p = [string]$m.Path }
            if (-not [string]::IsNullOrEmpty($p) -and -not $list.Contains($p)) { $list.Add($p) }
        }
        return $list.ToArray()
    }

    hidden [string] bpKey([string]$path) { return ([System.IO.Path]::GetFullPath($path)).ToLowerInvariant() }

    [int[]] GetBreakpoints([string]$path) {
        if ([string]::IsNullOrWhiteSpace($path)) { return @() }
        $e = $this.Breakpoints[$this.bpKey($path)]
        if ($null -eq $e) { return @() }
        return [int[]]@($e['Lines'] | Sort-Object)
    }

    [object[]] AllBreakpoints() {
        $list = New-Object System.Collections.ArrayList
        foreach ($e in @($this.Breakpoints.Values)) {
            foreach ($l in @($e['Lines'])) { [void]$list.Add(@{ Path = [string]$e['Path']; Line = [int]$l }) }
        }
        return [object[]]$list.ToArray()
    }

    [int[]] ToggleBreakpoint([string]$path, [int]$line) {
        if ([string]::IsNullOrWhiteSpace($path) -or $line -le 0) { return $this.GetBreakpoints($path) }
        $full = [System.IO.Path]::GetFullPath($path)
        $key = $full.ToLowerInvariant()
        $e = $this.Breakpoints[$key]
        if ($null -eq $e) { $e = @{ Path = $full; Lines = (New-Object System.Collections.Generic.List[int]) }; $this.Breakpoints[$key] = $e }
        $on = -not $e['Lines'].Contains($line)
        if ($on) { $e['Lines'].Add($line) } else { [void]$e['Lines'].Remove($line) }
        if ($e['Lines'].Count -eq 0) { $this.Breakpoints.Remove($key) }
        if ($this.IsActive()) { $this.Session.ApplyBreakpoint($full, $line, $on) }
        $this.trace(("breakpoint {0} {1}:{2}" -f $(if ($on) { 'set' } else { 'removed' }), $full, $line))
        return $this.GetBreakpoints($full)
    }

    [int[]] SetBreakpoints([string]$path, [int[]]$lines) {
        if ([string]::IsNullOrWhiteSpace($path)) { return @() }
        $full = [System.IO.Path]::GetFullPath($path)
        $old = @($this.GetBreakpoints($full))
        $new = @(@($lines) | Where-Object { [int]$_ -gt 0 } | ForEach-Object { [int]$_ } | Sort-Object -Unique)
        foreach ($l in $old) { if ($new -notcontains $l) { [void]$this.ToggleBreakpoint($full, [int]$l) } }
        foreach ($l in $new) { if ($old -notcontains $l) { [void]$this.ToggleBreakpoint($full, [int]$l) } }
        return $this.GetBreakpoints($full)
    }

    [void] ClearBreakpoints([string]$path) {
        foreach ($l in @($this.GetBreakpoints($path))) { [void]$this.ToggleBreakpoint($path, [int]$l) }
    }

    # rev 7: private copy of a module's folder, so the debug runspace compiles
    # its OWN class types. The copy is keyed by the folder path and the newest
    # file write time - reused while the module is unchanged. Returns the path
    # of the same file inside the copy.
    static [string] IsolatedModulePath([string]$modulePath, [string]$cacheRoot) {
        $full = [System.IO.Path]::GetFullPath($modulePath)
        if (-not [System.IO.File]::Exists($full)) { throw "module file not found: $full" }
        $base = [System.IO.Path]::GetDirectoryName($full)
        $files = @([System.IO.Directory]::GetFiles($base, '*', [System.IO.SearchOption]::AllDirectories))
        if ($files.Count -gt 5000) { throw ("module folder too large to copy ({0} files): {1}" -f $files.Count, $base) }
        $newest = [long]0
        foreach ($f in $files) { $t = [System.IO.File]::GetLastWriteTimeUtc($f).Ticks; if ($t -gt $newest) { $newest = $t } }
        $sha = [System.Security.Cryptography.SHA1]::Create()
        $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(($base.ToLowerInvariant() + '|' + [string]$newest + '|' + [string]$files.Count)))
        $sha.Dispose()
        $hash = [System.BitConverter]::ToString($bytes).Replace('-', '').Substring(0, 16).ToLowerInvariant()
        $root = $cacheRoot
        if ([string]::IsNullOrWhiteSpace($root)) { $root = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), 'SRxDebugger\modules') }
        $leafName = [System.IO.Path]::GetFileName($base)
        $target = [System.IO.Path]::Combine([System.IO.Path]::Combine($root, $hash), $leafName)
        $done = [System.IO.Path]::Combine($target, '.srxdbg-complete')
        if (-not [System.IO.File]::Exists($done)) {
            if ([System.IO.Directory]::Exists($target)) { [System.IO.Directory]::Delete($target, $true) }
            foreach ($f in $files) {
                $rel = $f.Substring($base.Length).TrimStart('\')
                $dst = [System.IO.Path]::Combine($target, $rel)
                [void][System.IO.Directory]::CreateDirectory([System.IO.Path]::GetDirectoryName($dst))
                [System.IO.File]::Copy($f, $dst, $true)
            }
            [System.IO.File]::WriteAllText($done, $full)
        }
        return [System.IO.Path]::Combine($target, $full.Substring($base.Length).TrimStart('\'))
    }

    static [int] FirstExecutableLine([string]$path) {
        $tokens = $null
        $errors = $null
        $ast = $null
        try { $ast = [System.Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors) } catch { $ast = $null }
        if ($null -eq $ast) { return 1 }
        foreach ($blk in @($ast.BeginBlock, $ast.ProcessBlock, $ast.EndBlock)) {
            if ($null -eq $blk) { continue }
            foreach ($st in @($blk.Statements)) {
                if ($null -eq $st) { continue }
                return [int]$st.Extent.StartLineNumber
            }
        }
        return 1
    }

    [string] NodeId([string]$kind, [string]$key) {
        $sha = [System.Security.Cryptography.SHA1]::Create()
        $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes(($kind + '|' + $key).ToLowerInvariant()))
        $sha.Dispose()
        $hex = [System.BitConverter]::ToString($bytes).Replace('-', '').Substring(0, 16).ToLowerInvariant()
        return ('srxdbg-{0}-{1}' -f $kind.ToLowerInvariant(), $hex)
    }

    hidden [void] trace([string]$line) {
        if ($null -ne $this.OnTrace) { try { & $this.OnTrace $line } catch { } }
    }
}
#endregion

function New-SRxDebugger {
    [CmdletBinding()]
    [OutputType([object])]
    param()
    return [SRxDebugger]::new()
}

function Get-SRxFirstExecutableLine {
    [CmdletBinding()]
    [OutputType([int])]
    param([Parameter(Mandatory)][string]$Path)
    return [SRxDebugger]::FirstExecutableLine($Path)
}

function Start-SRxUiWatchdog {
    <#
        .SYNOPSIS
        rev 6: start the UI-thread hang watchdog manually (call on the UI thread).
    #>

    [CmdletBinding()]
    param([string]$LogPath = '', [int]$HangMs = 3000)
    $wd = 'SRxDebug7.UiWatchdog' -as [type]
    $wd::HangMs = $HangMs
    $wd::Start($LogPath)
    return $wd::LogPath
}

Export-ModuleMember -Function New-SRxDebugger, Get-SRxFirstExecutableLine, Get-SRxDebugSetupScript, Get-SRxDebugTeardownScript, Start-SRxUiWatchdog