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);
+ }
+ }
+}