FpDebug: verified bugs and API gaps, with proposed fixesFound while building a headless (non-IDE) consumer of components/fpdebug — an MCP debug server driving FpDebug over stdio JSON-RPC against Win64 and RISC-V embedded targets. Every source claim was re-verified against a current checkout of Lazarus main (line numbers from that tree, marked ~, may drift slightly against trunk). Items marked
measured were also exercised against live targets. Posted for feedback before any MRs: the bugs come with patches, the gaps are proposals, and some may be by-design decisions worth defending instead.
Part 1 — bugsD1. Pretty-printer access violation printing a :flatten() result:flatten(Head^, Next) faults with EAccessViolation, while :len() of it,
- and [0..1] slices of it all work. Any single-member-key flatten faults; two or more keys do not. Cause: fppascalbuilder.pas, TFpPascalPrettyPrinter.DoGetArrayValue (~1320) probes one element past the end of the run to size a read cache:
repeat
TmpVal := AValue.Member[StartIdx + min(CacheCnt, Cnt) + LowBnd];
if IsTargetNotNil(TmpVal.Address) then begin // <-- TmpVal may be nilFor a DWARF array, Member[] fabricates a value for an out-of-range index, so the probe is harmless. TFpValueFlatteArray members come from a list and out-of-range returns nil (fppascalparser.pas ~1774), so TmpVal.Address dereferences nil. MemberValue three lines up is nil-checked and the main print loop is nil-checked; only this probe is not. Multi-key flatten escapes because its elements are synthetic tuples with no target address, so the caching branch containing the probe is never entered. Secondary bug at the same site: on the IsTargetNotNil=False path, TmpVal.ReleaseReference is never called — the probe value leaks. Proposed patch:
TmpVal := AValue.Member[StartIdx + min(CacheCnt, Cnt) + LowBnd];
if TmpVal = nil then
break; // probe past end: no caching
if IsTargetNotNil(TmpVal.Address) then begin
... existing body unchanged ...
end
else
TmpVal.ReleaseReference; // fixes the leak
break;IDE reachability: the watch window goes through TFpWatchResultConvertor and never hits this path; breakpoint log expressions use PrintValue and should reproduce it (not tested in the IDE).
D2. PrintValue returns False for ddfPointer on a pointer — while producing correct outputMeasured: ddfPointer on a PNode makes PrintValue return False ("invalid value") — yet ddfPointer
is implemented. In fppascalbuilder.pas, DoPointer (~835), the ddfPointer branch ends in
exit; // no data, which skips the
Result := True at the end of the procedure, so failure is reported with a perfectly good string in APrintedValue. The class/interface ddfPointer branch in DoStructure (~1183) gets it right; only the skPointer branch forgets. Fix: insert
Result := True; before that exit.
D3. Array slices ignore the array's declared boundsMeasured, with Arr: array[0..9] of Integer and DynArr of length 5:
Arr[8..20] -> 13 elements, the last 11 are whatever follows the array
DynArr[0..99] -> 100 elements, 95 of them heap garbageNo error, no truncation. In-range slices are correct, which makes the out-of-range output dangerously plausible — an automated consumer has no second signal to cross-check against. Cause: TFpPasParserValueSlicedArray.GetMemberCount (fppascalparser.pas ~7766) is
EndValue - StartValue + 1 — the count comes entirely from the literals in the expression — and GetMember re-evaluates the base expression per index with no bounds check either.
The clamp mechanism already exists: EndValue (~8014) honours an FHasUpperLimit/FUpperLimit pair, and the slice controller (~7887) populates it from the base value — but only when vfArrayUpperBoundLimit is set, and the only place in the tree that sets that flag is TFpValueFlatteArray.Create. Flatten results are protected; ordinary DWARF arrays are not. Proposed fix, in the same controller block: when the base is skArray with a known high bound, feed the same pair:
else
if (tmp.Kind = skArray) and (tmp.IndexTypeCount > 0) and
tmp.IndexType[0].GetValueHighBound(tmp, hb)
then begin
FSlicePart.FUpperLimit := hb;
FSlicePart.FHasUpperLimit := True;
end;plus Max(0, ...) in GetMemberCount for inverted/empty ranges, and a matching low-bound clamp in StartValue. Where no bound is known (open or pointer-backed arrays) the current permissive behaviour remains — as the documented exception rather than the default.
D4. A pointer whose value is 0 cannot be dereferenced ("Internal dereference error"), and address 0 is hard-coded unreadableThis matters most on bare metal: on the WCH CH32V parts I debug, 0x0 is the flash alias — the reset vector and initial SP live there. It is the first address a bring-up session wants to read, and the one address FpDebug will not touch. Measured on CH32V103 over a remote link — nothing below the evaluator refuses address 0:
read_memory 0x0 -> 6f2080066f20000d... (reset vector; identical at 0x8000000)
evaluate PByte(0) -> "Internal dereference error"
evaluate PByte(1) -> "PByte($00000001)" fine
evaluate PByte($8000000) -> "PByte($08000000)" fineAddress 1 is no more "valid" than 0 by any general rule, and it is accepted — whatever refuses 0 tests for zero specifically. Traced: TFpValueDwarfPointer.GetDerefAddress (fpdbgdwarf.pas ~2795) initialises Result := InvalidLoc and skips the address read when IsNilLoc(OrdOrDataAddr). For the typecast constant PByte(0), OrdOrDataAddr is ConstLoc(0); IsNilLoc (which includes mlfConstant at address 0) is True; the deref address stays InvalidLoc, and GetMember (~3093) then hits
if not IsTargetAddr(addr) and reports "Internal dereference error". PByte(1) passes because ReadAddress resolves ConstLoc(1) to TargetLoc(1). The pretty-printer compounds it: DoPointer prints "nil" correctly, but its speculative PChar probe (Member[0]) sets the error above, and the tail of InternalPrintValue replaces the successful output with the error string.
The policy layer: four predicates in fpdmemorytools.pas conflate "this pointer is nil" with "this address cannot be read" (IsReadableMem, IsReadableLoc, IsNilLoc, IsTargetNotNil all test Address <> 0). The first two are virtual on TFpDbgMemModel — and TFpDbgAvrMemModel already overrides both with no zero test, because AVR has real memory at 0. So "a target whose 0 is real can opt out" is settled policy; the problems are that many call sites use the free functions directly and never consult the model, and the deref path above has its own IsNilLoc shortcut that bypasses both.
Proposed, in increasing ambition: (1) fix the diagnostic — set a real "cannot dereference nil" error in GetDerefAddress, and have DoPointer clear the value's error when the speculative probe fails so it cannot clobber a successful print; (2) drop the IsNilLoc special case and let ReadAddress resolve the constant — hosted targets then fail honestly with "cannot read memory at address 0", targets whose model permits 0 succeed (needs a survey of GetDerefAddress callers first); (3) route the remaining zero tests through the model so the AVR-style override works everywhere. Item 1 is worth doing regardless of 2 and 3.
Part 2 — API gapsThings a headless consumer needs and cannot get, ordered roughly by smallness times payoff.
G1. TFpDbgBreakpoint has SetCondition but no getter. Interface at fpdbgclasses.pp ~580: no GetCondition, no property, though FCondition already exists on the base class. A consumer that reports breakpoints back to its user must carry a parallel map. Ask: a one-line read accessor.
G2. A breakpoint condition has no error channel and is never validated. IsValidHit (~4466) starts Result := True and only overwrites it when the expression is both valid and boolean — a typo'd or non-boolean condition stops on every hit exactly as if it were absent, with no error text, flag or event (measured). The author's own TODO ("parse expression when breakpoint is created") sits directly above. Ask: validate at SetCondition, and/or expose a ConditionError filled when evaluation falls back to True.
G3. No hit/ignore count, and no way to add one from outside. AddBreak (~2641) hard-codes TFpInternalBreakpoint.Create — no class-of hook, no virtual factory — so a consumer cannot subclass IsValidHit to count hits; counting in the consumer's stop handler costs a real halt per hit, which on a slow debug link is the expensive path conditions exist to avoid. Ask: HitCount/IgnoreCount on the base honoured by IsValidHit, or a breakpoint-class hook on TDbgProcess (the smaller change, and it unblocks more).
G4. SetCurrentThreadId fails silently on an unknown id. fpdbgcontroller.pas ~1681: unknown id → debugln and exit, with the raise commented out. Ask for thread 99, keep thread 1: every subsequent register read and backtrace answers about thread 1 while looking like thread 99. Ask: TrySetCurrentThreadId: Boolean (or honour the raise), keeping the property as the convenience form. Same visit: GetCurrentThreadId (~1650) has no nil guard (AV before the process exists), and GetDefaultContext (~1663) caches a context that SetCurrentThreadId does not invalidate — between a thread switch and the next ProcessLoop iteration, DefaultContext describes the old thread; a ReleaseRefAndNil in the setter closes it.
G5. Out-of-range stack frame surfaces as "identifier not found". FindSymbolScope (~2904) never returns nil for a bad frame: it falls through to a TFpDbgSymbolScope over a zero-address context, so a wrong frame
number reads like a misspelled
variable. There is also no way to get a frame count without paying for the unwind and measuring CallStackEntryList.Count. Ask: return nil (or a distinct error) for an out-of-range frame, or expose the count.
G6. DW_AT_artificial is parsed, cached, and unreachable. TFpSymbolDwarf.IsArtificial (fpdbgdwarf.pas ~4866) reads and caches the attribute; the DWARF plumbing below it is complete. But it is protected, and TDbgSymbolFlag has no member for it, so a consumer holding a TFpSymbol cannot ask. "List locals" features must therefore guess from names: a $-prefix rule catches $result and $parentfp but misses self (plain name, correctly artificial in DWARF — FpDebug itself checks IsArtificial when hunting the self parameter), and class members _vptr$TOBJECT and _MonitorData are indistinguishable from fields a programmer happened to name that way. Ask: add sfArtificial to TDbgSymbolFlag, set from the existing cached read — one enum member and one assignment — or promote IsArtificial to a public virtual on TFpSymbol.
G7. A watchpoint that failed to install is indistinguishable from one that works. AddWatch (~2683) returns a live object unconditionally; the constructor ends in SetBreak, a procedure whose only failure handling is to undo itself. And the caller cannot ask afterwards: TFpInternalWatchpoint descends from TFpDbgBreakpointBase, so its State is the base GetState — bksUnknown, always — while bksFailed sits unused in the enum for this class. Two paths reach it: x86 debug-register exhaustion (a wide or unaligned watch consumes several Dr slots), and the base TFpWatchPointData.AddOwnedWatchpoint, which is unconditionally False — a backend that does not override it fails every watchpoint, silently, forever. Measured end-to-end: four watchpoints install, the fifth "succeeds" and never fires; the only recourse is scanning TFpIntelWatchPointData.Owner[0..3] by pointer identity, which is Intel-only. Ask (any one closes it): make SetBreak return Boolean or set a failed flag; give TFpInternalWatchpoint a GetState override returning bksFailed; or expose SlotsTotal/SlotsUsed virtuals on TFpWatchPointData so refusals can say why.
G8. No DWARF name-to-address lookup for source-level function names. TDbgInfo.FindProcSymbol(AName) returns nil and TFpDwarfInfo does not override the by-name overload (verified), so resolution falls through to the PE/COFF table of FPC-mangled linker names. The expression evaluator's scope resolves @Add fine, so the information exists; the by-name entry point just does not reach it, and every consumer of AddBreak(AName) reimplements the fallback. Ask: implement the by-name FindProcSymbol in TFpDwarfInfo.
G9. Member[] out of range: two contracts. DWARF arrays fabricate a value for out-of-range indices; list-backed values (flatten) return nil. Both defensible, both undocumented; the combination is what turned the D1 probe into an AV — the printer nil-checks one of its two probes but not the other. Ask: document the contract, and make it uniform.
G10. GetInstanceClassName can leave an error on a healthy value. LazDebuggerFp knows the folklore:
ResValue.ResetError; // in case GetInstanceClassName did set an error (fpdebugdebuggerworkthreads.pas ~1258). A consumer without that line gets a value reporting an error it does not have. Ask: do not set the error for the no-RTTI case, or document the required reset.
G11. Three display formats declared but silently ignored. ddfChar, ddfString and ddfBinary have no implementation anywhere — the int and cardinal branches carry "// TODO ddfChar:" and fall to the default — so the caller gets a plausible number in the wrong base with no indication (measured: byte-identical to ddfDefault across seven types). ddfPointer is D2 above. Ask: implement them, or have PrintValue return False for a format it will not honour, so callers can tell "value in binary" from "request ignored".
G12. Backend failures flatten to fpErrAnyError — no neutral target/link failure classes. The error machinery is better than its usage suggests: TFpErrorCode is an open Integer space in informal bands, TFpError chains (the top link stays branchable via ErrorCode() while inner links keep their own codes), and OnErrorTextLookup lets codes FpDebug has never heard of still render. But the code list is entirely evaluator- and hosted-process-shaped, so an embedded backend has nothing neutral to map to: link lost, reset failed, target read-protected, hardware slots exhausted all become CreateError(fpErrAnyError, ['text']) — code 1 plus prose, unbranchable (measured in an out-of-tree RISC-V embedded backend). Ask: a small neutral band, chosen by what a generic caller would act on differently, not by cause:
// 20000 target / debug-link errors
fpErrTargetConnectionLost = TFpErrorCode(20000); // re-attach / replug
fpErrTargetBusy = TFpErrorCode(20001); // retry after halt
fpErrTargetTimeout = TFpErrorCode(20002); // retry or abort
fpErrTargetAccessDenied = TFpErrorCode(20003); // protection; needs user action
fpErrTargetResourceExhausted = TFpErrorCode(20004); // hw slots (data: used, total)
fpErrTargetNotSupported = TFpErrorCode(20005); // disable the featureAdmission test: if no consumer would take a different action on it, it is message text, not a code. Backend-specific detail (DMI cmderr values, protocol errors) stays in a private band and rides as the inner chain link. Related: enums like TFpDbgBreakpointState should stay coarse (they drive state machines; every added member grows every case in every consumer) and instead gain a LastError: TFpError — the same accessor G7 and G2 want; the real information sinks are the Booleans (SetBreak, AddOwnedWatchpoint, ReadData) that discard the reason. Two housekeeping notes: OnErrorTextLookup is a single function pointer, so two providers must chain it manually — a registration list would fix that; and the code bands are convention only — a documented reserved range for out-of-tree backends would avoid collisions.
Suggested order. One-liners first: G1, D2, G6, G4. Then the confidently-wrong-answer class: G7, G2, G5, D3 (the clamp machinery already exists). Then D1, D4 item 1 (the diagnostic half), G11, G3, G10, G8, and finally D4 items 2 and 3, which want a maintainer's view on blast radius. G12 is a design conversation rather than a patch, but it is the natural home for the LastError accessors G7 and G2 want, so it is worth raising alongside them.