Skip to content

Managed ilasm: remaining PDB differences from native ilasm after #135289, #135297, #135311 and #135312 #135314

Description

@pcshrosbree

While checking the managed ilasm's (src/tools/ilasm) PDB output against native ilasm and against src/tests/ilasm/PortablePdb, I found a number of differences. The ones that make that test suite fail are addressed by the PRs below. The rest are listed here, grouped, so they can be picked up one at a time. Nothing beyond the open PRs is planned unless you name it: are any of these important to you, or ones you would like to see addressed? I am happy to take whichever you point at, and to leave alone anything you consider intended or not worth a change.

Already addressed (open PRs, in landing order)

Related issues: #135211 (PdbChecksum hashes only the #Pdb stream; -DET leaves the PDB id's stamp at 0), #135214 (-DET derives MVID, PE timestamp and PDB id from a zero-filled buffer), #135213 (ILLink leaves zero-length LocalScope rows), #46328 (end-column default of a partly specified .line).

Parity gaps in the managed tool

  • 1. #line (auto-increment) is parsed but the increment is never applied. Native's implementation is also broken (it increments the start line but not the end line, and then rejects its own sequence point), so this would be spec conformance rather than parity.
  • 2. Partly specified .line directives (ilasm throws an error when partly-defined .line directives are used #46328): native fails the assembly; managed emits start+1 as the end column. The issue proposed 0xffff. Which is wanted?
  • 3. Sequence points are not validated: reversed line or column ranges reach the compressed-integer writer and throw, a 0xFEEFEE start line without 0,0 columns is treated as hidden, and columns of 0x10000 or more are encoded although the format forbids them. Native warns and fails the method body.
  • 4. A netmodule assembled with -DEBUG gets no DebuggableAttribute; native attaches one via AssemblyAttributesGoHere. The legacy two-Boolean DebuggableAttribute constructor for a v1 mscorlib target is not emitted either.
  • 5. Non-deterministic PDB ids use the constant stamp 0x04030201; native uses the clock. The PdbChecksum entry's timestamp is 0 (native: the PDB stamp). Overlaps with ilasm: PdbChecksum hashes only the #Pdb stream, and -DET leaves the PDB ID's stamp at 0 #135211.
  • 6. A second .entrypoint, or one on an instance method, is accepted silently; native reports an error.
  • 7. With -ERROR, native drops the rest of a method (and the following method) after a syntax error inside a body; managed keeps both methods, with the bad instruction emitting nothing. Which recovery is intended?
  • 8. Every diagnostic prints its location through the default record ToString, so a single warning dumps the whole source text (74 lines for TestLocalScopes4.il, which triggers the in-use warning as it does with native). Native prints file(line) : warning : message. I would send a small PR for file(line,col) : severity ILAxxxx : message if that is wanted.
  • 9. Integer literals are parsed as 64-bit and narrowed to 32-bit without a range check wherever the grammar takes an int32, so 4294967296 reads as 0. The local-slot path checks the literal as written since Managed ilasm: honour explicit local slots, scope local names lexically, and emit LocalScope/LocalVariable rows #135311; the other consumers do not.

Places where the managed tool is stricter than native

  • 10. A .line path with single backslashes ('C:\p\win.cs') is a lexer error; native silently strips the backslashes (document name C:pwin.cs).
  • 11. A .language GUID without braces is accepted; native ignores it.

Both look like improvements. Should they be listed in MANAGED-ILASM-FIXES.md as deliberate?

Native defects found on the way

No change to native is proposed; these are noted so the managed behaviour is not changed to match them. Each is already recorded as a deliberate difference in MANAGED-ILASM-FIXES.md by the PRs above.

    1. EmitLocals lets an implicit local after [65535] take slot 65536, which no local instruction can name, and the PDB writer wraps its LocalVariable index to 0; [4294967295] is read as [-1], and a hexadecimal slot literal of more than 16 digits is read by its low bits, all without a diagnostic.
    1. An instruction produced by a #define macro is mapped to line 1 (the macro text is lexed as a fresh source); after an #include, a .line file named inside the include stays current in the including file with the include's coordinates.

Curiosity

Added 2026-10-07

  • 15. #include resolution. Both tools resolve an #include path as written, relative to the current directory, and both honour -INCLUDE. The managed tool then also tries the path relative to the first input file's directory, where native fails the include. An #include inside an included file is resolved the same way as a top-level one in each tool, never relative to the including file. Is the input-directory fallback intended, and should it be listed in MANAGED-ILASM-FIXES.md as deliberate? Should nested includes resolve relative to the including file, as C preprocessors do? (Measured with the file in the current directory, beside the input, in both, and nested one level, against both tools.)

Inputs, commands and dumps for each are available on request; tested on Linux x64 against main at 4a6440c3f03.

Note

This survey and this note were prepared with AI assistance (Anthropic Claude and OpenAI Codex) under my direction. AI agents ran the builds and probes on my machine. I reviewed the text before posting.

Activity

  1. dotnet-policy-service commented on Oct 6, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @dotnet/interop-contrib
    See info in area-owners.md if you want to be subscribed.

  2. dotnet-policy-service commented on Oct 6, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @dotnet/jit-contrib
    See info in area-owners.md if you want to be subscribed.

  3. pcshrosbree commented on Oct 7, 2026

    @pcshrosbree
    ContributorAuthor

    Point 14, measured. The four bytes are the #US (user string) heap, and the cause is in the native metadata writer rather than in either PDB writer.

    native PDB managed PDB
    #US heap 8 bytes: 00 03 20 00 00 + 3 padding 4 bytes: 00 + 3 padding
    every other stream same size (in 27 of the 30 pairs below) same size

    Native's heap holds the mandatory empty entry followed by one user string, a single space. CLiteWeightStgdbRW::GetSaveSize (src/coreclr/md/enc/liteweightstgdbrw.cpp) adds it to any user-string heap that is still empty at save time, with the comment "An empty user string pool causes problems with edit and continue". Native ilasm saves the PDB through that same engine, and a PDB never has user strings, so every native PDB gets it. The same function adds a " " string to an empty #Strings heap, under FEATURE_METADATA_EMIT_PORTABLE_PDB, "in order to be recognized by the VS debugger". That one fits in the heap's alignment padding (00 20 00 00 against 00 00 00 00), so it never shows as a size difference; it is in every native PDB whose #Strings heap would otherwise be empty and absent once a local variable name is in the heap.

    Why native needs them: its pool-saving step skips a pool with no data ("If there is no data, then don't bother"), so without the seeds the #US and #Strings streams would be missing from the file. The managed ilasm writes through System.Reflection.Metadata, which emits the heap streams even when they hold only the empty entry; a Roslyn-built PDB has the same 4-byte #US heap.

    Which is right: both are structurally valid. ECMA-335 lets a writer omit or keep an empty heap and allows unreferenced entries, and the Portable PDB spec requires only the #Pdb and #~ streams. The debugging tables never reference #US. The readers in this repository (MetadataReader, PortablePdbSymbolReader, StackTraceSymbols) do not depend on the #Strings seed either; I have not tested Visual Studio against a PDB whose #Strings heap is empty, which is what native's comment is about. I would leave the managed output as it is. No change proposed.

    Evidence (Linux x64; native ilasm built from main, managed at 22a31f5c325, #135312's head): 35 inputs, each assembled by both tools from the same directory with the same input path and output name. 30 produced a PDB from both tools; native rejected four (the #line and partial .line inputs of items 1 and 2, and one .line-only input) and one failed in my harness over an include path. The two PDBs of each pair were compared stream by stream, the #Pdb stream after its 20-byte id, which always differs. In all 30 the #US heaps are exactly the two byte strings above. 27 of the 30 PDBs differ in size by exactly 4 bytes. The other three: two #include inputs where native keeps a Document row for an included file that has no sequence point of its own (the deliberate difference listed in #135312), and a netmodule, whose native #Pdb stream records the MemberRef and CustomAttribute row counts of the DebuggableAttribute native attaches to a netmodule (item 4). As a check on the mechanism, an input with ldstr "x" gives DLLs whose #US heaps agree in size, because native does not seed a non-empty heap, and PDBs that are still 4 bytes apart.

    For anyone comparing the two PDBs byte for byte, the streams of equal size are not byte-identical, for these reasons: the Sorted mask in the #~ header (native also marks absent type-system tables as sorted; System.Reflection.Metadata's PDB writer marks the present sorted debugging tables); the TypeRef row count in #Pdb, which mirrors the DLL, where the managed tool emits a TypeRef to System.ValueType in 24 of the 30 inputs through the lookup in its unsealed-type check (GrammarActions.Types.Headers.cs), unreferenced when the type extends Object; the order of local names in #Strings in two inputs; the separator byte of a document name with no directory part (native 0, managed /, same name); and the stream order (managed writes #Pdb first, native last). In the DLL, not the PDB, strings emitted through native's DefineUserString get a trailing flag byte of 1 ("This byte is not used by the runtime"), where System.Reflection.Metadata computes it as ECMA-335 II.24.2.4 specifies. The ValueType reference and the trailing byte are DLL matters; I can add them as rows here if you want them tracked.

    Note

    This measurement and this note were prepared with AI assistance (Anthropic Claude and OpenAI Codex) under my direction. AI agents ran the probes on my machine. I reviewed the text before posting.

  4. pcshrosbree commented on Oct 7, 2026

    @pcshrosbree
    ContributorAuthor

    Added item 15 to the list above: #include resolution. The managed tool resolves an include relative to the current directory and then, unlike native, relative to the first input file's directory; neither tool resolves a nested include relative to the including file. Two questions are in the item. Nothing in the open PRs depends on the answer.

    Note

    This note was prepared with AI assistance (Anthropic Claude) under my direction. I reviewed the text before posting.

  5. removed
    untriagedNew issue has not been triaged by the area owner
    on Oct 9, 2026
  6. added this to the 12.0.0 milestone on Oct 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

  • Status
    No status

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions