From bf463cd264cd13fc1762f7d5e76c8cbcedf4ff18 Mon Sep 17 00:00:00 2001 From: Radu Hociung Date: Tue, 13 Oct 2026 01:49:06 +0000 Subject: [PATCH] Keep the debug session alive when one inferior exits --- .../CommandFactories/MICommandFactory.cs | 17 ++ src/MICore/CommandFactories/gdb.cs | 20 ++ src/MICore/Debugger.cs | 129 +++++++++ src/MICoreUnitTests/MultiInferiorExitTests.cs | 254 ++++++++++++++++++ .../Engine.Impl/DebuggedProcess.cs | 29 ++ 5 files changed, 449 insertions(+) create mode 100644 src/MICoreUnitTests/MultiInferiorExitTests.cs diff --git a/src/MICore/CommandFactories/MICommandFactory.cs b/src/MICore/CommandFactories/MICommandFactory.cs index 54923e820..b63e2cd06 100644 --- a/src/MICore/CommandFactories/MICommandFactory.cs +++ b/src/MICore/CommandFactories/MICommandFactory.cs @@ -111,6 +111,23 @@ public virtual void DefineCurrentThread(int threadId) public virtual int CurrentThread { get; } + /// + /// True if the debugger can debug more than one process ('inferior' in gdb terms) in a single + /// session, and can be asked to switch between them. + /// + public virtual bool SupportsMultipleInferiors => false; + + /// + /// Makes 'threadId' the debugger's current thread. Commands issued by the engine normally name the + /// thread they apply to, so this is only needed to repair the debugger's notion of the current + /// thread after it has been left pointing at a thread which no longer exists. + /// + /// True if the current thread was changed, false if this debugger does not support it. + public virtual Task SelectThread(int threadId) + { + return Task.FromResult(false); + } + public virtual async Task ThreadInfo(uint? threadid = null) { string command = "-thread-info"; diff --git a/src/MICore/CommandFactories/gdb.cs b/src/MICore/CommandFactories/gdb.cs index a268df1c2..b35a35687 100644 --- a/src/MICore/CommandFactories/gdb.cs +++ b/src/MICore/CommandFactories/gdb.cs @@ -47,6 +47,26 @@ public override bool SupportsChildProcessDebugging() return true; } + public override bool SupportsMultipleInferiors => true; + + public override async Task SelectThread(int threadId) + { + ExclusiveLockToken lockToken = await _debugger.CommandLock.AquireExclusive(); + try + { + // Force '-thread-select' to be sent even if 'threadId' is the thread we believe is already + // current: this is called precisely when gdb's current thread has changed underneath us. + _currentThreadId = 0; + await ThreadSelect(threadId, lockToken); + } + finally + { + lockToken.Close(); + } + + return true; + } + public override bool AllowCommandsWhileRunning() { return false; diff --git a/src/MICore/Debugger.cs b/src/MICore/Debugger.cs index e77fd8e76..fde858938 100755 --- a/src/MICore/Debugger.cs +++ b/src/MICore/Debugger.cs @@ -43,6 +43,8 @@ public class Debugger : ITransportCallback public event EventHandler? ThreadCreatedEvent; public event EventHandler? ThreadExitedEvent; public event EventHandler? ThreadGroupExitedEvent; + // An inferior (a debuggee process) exited, but other inferiors are still being debugged + public event EventHandler? InferiorExitedEvent; public event EventHandler? TelemetryEvent; private int _exiting; public ProcessState ProcessState { get; private set; } @@ -110,6 +112,21 @@ public ResultEventArgs(Results results) public uint Id { get; private set; } }; + public class InferiorExitedEventArgs : ResultEventArgs + { + /// + /// Id of a thread which is still alive after the inferior exited. The debugger's current + /// thread has already been set to this thread. + /// + public readonly int SurvivingThreadId; + + public InferiorExitedEventArgs(Results results, int survivingThreadId) + : base(results) + { + SurvivingThreadId = survivingThreadId; + } + } + public class StoppingEventArgs : ResultEventArgs { public readonly BreakRequest AsyncRequest; @@ -254,12 +271,124 @@ public Task AddInternalBreakAction(Func func) } } + /// + /// Handles the '*stopped,reason="exited*"' record which gdb sends when an inferior exits. gdb sends this + /// record for *any* inferior, and it contains nothing which identifies the inferior that exited, so on + /// its own it cannot be distinguished from the end of the debug session. When more than one inferior is + /// being debugged (e.g. 'set detach-on-fork off' plus 'set schedule-multiple on') the session has to + /// survive the exit of an inferior which isn't the last one. + /// + /// gdb sends '=thread-group-exited' for the inferior which is going away before this record, and + /// HandleThreadGroupExited removes that thread group from '_debuggeePids'. So a non-empty '_debuggeePids' + /// here means other inferiors are still being debugged. Once the last thread group goes away, + /// HandleThreadGroupExited schedules its own '*stopped,reason="exited"' record, and that is what ends the + /// debug session. + /// + /// True if the debug session should continue, false if this record ends the session. + private async Task TryHandleInferiorExit(Results results) + { + if (this.ProcessState == ProcessState.Exited || _terminating || _exiting != 0) + { + return false; + } + + lock (_debuggeePids) + { + if (_debuggeePids.Count == 0) + { + // The single-inferior case: the only thread group is already gone, so the debuggee has exited. + return false; + } + } + + // Only gdb can debug more than one inferior in a single session. + if (!MICommandFactory.SupportsMultipleInferiors) + { + return false; + } + + // gdb stops every inferior when one of them exits (all-stop mode), so record that we are stopped + // before sending any commands. + this.ProcessState = ProcessState.Stopped; + FlushBreakStateData(); + + // gdb leaves the inferior which exited as the current one, so commands which don't name a thread -- + // notably the plain '-exec-continue' sent by CmdContinueAsync -- would fail with + // "The program is not being run.". Point the debugger at a thread which is still alive. + int survivingThreadId; + try + { + Results threadsInfo = await MICommandFactory.ThreadInfo(); + if (threadsInfo.ResultClass != ResultClass.done || + !TryFindFirstThreadId(threadsInfo, out survivingThreadId) || + !await MICommandFactory.SelectThread(survivingThreadId)) + { + return false; + } + } + catch (MIException) + { + return false; + } + catch (ObjectDisposedException) + { + return false; + } + + MICommandFactory.DefineCurrentThread(survivingThreadId); + + // Run anything which was queued up while the debuggee was running -- those actions need a stopped + // target. Passing false for fIsAsyncBreak keeps DoInternalBreakActions from resuming the debuggee, + // since this stop wasn't caused by an internal async break. + if (await DoInternalBreakActions(false)) + { + return true; + } + + InferiorExitedEvent?.Invoke(this, new InferiorExitedEventArgs(results, survivingThreadId)); + + return true; + } + + /// + /// Finds the id of the first thread in the result of '-thread-info'. Note that '-thread-info' does not + /// report a 'current-thread-id' when the current thread has gone away, which is the case after an + /// inferior exits. + /// + private static bool TryFindFirstThreadId(Results threadsInfo, out int threadId) + { + threadId = 0; + + ValueListValue? threads = threadsInfo.TryFind("threads"); + if (threads == null) + { + return false; + } + + foreach (ResultValue thread in threads.Content) + { + if (thread.Contains("id")) + { + threadId = thread.FindInt("id"); + return true; + } + } + + return false; + } + private async void OnStopped(Results results) { string reason = results.TryFindString("reason"); if (reason.StartsWith("exited", StringComparison.Ordinal) || reason.StartsWith("disconnected", StringComparison.Ordinal)) { + if (await TryHandleInferiorExit(results)) + { + // One of several inferiors exited. The debug session continues with the others. + return; + } + if (this.ProcessState != ProcessState.Exited) { this.ProcessState = ProcessState.Exited; diff --git a/src/MICoreUnitTests/MultiInferiorExitTests.cs b/src/MICoreUnitTests/MultiInferiorExitTests.cs new file mode 100644 index 000000000..dedf1d297 --- /dev/null +++ b/src/MICoreUnitTests/MultiInferiorExitTests.cs @@ -0,0 +1,254 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using MICore; +using Microsoft.DebugEngineHost; +using Xunit; + +namespace MICoreUnitTests +{ + /// + /// Tests for how the engine reacts to '*stopped,reason="exited-normally"'. gdb sends this record whenever + /// *any* inferior exits and the record does not say which one, so the record must only end the debug + /// session when the inferior which exited was the last one. + /// See https://github.com/microsoft/vscode-cpptools/issues/13893. + /// + public class MultiInferiorExitTests + { + [Fact] + public void TestSingleInferiorExitEndsSession() + { + using (TestHarness harness = new TestHarness()) + { + harness.Send("=thread-group-added,id=\"i1\""); + harness.Send("=thread-group-started,id=\"i1\",pid=\"1000\""); + harness.Send("=thread-created,id=\"1\",group-id=\"i1\""); + + // gdb sends '=thread-group-exited' before the '*stopped' record, so by the time the '*stopped' + // record is processed there are no thread groups left. + harness.Send("=thread-exited,id=\"1\",group-id=\"i1\""); + harness.Send("=thread-group-exited,id=\"i1\",exit-code=\"0\""); + harness.Send("*stopped,reason=\"exited-normally\""); + + Assert.True(harness.WaitForProcessExit(), "The debug session should end when the only inferior exits."); + Assert.Equal(0, harness.InferiorExitedCount); + Assert.Equal(ProcessState.Exited, harness.Debugger.ProcessState); + } + } + + [Fact] + public void TestNonFinalInferiorExitKeepsSessionAlive() + { + using (TestHarness harness = new TestHarness()) + { + harness.Send("=thread-group-added,id=\"i1\""); + harness.Send("=thread-group-started,id=\"i1\",pid=\"1000\""); + harness.Send("=thread-created,id=\"1\",group-id=\"i1\""); + + // The debuggee forked with 'set detach-on-fork off', so gdb added a second inferior. + harness.Send("=thread-group-added,id=\"i2\""); + harness.Send("=thread-group-started,id=\"i2\",pid=\"1001\""); + harness.Send("=thread-created,id=\"2\",group-id=\"i2\""); + + // The child exits. Thread group 'i1' is still alive. + harness.Send("=thread-exited,id=\"2\",group-id=\"i2\""); + harness.Send("=thread-group-exited,id=\"i2\",exit-code=\"0\""); + harness.Send("*stopped,reason=\"exited-normally\""); + + Assert.True(harness.WaitForInferiorExited(), "The exit of one of two inferiors should be reported as an inferior exit."); + Assert.Equal(0, harness.ProcessExitCount); + Assert.Equal(ProcessState.Stopped, harness.Debugger.ProcessState); + Assert.Equal(1, harness.SurvivingThreadId); + + // gdb leaves the exited inferior current, so the engine must select a live thread to make + // subsequent commands (e.g. a plain '-exec-continue') work. + Assert.Contains("-thread-select 1", harness.CommandsSent); + } + } + + [Fact] + public void TestLastInferiorExitEndsSession() + { + using (TestHarness harness = new TestHarness()) + { + harness.Send("=thread-group-added,id=\"i1\""); + harness.Send("=thread-group-started,id=\"i1\",pid=\"1000\""); + harness.Send("=thread-group-added,id=\"i2\""); + harness.Send("=thread-group-started,id=\"i2\",pid=\"1001\""); + + harness.Send("=thread-group-exited,id=\"i2\",exit-code=\"0\""); + harness.Send("*stopped,reason=\"exited-normally\""); + Assert.True(harness.WaitForInferiorExited(), "The first of two inferiors to exit should not end the session."); + Assert.Equal(0, harness.ProcessExitCount); + + // Now the remaining inferior exits. HandleThreadGroupExited schedules the synthetic + // '*stopped,reason="exited"' record which ends the session. + harness.Send("=thread-group-exited,id=\"i1\",exit-code=\"0\""); + + Assert.True(harness.WaitForProcessExit(), "The debug session should end when the last inferior exits."); + } + } + + private sealed class TestHarness : IDisposable + { + private const int TimeoutMs = 10000; + + private readonly ScriptedTransport _transport; + + public MICore.Debugger Debugger { get; } + public int ProcessExitCount { get; private set; } + public int InferiorExitedCount { get; private set; } + public int SurvivingThreadId { get; private set; } + public IReadOnlyList CommandsSent => _transport.Commands; + + public TestHarness() + { + LaunchOptions launchOptions = LaunchOptions.GetInstance( + null, + "bogus-exe-path", + null, + null, + GetLaunchOptionsXml(), + false, + null, + TargetEngine.Native, + null); + + this.Debugger = new MICore.Debugger(launchOptions, Logger.EnsureInitialized()); + this.Debugger.ProcessExitEvent += (o, args) => this.ProcessExitCount++; + this.Debugger.InferiorExitedEvent += (o, args) => + { + this.SurvivingThreadId = ((MICore.Debugger.InferiorExitedEventArgs)args).SurvivingThreadId; + this.InferiorExitedCount++; + }; + + _transport = new ScriptedTransport(); + this.Debugger.Init(_transport, launchOptions); + } + + public void Send(string line) + { + ((ITransportCallback)this.Debugger).OnStdOutLine(line); + } + + public bool WaitForProcessExit() + { + return Wait(() => this.ProcessExitCount > 0); + } + + public bool WaitForInferiorExited() + { + return Wait(() => this.InferiorExitedCount > 0); + } + + // The MI records are processed by 'async void' methods, so the effects may not be visible by the + // time Send returns. + private static bool Wait(Func condition) + { + System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); + while (stopwatch.ElapsedMilliseconds < TimeoutMs) + { + if (condition()) + { + return true; + } + + Task.Delay(10).Wait(); + } + + return condition(); + } + + private static string GetLaunchOptionsXml() + { + string fakeFilePath = typeof(MultiInferiorExitTests).Assembly.Location; + return string.Concat(""); + } + + public void Dispose() + { + _transport.Close(); + } + } + + /// + /// A transport which answers the handful of commands this test's code path sends, instead of replaying + /// a captured log the way MockTransport does. + /// + private sealed class ScriptedTransport : ITransport + { + private ITransportCallback? _callback; + private readonly List _commands = new List(); + + /// + /// Commands sent by the engine, with the MI token stripped off. + /// + public IReadOnlyList Commands => _commands; + + public bool IsClosed { get; private set; } + + public int DebuggerPid => 0; + + public void Init(ITransportCallback transportCallback, LaunchOptions options, Logger logger, HostWaitLoop? waitLoop = null) + { + _callback = transportCallback; + } + + public void Send(string cmd) + { + if (string.IsNullOrEmpty(cmd)) + { + return; + } + + int tokenLength = 0; + while (tokenLength < cmd.Length && char.IsDigit(cmd[tokenLength])) + { + tokenLength++; + } + + string token = cmd.Substring(0, tokenLength); + string command = cmd.Substring(tokenLength); + _commands.Add(command); + + string response; + if (command.StartsWith("-thread-info", StringComparison.Ordinal)) + { + // Note that gdb reports no 'current-thread-id' when the current thread has exited. + response = "^done,threads=[{id=\"1\",target-id=\"Thread 1\",state=\"stopped\"}]"; + } + else + { + response = "^done"; + } + + _callback?.OnStdOutLine(string.Concat(token, response)); + } + + public void Close() + { + this.IsClosed = true; + } + + public int ExecuteSyncCommand(string commandDescription, string commandText, int timeout, out string output, out string error) + { + throw new NotImplementedException(); + } + + public bool CanExecuteCommand() + { + return false; + } + } + } +} diff --git a/src/MIDebugEngine/Engine.Impl/DebuggedProcess.cs b/src/MIDebugEngine/Engine.Impl/DebuggedProcess.cs index 27edfad98..0462334ba 100755 --- a/src/MIDebugEngine/Engine.Impl/DebuggedProcess.cs +++ b/src/MIDebugEngine/Engine.Impl/DebuggedProcess.cs @@ -433,6 +433,35 @@ public DebuggedProcess(bool bLaunched, LaunchOptions launchOptions, ISampleEngin ThreadCache.ThreadGroupExitedEvent(result.Results.FindString("id")); }; + // One of several inferiors exited, so the debug session continues with the remaining ones. gdb has + // stopped everything, so report a break on a thread which is still alive, the same way the pause + // button does. Without this the UI would show the debuggee as running while gdb is stopped. + InferiorExitedEvent += async delegate (object o, EventArgs args) + { + // NOTE: This is an async void method, so make sure exceptions are caught and somehow reported + MICore.Debugger.InferiorExitedEventArgs inferiorExited = (MICore.Debugger.InferiorExitedEventArgs)args; + + try + { + ThreadCache.MarkDirty(); + MICommandFactory.DefineCurrentThread(inferiorExited.SurvivingThreadId); + + DebuggedThread thread = await ThreadCache.GetThread(inferiorExited.SurvivingThreadId); + if (thread == null) + { + return; + } + + await ThreadCache.StackFrames(thread); // prepopulate the break thread in the thread cache + ThreadCache.SendThreadEvents(this, null); // push the exited inferior's thread removals to the UI + + _callback.OnAsyncBreakComplete(thread); + } + catch (Exception e) when (ExceptionHelper.BeforeCatch(e, Logger, reportOnlyCorrupting: true)) + { + } + }; + TelemetryEvent += (object o, ResultEventArgs args) => { string eventName;