diff --git a/Python/Product/Debugger.Concord/Debugger.Concord.csproj b/Python/Product/Debugger.Concord/Debugger.Concord.csproj index a2c84b3762..04e9c9dbf3 100644 --- a/Python/Product/Debugger.Concord/Debugger.Concord.csproj +++ b/Python/Product/Debugger.Concord/Debugger.Concord.csproj @@ -126,6 +126,8 @@ + + diff --git a/Python/Product/Debugger.Concord/DkmExtensions.cs b/Python/Product/Debugger.Concord/DkmExtensions.cs index 0706c62fc6..177377bb15 100644 --- a/Python/Product/Debugger.Concord/DkmExtensions.cs +++ b/Python/Product/Debugger.Concord/DkmExtensions.cs @@ -162,6 +162,19 @@ public static ulong GetExportedStaticVariableAddress(this DkmNativeModuleInstanc return moduleInstance.BaseAddress + addr.RVA; } + /// + /// Like , but returns 0 instead of throwing + /// when the export is not present. Useful for optional symbols (e.g. _PyRuntime) + /// that we want to probe for without a PDB and without asserting when absent. + /// + public static ulong TryGetExportedStaticVariableAddress(this DkmNativeModuleInstance moduleInstance, string name) { + var addr = moduleInstance.FindExportName(name, false); + if (addr == null) { + return 0; + } + return moduleInstance.BaseAddress + addr.RVA; + } + public static TProxy GetExportedStaticVariable(this DkmNativeModuleInstance moduleInstance, string name) where TProxy : IDataProxy { ulong address = GetExportedStaticVariableAddress(moduleInstance, name); diff --git a/Python/Product/Debugger.Concord/ExpressionEvaluator.cs b/Python/Product/Debugger.Concord/ExpressionEvaluator.cs index 2cbc2cc042..8748663e2c 100644 --- a/Python/Product/Debugger.Concord/ExpressionEvaluator.cs +++ b/Python/Product/Debugger.Concord/ExpressionEvaluator.cs @@ -377,6 +377,17 @@ public void GetFrameLocals(DkmInspectionContext inspectionContext, DkmWorkList w var f_code = pythonFrame.f_code.Read(); var f_localsplus = pythonFrame.f_localsplus; + // In CPython 3.14, the frame's localsplus slots (locals, cells, and free vars) are + // _PyStackRef values rather than plain PyObject*. Their low bits carry a deferred/immortal + // reference tag (Py_TAG_BITS), so strip them to recover the real object pointer; this + // mirrors CPython's own PyStackRef_AsPyObjectBorrow. Without this, any local holding a + // deferred object (None, booleans, small ints, interned strings, ...) reads at a + // misaligned address and throws, which aborts the entire locals enumeration. Object + // pointers are 8-byte aligned, so the mask is a no-op for untagged slots; it is 0 for + // versions before 3.14, leaving their behavior byte-for-byte identical. + ulong localsPlusTagMask = + pythonFrame.Process.GetPythonRuntimeInfo().LanguageVersion >= PythonLanguageVersion.V314 ? 0x3ul : 0ul; + // Process cellvars and freevars first, because function arguments can appear in both cellvars and varnames if the argument is captured by a closure, // in which case we want to use the cellvar because the regular var slot will then be unused by Python (and in Python 3.4+, nulled out). var namesSeen = new HashSet(); @@ -384,7 +395,7 @@ public void GetFrameLocals(DkmInspectionContext inspectionContext, DkmWorkList w var cellSlots = f_localsplus.Skip(f_code.co_nlocals.Read()); foreach (var pair in cellNames.Zip(cellSlots, (nameObj, cellSlot) => new { nameObj, cellSlot = cellSlot })) { var nameObj = pair.nameObj; - var cellSlot = pair.cellSlot; + var cellSlot = pair.cellSlot.WithTagMask(localsPlusTagMask); var name = (nameObj.Read() as IPyBaseStringObject).ToStringOrNull(); if (name == null) { @@ -392,28 +403,36 @@ public void GetFrameLocals(DkmInspectionContext inspectionContext, DkmWorkList w } namesSeen.Add(name); - if (cellSlot.IsNull) { - continue; - } + try { + if (cellSlot.IsNull) { + continue; + } - var cell = cellSlot.Read() as PyCellObject; - if (cell == null) { - continue; - } + var cell = cellSlot.Read() as PyCellObject; + if (cell == null) { + continue; + } - var localPtr = cell.ob_ref; - if (localPtr.IsNull) { - continue; - } + var localPtr = cell.ob_ref; + if (localPtr.IsNull) { + continue; + } - var evalResult = CreatePyObjectEvaluationResult(inspectionContext, stackFrame, null, new PythonEvaluationResult(localPtr, name), cppEval); - evalResults.Add(evalResult); + var evalResult = CreatePyObjectEvaluationResult(inspectionContext, stackFrame, null, new PythonEvaluationResult(localPtr, name), cppEval); + evalResults.Add(evalResult); + } catch (Exception ex) { + // A single unreadable local must never blank out the entire Locals window. + // Surface it as a failed entry (which also aids diagnosis) and keep going. + evalResults.Add(DkmFailedEvaluationResult.Create( + inspectionContext, stackFrame, name, name, + ex.Message, DkmEvaluationResultFlags.Invalid, null)); + } } PyTupleObject co_varnames = f_code.co_varnames.Read(); foreach (var pair in co_varnames.ReadElements().Zip(f_localsplus, (nameObj, varSlot) => new { nameObj, cellSlot = varSlot })) { var nameObj = pair.nameObj; - var varSlot = pair.cellSlot; + var varSlot = pair.cellSlot.WithTagMask(localsPlusTagMask); var name = (nameObj.Read() as IPyBaseStringObject).ToStringOrNull(); if (name == null) { @@ -425,12 +444,19 @@ public void GetFrameLocals(DkmInspectionContext inspectionContext, DkmWorkList w continue; } - if (varSlot.IsNull) { - continue; - } + try { + if (varSlot.IsNull) { + continue; + } - var evalResult = CreatePyObjectEvaluationResult(inspectionContext, stackFrame, null, new PythonEvaluationResult(varSlot, name), cppEval); - evalResults.Add(evalResult); + var evalResult = CreatePyObjectEvaluationResult(inspectionContext, stackFrame, null, new PythonEvaluationResult(varSlot, name), cppEval); + evalResults.Add(evalResult); + } catch (Exception ex) { + // A single unreadable local must never blank out the entire Locals window. + evalResults.Add(DkmFailedEvaluationResult.Create( + inspectionContext, stackFrame, name, name, + ex.Message, DkmEvaluationResultFlags.Invalid, null)); + } } var globals = pythonFrame.f_globals.TryRead(); diff --git a/Python/Product/Debugger.Concord/Proxies/PointerProxy.cs b/Python/Product/Debugger.Concord/Proxies/PointerProxy.cs index 86f72b2c04..d7e69c0861 100644 --- a/Python/Product/Debugger.Concord/Proxies/PointerProxy.cs +++ b/Python/Product/Debugger.Concord/Proxies/PointerProxy.cs @@ -73,16 +73,36 @@ internal struct PointerProxy : IWritableDataProxy private readonly bool _polymorphic; + // Low bits to strip from the stored pointer value before dereferencing. Used for CPython + // 3.14 _PyStackRef fields (e.g. _PyInterpreterFrame.f_executable), whose low bits carry a + // tag (Py_TAG_BITS). Zero (the default) leaves the pointer untouched, so every existing + // construction is unaffected. Masking aligned pointers is a no-op, so it is always safe. + private readonly ulong _tagMask; + public PointerProxy(DkmProcess process, ulong address) : this(process, address, true) { } public PointerProxy(DkmProcess process, ulong address, bool polymorphic) + : this(process, address, polymorphic, 0) { + } + + private PointerProxy(DkmProcess process, ulong address, bool polymorphic, ulong tagMask) : this() { Debug.Assert(process != null && address != 0); Process = process; Address = address; _polymorphic = polymorphic; + _tagMask = tagMask; + } + + /// + /// Returns a copy of this pointer that strips from the stored + /// value before dereferencing. Used to read CPython 3.14 _PyStackRef fields, whose + /// low bits are a tag rather than part of the object address. + /// + public PointerProxy WithTagMask(ulong tagMask) { + return new PointerProxy(Process, Address, _polymorphic, tagMask); } public long ObjectSize { @@ -90,7 +110,7 @@ public long ObjectSize { } public bool IsNull { - get { return Raw.IsNull; } + get { return ReadTarget() == 0; } } /// @@ -100,13 +120,18 @@ public PointerProxy Raw { get { return new PointerProxy(Process, Address); } } + // The pointer value stored at Address, with any tag bits stripped. + private ulong ReadTarget() { + return Raw.Read() & ~_tagMask; + } + public TProxy Read() { - if (IsNull) { + var ptr = ReadTarget(); + if (ptr == 0) { Debug.Fail("Trying to dereference a null PointerProxy."); throw new InvalidOperationException(); } - var ptr = Raw.Read(); return DataProxy.Create(Process, ptr, _polymorphic); } @@ -114,11 +139,11 @@ public TProxy Read() { /// Like , but returns default() if pointer is null. /// public TProxy TryRead() { - if (IsNull) { + var ptr = ReadTarget(); + if (ptr == 0) { return default(TProxy); } - var ptr = Raw.Read(); return DataProxy.Create(Process, ptr, _polymorphic); } diff --git a/Python/Product/Debugger.Concord/Proxies/StructProxy.cs b/Python/Product/Debugger.Concord/Proxies/StructProxy.cs index 673c5e9ca2..aba5b79fa7 100644 --- a/Python/Product/Debugger.Concord/Proxies/StructProxy.cs +++ b/Python/Product/Debugger.Concord/Proxies/StructProxy.cs @@ -195,7 +195,9 @@ private static TFields GetStructFields(StructMetadata metadata) return (TFields)metadata.Fields; } - var pyVersion = metadata.Process.GetPythonRuntimeInfo().LanguageVersion; + var pyrtInfo = metadata.Process.GetPythonRuntimeInfo(); + var pyVersion = pyrtInfo.LanguageVersion; + var offsetProvider = pyrtInfo.StructFieldOffsetProvider; var fields = new TFields(); foreach (var fieldInfo in typeof(TFields).GetFields()) { @@ -208,7 +210,15 @@ private static TFields GetStructFields(StructMetadata metadata) continue; } - long offset = metadata.Symbol.GetFieldOffset(name); + // Prefer an authoritative offset from the interpreter's self-describing table + // (CPython 3.14 _Py_DebugOffsets, hot-path fields only) when one is available; + // otherwise fall back to the PDB. The provider is null for every other version, + // so this path is byte-for-byte identical to before there. + long offset; + if (offsetProvider == null || !offsetProvider.TryGetFieldOffset(metadata.Name, name, out offset)) { + offset = metadata.Symbol.GetFieldOffset(name); + } + var field = (IStructField)Activator.CreateInstance(fieldType); field.Process = metadata.Process; field.Offset = offset; diff --git a/Python/Product/Debugger.Concord/Proxies/Structs/DebugOffsetsFieldProvider.cs b/Python/Product/Debugger.Concord/Proxies/Structs/DebugOffsetsFieldProvider.cs new file mode 100644 index 0000000000..165b2b84ac --- /dev/null +++ b/Python/Product/Debugger.Concord/Proxies/Structs/DebugOffsetsFieldProvider.cs @@ -0,0 +1,97 @@ +// Python Tools for Visual Studio +// Copyright(c) Microsoft Corporation +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the License); you may not use +// this file except in compliance with the License. You may obtain a copy of the +// License at http://www.apache.org/licenses/LICENSE-2.0 +// +// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY +// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +// MERCHANTABILITY OR NON-INFRINGEMENT. +// +// See the Apache Version 2.0 License for specific language governing +// permissions and limitations under the License. + +using System; +using System.Collections.Generic; + +namespace Microsoft.PythonTools.Debugger.Concord.Proxies.Structs { + /// + /// Supplies the byte offset of a struct field by some means other than the interpreter's PDB. + /// consults this before falling back to DIA/PDB symbol lookup, letting + /// us source authoritative offsets from CPython's self-describing tables when available. + /// + internal interface IStructFieldOffsetProvider { + /// + /// Returns true and sets if this provider knows the offset of + /// within ; otherwise returns false + /// so the caller falls back to its default (PDB) resolution. + /// + bool TryGetFieldOffset(string structName, string fieldName, out long offset); + } + + /// + /// Resolves a curated set of hot-path struct fields from CPython 3.14's _Py_DebugOffsets + /// table (see ) instead of the PDB. Coverage is intentionally limited + /// to the frame / code-object / thread-state fields that the mixed-mode stack walk and in-process + /// line-number computation depend on; every other field still resolves via the PDB. This is what + /// automatically tracks the free-threaded build's shifted object layouts. + /// + /// The map keys are CPython struct/field names (matching 's field names + /// and each proxy's StructName); the values are the corresponding + /// (group, field) entries. + /// + internal sealed class DebugOffsetsFieldProvider : IStructFieldOffsetProvider { + private static readonly Dictionary> Map = + new Dictionary>(StringComparer.Ordinal) { + // _PyInterpreterFrame (StructName "_PyInterpreterFrame"). + { "_PyInterpreterFrame.previous", Entry("interpreter_frame", "previous") }, + { "_PyInterpreterFrame.f_executable", Entry("interpreter_frame", "executable") }, + { "_PyInterpreterFrame.instr_ptr", Entry("interpreter_frame", "instr_ptr") }, + { "_PyInterpreterFrame.localsplus", Entry("interpreter_frame", "localsplus") }, + { "_PyInterpreterFrame.owner", Entry("interpreter_frame", "owner") }, + + // PyCodeObject (StructName "PyCodeObject"). + { "PyCodeObject.co_filename", Entry("code_object", "filename") }, + { "PyCodeObject.co_name", Entry("code_object", "name") }, + { "PyCodeObject.co_firstlineno", Entry("code_object", "firstlineno") }, + { "PyCodeObject.co_localsplusnames", Entry("code_object", "localsplusnames") }, + { "PyCodeObject.co_localspluskinds", Entry("code_object", "localspluskinds") }, + { "PyCodeObject.co_code_adaptive", Entry("code_object", "co_code_adaptive") }, + { "PyCodeObject.co_linetable", Entry("code_object", "linetable") }, + + // PyThreadState (StructName "_ts"). + { "_ts.next", Entry("thread_state", "next") }, + { "_ts.interp", Entry("thread_state", "interp") }, + { "_ts.thread_id", Entry("thread_state", "thread_id") }, + { "_ts.current_frame", Entry("thread_state", "current_frame") }, + }; + + private readonly PyDebugOffsets _offsets; + + public DebugOffsetsFieldProvider(PyDebugOffsets offsets) { + _offsets = offsets ?? throw new ArgumentNullException(nameof(offsets)); + } + + public bool TryGetFieldOffset(string structName, string fieldName, out long offset) { + offset = 0; + if (structName == null || fieldName == null) { + return false; + } + + KeyValuePair entry; + if (!Map.TryGetValue(structName + "." + fieldName, out entry)) { + return false; + } + + offset = (long)_offsets.Offset(entry.Key, entry.Value); + return true; + } + + private static KeyValuePair Entry(string group, string field) { + return new KeyValuePair(group, field); + } + } +} diff --git a/Python/Product/Debugger.Concord/Proxies/Structs/PyDebugOffsets.cs b/Python/Product/Debugger.Concord/Proxies/Structs/PyDebugOffsets.cs new file mode 100644 index 0000000000..9e0ea77c6a --- /dev/null +++ b/Python/Product/Debugger.Concord/Proxies/Structs/PyDebugOffsets.cs @@ -0,0 +1,262 @@ +// Python Tools for Visual Studio +// Copyright(c) Microsoft Corporation +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the License); you may not use +// this file except in compliance with the License. You may obtain a copy of the +// License at http://www.apache.org/licenses/LICENSE-2.0 +// +// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY +// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +// MERCHANTABILITY OR NON-INFRINGEMENT. +// +// See the Apache Version 2.0 License for specific language governing +// permissions and limitations under the License. + +using System; +using Microsoft.VisualStudio.Debugger; +using Microsoft.VisualStudio.Debugger.Native; + +namespace Microsoft.PythonTools.Debugger.Concord.Proxies.Structs { + /// + /// Managed reader for CPython's self-describing _Py_DebugOffsets table + /// (CPython 3.14, Include/internal/pycore_debug_offsets.h). The table is placed + /// at offset 0 of the exported _PyRuntime global so out-of-process debuggers can + /// discover the byte offset of interesting fields (frames, code objects, thread state, + /// builtin object layouts, ...) without relying on the interpreter's PDB. + /// + /// The table opens with an 8-byte "xdebugpy" cookie, a PY_VERSION_HEX + /// version word and a free_threaded flag, followed by a flat run of little-endian + /// uint64_t offsets grouped by struct. This class only knows the 3.14 layout; the + /// header explicitly warns the layout is not stable across minor versions, so callers must + /// gate on before trusting anything but the cookie/version prefix. + /// + internal sealed class PyDebugOffsets { + public const string CookieString = "xdebugpy"; + + // Header: char cookie[8]; uint64 version; uint64 free_threaded. + private const int CookieSize = 8; + private const int HeaderSize = CookieSize + sizeof(ulong) + sizeof(ulong); + + // The 3.14 layout, mirroring pycore_debug_offsets.h field-for-field (every entry is + // a uint64 offset). Keeping the groups/fields in exact source order is what makes the + // byte positions correct by construction; do not reorder without matching the header. + private static readonly string[] RuntimeStateFields = { + "size", "finalizing", "interpreters_head", + }; + private static readonly string[] InterpreterStateFields = { + "size", "id", "next", "threads_head", "threads_main", "gc", + "imports_modules", "sysdict", "builtins", "ceval_gil", + "gil_runtime_state", "gil_runtime_state_enabled", + "gil_runtime_state_locked", "gil_runtime_state_holder", + "code_object_generation", "tlbc_generation", + }; + private static readonly string[] ThreadStateFields = { + "size", "prev", "next", "interp", "current_frame", "thread_id", + "native_thread_id", "datastack_chunk", "status", + }; + private static readonly string[] InterpreterFrameFields = { + "size", "previous", "executable", "instr_ptr", "localsplus", "owner", + "stackpointer", "tlbc_index", + }; + private static readonly string[] CodeObjectFields = { + "size", "filename", "name", "qualname", "linetable", "firstlineno", + "argcount", "localsplusnames", "localspluskinds", "co_code_adaptive", + "co_tlbc", + }; + private static readonly string[] PyObjectFields = { + "size", "ob_type", + }; + private static readonly string[] TypeObjectFields = { + "size", "tp_name", "tp_repr", "tp_flags", + }; + private static readonly string[] TupleObjectFields = { + "size", "ob_item", "ob_size", + }; + private static readonly string[] ListObjectFields = { + "size", "ob_item", "ob_size", + }; + private static readonly string[] SetObjectFields = { + "size", "used", "table", "mask", + }; + private static readonly string[] DictObjectFields = { + "size", "ma_keys", "ma_values", + }; + private static readonly string[] FloatObjectFields = { + "size", "ob_fval", + }; + private static readonly string[] LongObjectFields = { + "size", "lv_tag", "ob_digit", + }; + private static readonly string[] BytesObjectFields = { + "size", "ob_size", "ob_sval", + }; + private static readonly string[] UnicodeObjectFields = { + "size", "state", "length", "asciiobject_size", + }; + private static readonly string[] GcFields = { + "size", "collecting", + }; + private static readonly string[] GenObjectFields = { + "size", "gi_name", "gi_iframe", "gi_frame_state", + }; + private static readonly string[] LListNodeFields = { + "next", "prev", + }; + private static readonly string[] DebuggerSupportFields = { + "eval_breaker", "remote_debugger_support", "remote_debugging_enabled", + "debugger_pending_call", "debugger_script_path", "debugger_script_path_size", + }; + + // Groups in the exact order they appear in _Py_DebugOffsets. + private static readonly Tuple[] Groups = { + Tuple.Create("runtime_state", RuntimeStateFields), + Tuple.Create("interpreter_state", InterpreterStateFields), + Tuple.Create("thread_state", ThreadStateFields), + Tuple.Create("interpreter_frame", InterpreterFrameFields), + Tuple.Create("code_object", CodeObjectFields), + Tuple.Create("pyobject", PyObjectFields), + Tuple.Create("type_object", TypeObjectFields), + Tuple.Create("tuple_object", TupleObjectFields), + Tuple.Create("list_object", ListObjectFields), + Tuple.Create("set_object", SetObjectFields), + Tuple.Create("dict_object", DictObjectFields), + Tuple.Create("float_object", FloatObjectFields), + Tuple.Create("long_object", LongObjectFields), + Tuple.Create("bytes_object", BytesObjectFields), + Tuple.Create("unicode_object", UnicodeObjectFields), + Tuple.Create("gc", GcFields), + Tuple.Create("gen_object", GenObjectFields), + Tuple.Create("llist_node", LListNodeFields), + Tuple.Create("debugger_support", DebuggerSupportFields), + }; + + private readonly System.Collections.Generic.Dictionary _offsets; + + /// PY_VERSION_HEX recorded by the interpreter that produced this table. + public ulong Version { get; } + + /// True if the interpreter was built free-threaded (PEP 703, Py_GIL_DISABLED). + public bool FreeThreaded { get; } + + public int Major => (int)((Version >> 24) & 0xFF); + public int Minor => (int)((Version >> 16) & 0xFF); + public int Micro => (int)((Version >> 8) & 0xFF); + + /// True when this table describes a CPython 3.14 layout (the only layout this reader knows). + public bool Is314 => Major == 3 && Minor == 14; + + /// Total number of bytes consumed by the parsed 3.14 table (header + all offset words). + public static int TableSize { + get { + int fields = 0; + foreach (var group in Groups) { + fields += group.Item2.Length; + } + return HeaderSize + fields * sizeof(ulong); + } + } + + private PyDebugOffsets(ulong version, bool freeThreaded, System.Collections.Generic.Dictionary offsets) { + Version = version; + FreeThreaded = freeThreaded; + _offsets = offsets; + } + + /// + /// Returns the recorded offset for ., e.g. + /// Offset("code_object", "linetable"). Throws if the name is unknown (programming error). + /// + public ulong Offset(string group, string field) { + return _offsets[group + "." + field]; + } + + /// + /// Attempts to parse a _Py_DebugOffsets table from raw bytes read out of the debuggee. + /// Validates the cookie and that the buffer is large enough for the full 3.14 layout, but does + /// not require a specific version so callers can inspect / . + /// + public static bool TryParse(byte[] data, out PyDebugOffsets result, out string error) { + result = null; + error = null; + + if (data == null || data.Length < HeaderSize) { + error = "buffer too small for _Py_DebugOffsets header"; + return false; + } + + for (int i = 0; i < CookieSize; i++) { + if (data[i] != (byte)CookieString[i]) { + error = "missing xdebugpy cookie"; + return false; + } + } + + ulong version = BitConverter.ToUInt64(data, CookieSize); + ulong freeThreaded = BitConverter.ToUInt64(data, CookieSize + sizeof(ulong)); + + int needed = TableSize; + if (data.Length < needed) { + error = "buffer too small for _Py_DebugOffsets 3.14 layout (need " + needed + " bytes, have " + data.Length + ")"; + return false; + } + + var offsets = new System.Collections.Generic.Dictionary(needed / sizeof(ulong)); + int pos = HeaderSize; + foreach (var group in Groups) { + foreach (var field in group.Item2) { + offsets[group.Item1 + "." + field] = BitConverter.ToUInt64(data, pos); + pos += sizeof(ulong); + } + } + + result = new PyDebugOffsets(version, freeThreaded != 0, offsets); + return true; + } + + /// + /// The name of the exported _PyRuntime global whose first field is the + /// _Py_DebugOffsets table. It is exported (PyAPI_DATA) so it can be located + /// from the module's export table without a PDB. + /// + public const string RuntimeSymbol = "_PyRuntime"; + + /// + /// Attempts to locate and parse the _Py_DebugOffsets table out of a live debuggee. + /// Resolves the exported _PyRuntime symbol from (no PDB + /// required), reads the table bytes from process memory and parses them. Returns null if the + /// symbol is absent (pre-3.14 interpreters) or the table does not validate. + /// + public static PyDebugOffsets TryRead(DkmProcess process, DkmNativeModuleInstance pythonDll) { + if (process == null || pythonDll == null) { + return null; + } + + ulong address = pythonDll.TryGetExportedStaticVariableAddress(RuntimeSymbol); + if (address == 0) { + return null; + } + + var buffer = new byte[TableSize]; + try { + process.ReadMemory(address, DkmReadMemoryFlags.None, buffer); + } catch (DkmException) { + return null; + } + + PyDebugOffsets result; + string error; + if (!TryParse(buffer, out result, out error)) { + return null; + } + return result; + } + + public override string ToString() { + return string.Format( + "_Py_DebugOffsets(version=0x{0:x}, {1}.{2}.{3}, free_threaded={4})", + Version, Major, Minor, Micro, FreeThreaded); + } + } +} diff --git a/Python/Product/Debugger.Concord/Proxies/Structs/PyInterpreterFrame.cs b/Python/Product/Debugger.Concord/Proxies/Structs/PyInterpreterFrame.cs index b3b43d8bfe..f0ca8cba39 100644 --- a/Python/Product/Debugger.Concord/Proxies/Structs/PyInterpreterFrame.cs +++ b/Python/Product/Debugger.Concord/Proxies/Structs/PyInterpreterFrame.cs @@ -49,6 +49,11 @@ internal class Fields { private const int FRAME_OWNED_BY_FRAME_OBJECT = 2; private const int FRAME_OWNED_BY_CSTACK = 3; + // CPython 3.14 _PyStackRef tag bits (Py_TAG_BITS). Object pointers are always at least + // 8-byte aligned, so the low bits are free to carry the reference tag; strip them to get + // the real PyObject* back. + private const ulong StackRefTagMask = 0x3; + private readonly Fields _fields; public PyInterpreterFrame(DkmProcess process, ulong address) @@ -62,7 +67,17 @@ public PointerProxy f_code { if (_fields.f_code.Process != null) { return GetFieldProxy(_fields.f_code); } - return GetFieldProxy(_fields.f_executable); + + var executable = GetFieldProxy(_fields.f_executable); + if (Process.GetPythonRuntimeInfo().LanguageVersion >= PythonLanguageVersion.V314) { + // In 3.14, f_executable is a _PyStackRef rather than a plain PyObject*. Its two + // low bits are a reference tag (Py_TAG_BITS, i.e. mask 0x3; set for deferred/ + // immortal references such as frozen-module code objects), so strip them to + // recover the PyCodeObject pointer. This mirrors CPython's own out-of-process + // reader (CLEAR_PTR_TAG in _remote_debugging_module.c). + executable = executable.WithTagMask(StackRefTagMask); + } + return executable; } } diff --git a/Python/Product/Debugger.Concord/PythonRuntimeInfo.cs b/Python/Product/Debugger.Concord/PythonRuntimeInfo.cs index c04a3a5ad1..a21e342f51 100644 --- a/Python/Product/Debugger.Concord/PythonRuntimeInfo.cs +++ b/Python/Product/Debugger.Concord/PythonRuntimeInfo.cs @@ -86,6 +86,11 @@ public static PythonLanguageVersion GetPythonLanguageVersion(DkmNativeModuleInst } internal class PythonRuntimeInfo : DkmDataItem { + private bool _debugOffsetsProbed; + private Proxies.Structs.PyDebugOffsets _debugOffsets; + private bool _offsetProviderProbed; + private Proxies.Structs.IStructFieldOffsetProvider _offsetProvider; + public PythonLanguageVersion LanguageVersion { get; set; } public PythonDLLs DLLs { get; private set; } @@ -100,6 +105,41 @@ public PyRuntimeState GetRuntimeState() { } return DLLs.Python.GetStaticVariable("_PyRuntime"); } + + /// + /// The self-describing _Py_DebugOffsets table exposed by CPython 3.14+ at the start of + /// _PyRuntime, or null when the interpreter does not provide one (or it fails to validate). + /// Read lazily once and cached, since it never changes for the lifetime of the process. + /// + public Proxies.Structs.PyDebugOffsets DebugOffsets { + get { + if (!_debugOffsetsProbed) { + _debugOffsetsProbed = true; + _debugOffsets = Proxies.Structs.PyDebugOffsets.TryRead(DLLs.Python?.Process, DLLs.Python); + } + return _debugOffsets; + } + } + + /// + /// Offset source that consults before falling back to the + /// interpreter PDB. Non-null only for CPython 3.14, where the _Py_DebugOffsets table + /// authoritatively describes the (potentially free-threaded-shifted) layout of the mixed-mode + /// hot-path structs. Older interpreters return null and resolve every field via the PDB exactly + /// as before, so this cannot regress them. + /// + public Proxies.Structs.IStructFieldOffsetProvider StructFieldOffsetProvider { + get { + if (!_offsetProviderProbed) { + _offsetProviderProbed = true; + var offsets = DebugOffsets; + if (offsets != null && offsets.Is314) { + _offsetProvider = new Proxies.Structs.DebugOffsetsFieldProvider(offsets); + } + } + return _offsetProvider; + } + } } internal static class PythonRuntimeInfoExtensions { diff --git a/Python/Product/DebuggerHelper/trace.cpp b/Python/Product/DebuggerHelper/trace.cpp index 5f2814d92e..9305ebf452 100644 --- a/Python/Product/DebuggerHelper/trace.cpp +++ b/Python/Product/DebuggerHelper/trace.cpp @@ -490,6 +490,11 @@ static void TraceLine(void* frame) { // the f_code object. void* f_frame = ReadField(frame, fieldOffsets.PyFrameObject.f_frame); void* f_code = ReadField(f_frame, fieldOffsets.PyFrameObject.f_code); + // In 3.14, f_code (f_executable) is a _PyStackRef whose low bits are a tag (Py_TAG_BITS), + // set for deferred/immortal references such as frozen-module code objects. Strip them to + // recover the PyCodeObject pointer. Object pointers are always at least 8-byte aligned, so + // this is a no-op on older versions where f_code is already a plain pointer. + f_code = reinterpret_cast(reinterpret_cast(f_code) & ~static_cast(3)); co_filename = ReadField(f_code, fieldOffsets.PyCodeObject.co_filename); } if (co_filename == nullptr) { diff --git a/Python/Tests/DebuggerTests/DebuggerTests.csproj b/Python/Tests/DebuggerTests/DebuggerTests.csproj index 17cc6afb69..da9439228f 100644 --- a/Python/Tests/DebuggerTests/DebuggerTests.csproj +++ b/Python/Tests/DebuggerTests/DebuggerTests.csproj @@ -69,6 +69,8 @@ + + diff --git a/Python/Tests/DebuggerTests/PyDebugOffsetsProviderTests.cs b/Python/Tests/DebuggerTests/PyDebugOffsetsProviderTests.cs new file mode 100644 index 0000000000..6a44f0a1a9 --- /dev/null +++ b/Python/Tests/DebuggerTests/PyDebugOffsetsProviderTests.cs @@ -0,0 +1,121 @@ +// Python Tools for Visual Studio +// Copyright(c) Microsoft Corporation +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the License); you may not use +// this file except in compliance with the License. You may obtain a copy of the +// License at http://www.apache.org/licenses/LICENSE-2.0 +// +// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY +// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +// MERCHANTABILITY OR NON-INFRINGEMENT. +// +// See the Apache Version 2.0 License for specific language governing +// permissions and limitations under the License. + +using System; +using Microsoft.PythonTools.Debugger.Concord.Proxies.Structs; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DebuggerTests { + /// + /// Verifies the hot-path offset provider () that lets + /// StructProxy source CPython 3.14 frame / code-object / thread-state field offsets from the + /// self-describing _Py_DebugOffsets table instead of the PDB. Everything is exercised against + /// the same real 3.14.6 vectors used by , mapping the CPython + /// struct/field names that StructProxy passes in to the values the table reports. + /// + [TestClass] + public class DebugOffsetsFieldProviderTests { + private static byte[] FromHex(string hex) { + var bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) { + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + } + return bytes; + } + + private static DebugOffsetsFieldProvider Provider(string rawHex) { + PyDebugOffsets offsets; + string error; + Assert.IsTrue(PyDebugOffsets.TryParse(FromHex(rawHex), out offsets, out error), error); + return new DebugOffsetsFieldProvider(offsets); + } + + private static long Offset(IStructFieldOffsetProvider provider, string structName, string fieldName) { + long offset; + Assert.IsTrue(provider.TryGetFieldOffset(structName, fieldName, out offset), + "expected a mapped offset for " + structName + "." + fieldName); + return offset; + } + + [TestMethod, Priority(0)] + public void Standard314_MapsInterpreterFrameHotPath() { + var provider = Provider(PyDebugOffsetsTests.RawV314); + Assert.AreEqual(8L, Offset(provider, "_PyInterpreterFrame", "previous")); + Assert.AreEqual(0L, Offset(provider, "_PyInterpreterFrame", "f_executable")); + Assert.AreEqual(56L, Offset(provider, "_PyInterpreterFrame", "instr_ptr")); + Assert.AreEqual(80L, Offset(provider, "_PyInterpreterFrame", "localsplus")); + Assert.AreEqual(74L, Offset(provider, "_PyInterpreterFrame", "owner")); + } + + [TestMethod, Priority(0)] + public void Standard314_MapsCodeObjectHotPath() { + var provider = Provider(PyDebugOffsetsTests.RawV314); + Assert.AreEqual(112L, Offset(provider, "PyCodeObject", "co_filename")); + Assert.AreEqual(120L, Offset(provider, "PyCodeObject", "co_name")); + Assert.AreEqual(68L, Offset(provider, "PyCodeObject", "co_firstlineno")); + Assert.AreEqual(96L, Offset(provider, "PyCodeObject", "co_localsplusnames")); + Assert.AreEqual(104L, Offset(provider, "PyCodeObject", "co_localspluskinds")); + Assert.AreEqual(208L, Offset(provider, "PyCodeObject", "co_code_adaptive")); + Assert.AreEqual(136L, Offset(provider, "PyCodeObject", "co_linetable")); + } + + [TestMethod, Priority(0)] + public void Standard314_MapsThreadStateHotPath() { + var provider = Provider(PyDebugOffsetsTests.RawV314); + Assert.AreEqual(8L, Offset(provider, "_ts", "next")); + Assert.AreEqual(16L, Offset(provider, "_ts", "interp")); + Assert.AreEqual(152L, Offset(provider, "_ts", "thread_id")); + Assert.AreEqual(72L, Offset(provider, "_ts", "current_frame")); + } + + [TestMethod, Priority(0)] + public void FreeThreaded314_TracksShiftedLayout() { + var standard = Provider(PyDebugOffsetsTests.RawV314); + var freeThreaded = Provider(PyDebugOffsetsTests.RawV314T); + + // The whole point of sourcing from the table: the free-threaded build shifts these fields, + // and the provider surfaces the shifted offsets with no code change (co_linetable 136->152, + // co_firstlineno 68->84). + Assert.AreEqual(136L, Offset(standard, "PyCodeObject", "co_linetable")); + Assert.AreEqual(152L, Offset(freeThreaded, "PyCodeObject", "co_linetable")); + Assert.AreEqual(68L, Offset(standard, "PyCodeObject", "co_firstlineno")); + Assert.AreEqual(84L, Offset(freeThreaded, "PyCodeObject", "co_firstlineno")); + + // Fields that don't move stay put across builds. + Assert.AreEqual(56L, Offset(freeThreaded, "_PyInterpreterFrame", "instr_ptr")); + Assert.AreEqual(72L, Offset(freeThreaded, "_ts", "current_frame")); + } + + [TestMethod, Priority(0)] + public void ReturnsFalse_ForUnmappedFieldsAndStructs() { + var provider = Provider(PyDebugOffsetsTests.RawV314); + long offset; + + // Field exists in the struct but is deliberately left on the PDB (not a hot-path field). + Assert.IsFalse(provider.TryGetFieldOffset("_PyInterpreterFrame", "f_globals", out offset)); + Assert.IsFalse(provider.TryGetFieldOffset("PyCodeObject", "co_names", out offset)); + + // Structs with no hot-path mapping at all fall through to the PDB. + Assert.IsFalse(provider.TryGetFieldOffset("_is", "eval_frame", out offset)); + Assert.IsFalse(provider.TryGetFieldOffset("PyDictObject", "ma_keys", out offset)); + + // Unknown names and nulls never match. + Assert.IsFalse(provider.TryGetFieldOffset("_ts", "not_a_field", out offset)); + Assert.IsFalse(provider.TryGetFieldOffset(null, "next", out offset)); + Assert.IsFalse(provider.TryGetFieldOffset("_ts", null, out offset)); + } + } +} diff --git a/Python/Tests/DebuggerTests/PyDebugOffsetsTests.cs b/Python/Tests/DebuggerTests/PyDebugOffsetsTests.cs new file mode 100644 index 0000000000..505008506b --- /dev/null +++ b/Python/Tests/DebuggerTests/PyDebugOffsetsTests.cs @@ -0,0 +1,204 @@ +// Python Tools for Visual Studio +// Copyright(c) Microsoft Corporation +// All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the License); you may not use +// this file except in compliance with the License. You may obtain a copy of the +// License at http://www.apache.org/licenses/LICENSE-2.0 +// +// THIS CODE IS PROVIDED ON AN *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS +// OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY +// IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, +// MERCHANTABILITY OR NON-INFRINGEMENT. +// +// See the Apache Version 2.0 License for specific language governing +// permissions and limitations under the License. + +using System; +using Microsoft.PythonTools.Debugger.Concord.Proxies.Structs; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DebuggerTests { + /// + /// Locks down the managed reader of CPython 3.14's self-describing _Py_DebugOffsets + /// table () against bytes recorded from real 3.14.6 interpreters. + /// The two vectors are the standard (GIL) build and the free-threaded (python3.14t, + /// PEP 703) build; each byte blob is the exact _Py_DebugOffsets region read out of the + /// live _PyRuntime global, and every expected offset was cross-checked in-process + /// against live objects (e.g. code_object.linetable actually points at + /// co.co_linetable) by the generator that produced these vectors. + /// + [TestClass] + public class PyDebugOffsetsTests { + // Recorded _Py_DebugOffsets bytes from CPython 3.14.6 (standard, GIL-enabled build). + internal const string RawV314 = + "7864656275677079f0060e03000000000000000000000000d0d0040000000000100300000000000028030000000000008871030000000000701c0000" + + "00000000681c000000000000a81c000000000000b81c000000000000e81c000000000000001e000000000000f01d000000000000f81d000000000000" + + "1000000000000000581e0000000000000000000000000000681e000000000000601e000000000000901e000000000000000000000000000038030000" + + "00000000000000000000000008000000000000001000000000000000480000000000000098000000000000009c00000000000000e000000000000000" + + "2000000000000000580000000000000008000000000000000000000000000000380000000000000050000000000000004a0000000000000040000000" + + "000000000000000000000000d80000000000000070000000000000007800000000000000800000000000000088000000000000004400000000000000" + + "340000000000000060000000000000006800000000000000d000000000000000000000000000000010000000000000000800000000000000a0010000" + + "0000000018000000000000005800000000000000a8000000000000002800000000000000200000000000000010000000000000002800000000000000" + + "18000000000000001000000000000000c800000000000000180000000000000028000000000000002000000000000000300000000000000020000000" + + "000000002800000000000000180000000000000010000000000000002000000000000000100000000000000018000000000000002800000000000000" + + "1000000000000000200000000000000040000000000000002000000000000000100000000000000028000000000000000801000000000000c0000000" + + "00000000a000000000000000180000000000000048000000000000004300000000000000000000000000000008000000000000001800000000000000" + + "2801000000000000e01e000000000000000000000000000004000000000000000002000000000000"; + + // Recorded _Py_DebugOffsets bytes from CPython 3.14.6 free-threaded build (python3.14t). + internal const string RawV314T = + "7864656275677079f0060e03000000000100000000000000c0830500000000001003000000000000280300000000000000c3030000000000701c0000" + + "00000000681c000000000000a81c000000000000b81c000000000000e81c000000000000181e000000000000081e000000000000101e000000000000" + + "1000000000000000701e0000000000000000000000000000801e000000000000781e000000000000a81e0000000000001c4400000000000038030000" + + "00000000000000000000000008000000000000001000000000000000480000000000000098000000000000009c00000000000000e000000000000000" + + "2000000000000000580000000000000008000000000000000000000000000000380000000000000050000000000000004e0000000000000040000000" + + "000000004800000000000000f00000000000000080000000000000008800000000000000900000000000000098000000000000005400000000000000" + + "440000000000000070000000000000007800000000000000e800000000000000e00000000000000020000000000000001800000000000000b0010000" + + "0000000028000000000000006800000000000000b8000000000000003800000000000000300000000000000020000000000000003800000000000000" + + "28000000000000002000000000000000d800000000000000280000000000000038000000000000003000000000000000400000000000000030000000" + + "000000003800000000000000280000000000000020000000000000003000000000000000200000000000000028000000000000003800000000000000" + + "2000000000000000300000000000000050000000000000003000000000000000200000000000000038000000000000002001000000000000c0000000" + + "00000000b000000000000000280000000000000058000000000000005300000000000000000000000000000008000000000000001800000000000000" + + "2801000000000000f81e000000000000000000000000000004000000000000000002000000000000"; + + // Full expected offsets for the standard 3.14.6 build (every field in the table). + private static readonly Tuple[] ExpectedV314 = { + T("runtime_state", "size", 315600), T("runtime_state", "finalizing", 784), T("runtime_state", "interpreters_head", 808), + T("interpreter_state", "size", 225672), T("interpreter_state", "id", 7280), T("interpreter_state", "next", 7272), + T("interpreter_state", "threads_head", 7336), T("interpreter_state", "threads_main", 7352), T("interpreter_state", "gc", 7400), + T("interpreter_state", "imports_modules", 7680), T("interpreter_state", "sysdict", 7664), T("interpreter_state", "builtins", 7672), + T("interpreter_state", "ceval_gil", 16), T("interpreter_state", "gil_runtime_state", 7768), T("interpreter_state", "gil_runtime_state_enabled", 0), + T("interpreter_state", "gil_runtime_state_locked", 7784), T("interpreter_state", "gil_runtime_state_holder", 7776), + T("interpreter_state", "code_object_generation", 7824), T("interpreter_state", "tlbc_generation", 0), + T("thread_state", "size", 824), T("thread_state", "prev", 0), T("thread_state", "next", 8), T("thread_state", "interp", 16), + T("thread_state", "current_frame", 72), T("thread_state", "thread_id", 152), T("thread_state", "native_thread_id", 156), + T("thread_state", "datastack_chunk", 224), T("thread_state", "status", 32), + T("interpreter_frame", "size", 88), T("interpreter_frame", "previous", 8), T("interpreter_frame", "executable", 0), + T("interpreter_frame", "instr_ptr", 56), T("interpreter_frame", "localsplus", 80), T("interpreter_frame", "owner", 74), + T("interpreter_frame", "stackpointer", 64), T("interpreter_frame", "tlbc_index", 0), + T("code_object", "size", 216), T("code_object", "filename", 112), T("code_object", "name", 120), T("code_object", "qualname", 128), + T("code_object", "linetable", 136), T("code_object", "firstlineno", 68), T("code_object", "argcount", 52), + T("code_object", "localsplusnames", 96), T("code_object", "localspluskinds", 104), T("code_object", "co_code_adaptive", 208), + T("code_object", "co_tlbc", 0), + T("pyobject", "size", 16), T("pyobject", "ob_type", 8), + T("type_object", "size", 416), T("type_object", "tp_name", 24), T("type_object", "tp_repr", 88), T("type_object", "tp_flags", 168), + T("tuple_object", "size", 40), T("tuple_object", "ob_item", 32), T("tuple_object", "ob_size", 16), + T("list_object", "size", 40), T("list_object", "ob_item", 24), T("list_object", "ob_size", 16), + T("set_object", "size", 200), T("set_object", "used", 24), T("set_object", "table", 40), T("set_object", "mask", 32), + T("dict_object", "size", 48), T("dict_object", "ma_keys", 32), T("dict_object", "ma_values", 40), + T("float_object", "size", 24), T("float_object", "ob_fval", 16), + T("long_object", "size", 32), T("long_object", "lv_tag", 16), T("long_object", "ob_digit", 24), + T("bytes_object", "size", 40), T("bytes_object", "ob_size", 16), T("bytes_object", "ob_sval", 32), + T("unicode_object", "size", 64), T("unicode_object", "state", 32), T("unicode_object", "length", 16), T("unicode_object", "asciiobject_size", 40), + T("gc", "size", 264), T("gc", "collecting", 192), + T("gen_object", "size", 160), T("gen_object", "gi_name", 24), T("gen_object", "gi_iframe", 72), T("gen_object", "gi_frame_state", 67), + T("llist_node", "next", 0), T("llist_node", "prev", 8), + T("debugger_support", "eval_breaker", 24), T("debugger_support", "remote_debugger_support", 296), + T("debugger_support", "remote_debugging_enabled", 7904), T("debugger_support", "debugger_pending_call", 0), + T("debugger_support", "debugger_script_path", 4), T("debugger_support", "debugger_script_path_size", 512), + }; + + private static Tuple T(string group, string field, ulong value) { + return Tuple.Create(group, field, value); + } + + private static byte[] FromHex(string hex) { + var bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) { + bytes[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16); + } + return bytes; + } + + [TestMethod, Priority(0)] + public void TryParse_ParsesStandard314Header() { + PyDebugOffsets result; + string error; + Assert.IsTrue(PyDebugOffsets.TryParse(FromHex(RawV314), out result, out error), error); + Assert.AreEqual(3, result.Major); + Assert.AreEqual(14, result.Minor); + Assert.AreEqual(6, result.Micro); + Assert.AreEqual(0x30e06f0UL, result.Version); + Assert.IsTrue(result.Is314); + Assert.IsFalse(result.FreeThreaded); + } + + [TestMethod, Priority(0)] + public void TryParse_ReadsEveryStandard314Offset() { + PyDebugOffsets result; + string error; + Assert.IsTrue(PyDebugOffsets.TryParse(FromHex(RawV314), out result, out error), error); + foreach (var expected in ExpectedV314) { + ulong actual = result.Offset(expected.Item1, expected.Item2); + Assert.AreEqual(expected.Item3, actual, expected.Item1 + "." + expected.Item2); + } + } + + [TestMethod, Priority(0)] + public void TryParse_ParsesFreeThreaded314() { + PyDebugOffsets result; + string error; + Assert.IsTrue(PyDebugOffsets.TryParse(FromHex(RawV314T), out result, out error), error); + Assert.IsTrue(result.Is314); + Assert.IsTrue(result.FreeThreaded); + + // instr_ptr / current_frame happen to land at the same place as the standard build... + Assert.AreEqual(56UL, result.Offset("interpreter_frame", "instr_ptr")); + Assert.AreEqual(72UL, result.Offset("thread_state", "current_frame")); + + // ...but the free-threaded build inserts extra fields, so object layouts shift. This is + // precisely why reading the table beats hard-coding offsets: linetable/firstlineno move + // within PyCodeObject and ob_type moves within PyObject. + Assert.AreEqual(152UL, result.Offset("code_object", "linetable")); + Assert.AreEqual(84UL, result.Offset("code_object", "firstlineno")); + Assert.AreEqual(24UL, result.Offset("pyobject", "ob_type")); + + // The TLBC (thread-local bytecode) fields are only non-zero in the free-threaded build. + Assert.AreEqual(72UL, result.Offset("interpreter_frame", "tlbc_index")); + Assert.AreEqual(224UL, result.Offset("code_object", "co_tlbc")); + Assert.AreEqual(17436UL, result.Offset("interpreter_state", "tlbc_generation")); + } + + [TestMethod, Priority(0)] + public void TryParse_TlbcFieldsZeroInStandardBuild() { + PyDebugOffsets result; + string error; + Assert.IsTrue(PyDebugOffsets.TryParse(FromHex(RawV314), out result, out error), error); + Assert.AreEqual(0UL, result.Offset("interpreter_frame", "tlbc_index")); + Assert.AreEqual(0UL, result.Offset("code_object", "co_tlbc")); + Assert.AreEqual(0UL, result.Offset("interpreter_state", "tlbc_generation")); + } + + [TestMethod, Priority(0)] + public void TryParse_RejectsMissingCookie() { + var data = FromHex(RawV314); + data[0] = (byte)'X'; + PyDebugOffsets result; + string error; + Assert.IsFalse(PyDebugOffsets.TryParse(data, out result, out error)); + Assert.IsNull(result); + Assert.IsNotNull(error); + } + + [TestMethod, Priority(0)] + public void TryParse_RejectsTooShortBuffer() { + PyDebugOffsets result; + string error; + Assert.IsFalse(PyDebugOffsets.TryParse(new byte[8], out result, out error)); + Assert.IsFalse(PyDebugOffsets.TryParse(null, out result, out error)); + + // A valid header but a buffer too short for the full 3.14 layout is rejected. + var truncated = new byte[PyDebugOffsets.TableSize - 1]; + var full = FromHex(RawV314); + Array.Copy(full, truncated, truncated.Length); + Assert.IsFalse(PyDebugOffsets.TryParse(truncated, out result, out error)); + } + + [TestMethod, Priority(0)] + public void TableSize_MatchesRecordedVectorLength() { + Assert.AreEqual(FromHex(RawV314).Length, PyDebugOffsets.TableSize); + Assert.AreEqual(FromHex(RawV314T).Length, PyDebugOffsets.TableSize); + } + } +}