Skip to content

Managed ilasm: per-point PDB documents, the input file and .line files as documents, LocalSignature row id - #135297

Open
pcshrosbree wants to merge 7 commits into
dotnet:mainfrom
pcshrosbree:ilasm-goodwill/pdb-documents
Open

pcshrosbree wants to merge 7 commits into
dotnet:mainfrom
pcshrosbree:ilasm-goodwill/pdb-documents

Conversation

@pcshrosbree

@pcshrosbree pcshrosbree commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Part of making the managed ilasm (src/tools/ilasm) a drop-in for native ilasm, as discussed in #135212. There @jkotas pointed to the managed rewrite as the way forward, @jkoritzinsky confirmed that PDB differences from native ilasm are unintended, and @am11 suggested combining efforts.

#135289 (the separate .pdb file) has merged; this PR's seven commits are now the whole diff; the last three address review feedback.

Without this PR, with the separate-PDB fix applied, the runtime's PortablePdb test cases (IlasmPortablePdbTester in src/tests/ilasm/PortablePdb) pass 10/10 with native ilasm and 4/10 with the managed one, measured by running those cases against both ilasms on Linux x64. This PR takes the document-related failures and one spec defect.

  1. A method can span documents. Before: one document per method (the file of its first .line), so a method whose .line directives named two files had every point in the first. After: each point records the document current when its directive is applied. A method spanning documents has Document = nil, an InitialDocument in the blob header, and a document-record before each non-hidden point whose document changes, which is what native writes (DefineSequencePoints).
  2. '' means "same file". Before: .line N : C '' created a document named ''. ildasm writes that form when the file did not change, including on a method's first .line. After: an empty name keeps the current document, as native's SetSourceFileName does.
  3. Documents. Before: documents were created only for files a method's first point used, in MethodDef order, keyed by name and language; their language was empty without .language; and the input .il file was never a document. After, as in native: the input file, by full path, is document WIP: repo consolidation scouting kick-off - make clr build locally on Windows #1; every .line/#line file is defined when its directive is applied, wherever it appears and whether or not a point uses it; documents are in encounter order, keyed by name (case-sensitive); each keeps the language current at its first definition, which defaults to IL assembly (af046cd3-d0e1-11d2-977c-00a0c9b4d50c).
  4. LocalSignature. Before: the sequence-points blob header always had LocalSignature = 0. After: it is the StandAloneSig row id of the body's local signature, as the Portable PDB spec requires and native writes, or 0 without locals. This includes bodies shared by /FOLD.
  5. Methods without a body. Before: a .line inside an abstract or pinvokeimpl method, or in a body with directives but no instructions, gave it a sequence point at IL offset 0 of a body it does not have. After: such a method has no sequence points; the directive still names its file, as in native.
  6. --pathmap (from review). --pathmap <path>=<sourcePath>[,...] replaces path prefixes in the paths the debug information records, every PDB document name and the PDB path in the CodeView entry, with the C# compiler's -pathmap rules: each side is completed with a trailing separator so a key matches whole directories only, the first matching entry wins, and the result's separators follow the replacement's. With /DET, where the CodeView entry already names only the PDB's file name, mapping the directory of the inputs gives byte-identical image and PDB files wherever the tree is. Files are still read and written at their real paths.

Behaviour changes to be aware of:

  • With /DEBUG or /PDB, every PDB now has the input file as a document, even without .line directives.
  • The CLI now passes each input file's full path to the assembler, so it can name the input document by full path. As a result, error messages also name the input by its full path, whereas native ilasm prints the path as given on the command line. I'm happy to keep the diagnostic path as given and pass the document name separately if that is preferred.

Deliberate difference from native: a method with no sequence points, or without a body, keeps a nil Document, as the spec says; native names the current document.

Other differences from native ilasm found in the same survey, not taken by any of these PRs, are tracked in #135314.

Not in this PR (follow-ups):

  • LocalScope/LocalVariable rows, explicit local slots and lexical scopes.
  • Implicit sequence points for the .il source's own lines.
  • #included files as documents. In particular, a file named by a .line inside an include currently stays current after the include ends, whereas native re-establishes the including file.
  • #line auto-increment.
  • .line end-column defaults.
  • Two cases that come from the managed ilasm recording sequence points when a directive is applied rather than when instructions are emitted; the implicit-points work will change this: a .line after a method's last instruction gives a point at the end offset, where native gives none; a class-level .line followed by a method without .line gives that method's first instruction no point, where native gives it the class-level coordinates.

Testing:

  • Unit tests. New PdbDocumentTests reads the PDB back with System.Reflection.Metadata, and for the encoding also reads the blob's raw records. It covers: the nil Document, the InitialDocument and each point's document (A→B→A switching; hidden points, with no document-record); two directives at one offset; '' within a method and on its first .line; files named at top level, class level or by a replaced directive; encounter order (not MethodDef order) and case-sensitive names; the default language, .language for later documents and for the next input file, and the first language sticking; one document per input file; abstract and pinvokeimpl methods getting no points; no points for a body with directives but no instructions, while an error-tolerant compile keeps the points of a method that still has its instructions; identical PDB bytes under /DET; the LocalSignature row id: rows 1 and 2, 0 without locals, /FOLD, and no blob for a method with locals but no .line. Each new behaviour has a test that fails when the behaviour is removed; I checked this by breaking each one in turn. Existing tests that checked documents now assert the complete ordered list of document names, input file first.
  • Generated cases. 200 seeded programs, some split across two input files and some with abstract methods. Their methods move between a few documents with named, '', unnamed and hidden .line directives (in the full form native accepts), class-level directives, .language and locals. The generator computes the expected documents and points independently. Theories check each point's document, the method's Document, the InitialDocument, the document table and the LocalSignature.
  • Command line. New CommandLinePdbDocumentTests runs the ilasm command line in process, as CommandLinePdbFileTests does, and reads the PDB it writes. It checks what only the command line decides: without any .line, the input file is the only document, by full path; an input given by a path relative to the current directory (written under it, so the argument is never rooted, without setting the current directory) is named by its full path; two input files are two documents in command-line order, before the document of a .line file; and the TestDocuments2 case end to end ('' on a method's first .line keeps the previous method's document). Each of these fails when the behaviour it checks is broken. PortablePdbTestReader can now also open an image and PDB from files. MethodTests now also checks that the PDB header's entry point is the .entrypoint method's MethodDef, not the first MethodDef, and nil without one; the runtime's TestPdbDebugDirectory2 case was the only test of this.
  • --pathmap. PathMapTests checks the parsing and mapping rules as strings; PdbDocumentTests and CompilerOptionsTests check the document names and the CodeView path on the library; CommandLinePathMapTests runs the command line in process: the same source in two directories, each mapped to /_/, assembled with -DET -DEBUG, gives byte-identical image and PDB files; the option can be given more than once and the entries accumulate in order; a malformed value fails before anything is written; the PDB is written at its real path. Each rule has a test that fails when the rule is removed.
  • Runtime PortablePdb cases (a measurement, not a change in this PR). This PR changes nothing under src/tests/ilasm. I ran that directory's IlasmPortablePdbTester cases against both ilasms on Linux x64, outside the tree: native passes 10/10, and the managed ilasm passes 4/10 at Write the managed ilasm PDB to a separate file, as native ilasm does #135289's head and 6/10 with this PR; the four still red are TestPortablePdbLocalScope(TestLocalScopes1..4.il), which the local-scope PR takes. The two cases I added while measuring, TestDocuments2 and an input given by a relative path, passed with both ilasms and are now in CommandLinePdbDocumentTests.

Note

This change was prepared with AI assistance (Anthropic Claude and OpenAI Codex) under my direction. AI agents wrote the code and ran the build and tests on my machine. I reviewed the change before posting.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 5 pipeline(s).
11 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

Comment thread src/tools/ilasm/src/ilasm/Program.cs
Comment thread src/tools/ilasm/src/ILAssembler/Actions/GrammarActions.BuildImage.cs Outdated
Comment thread src/tools/ilasm/tests/ILAssembler.Tests/PortablePdbTestReader.cs
@pcshrosbree
pcshrosbree force-pushed the ilasm-goodwill/pdb-documents branch from ea812f3 to 11bf178 Compare October 9, 2026 02:15
@@ -33,6 +33,7 @@ internal static class NativeCommandLine
["NOL"] = "--nologo",
["OPT"] = "--optimize",
["OUT"] = "--output",
["PAT"] = "--pathmap",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The legacy command-line options recognized here are supported solely for backward compatibility with native ilasm. No new options should be added here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 39e68ce: the entry, its rows in the command-line test data and the test of that form; the README no longer names it. A row in UnrecognizedNativeOption_Throws now checks that -PATHMAP= is rejected like any other unknown native option.

@pcshrosbree
pcshrosbree force-pushed the ilasm-goodwill/pdb-documents branch 3 times, most recently from bf6b1b0 to 8e01ead Compare October 9, 2026 21:10
…an several documents

The managed ilasm kept one document per method: the file named by the first
.line directive of the method. A method whose .line directives named two
files had all of its sequence points in the first one, and an empty file
name ('' or "") created a document named ''.

Each sequence point now records the document that is current when its
directive is applied, as an index into the compilation's PDB document
table. When the points of a method span several documents, its
MethodDebugInformation row has a nil Document, the sequence points blob
starts with the InitialDocument (the document of the first point), and a
document-record precedes each non-hidden point whose document differs from
the current one. Hidden points take the current document. This is the
encoding native ilasm writes (portable_pdb.cpp, DefineSequencePoints).
A method whose points are all in one document still names that document
and has no InitialDocument.

An empty file name leaves the current document unchanged, as in native
ilasm (SetSourceFileName ignores an empty name). ildasm writes '' for "the
same file as the previous directive", including on the first .line of a
method whose file did not change.

A directive at the same IL offset as the previous one still replaces that
point; the replacement now also takes the last directive's document.

A method without an IL body (abstract, pinvokeimpl, runtime-implemented)
gets a MethodDebugInformation row with a nil Document and no sequence
points blob, even when it contains a .line directive. Before, such a method
got a point at IL offset 0 of a body it does not have; native ilasm records
sequence points as it emits instructions, so it records none.
MethodDefinitionEntity.HasBody is the one test for "has a body" that both
the IL writer and the PDB writer use.

Tests: PdbDocumentTests reads the PDB back with System.Reflection.Metadata
and checks the nil Document, the InitialDocument, the document of each
point (including A, B, A switching and hidden points, with the blob's raw
records showing that no document-record is written for a hidden point),
'' within and at the start of a method, that a method without points keeps
a nil Document and no blob, that abstract and pinvokeimpl methods with a
.line directive get no points, nor does a body with directives but no
instructions, and that in an error-tolerant compile a method that still
has its instructions keeps its points.
The managed ilasm created a PDB document only for a file that a method's
first sequence point used, in MethodDef order, keyed by name and language,
and with no language unless .language was given. The input .il file was
never a document.

Native ilasm defines documents as it meets them: the input file, by full
path, before parsing it (main.cpp), and each file named by .line or #line
when the directive is applied, at top level, in a class or in a method,
whether or not a sequence point ever uses it (SetSourceFileName,
DefineDocument). A document is identified by its name alone and keeps the
language current at its first definition. The language defaults to IL
assembly, af046cd3-d0e1-11d2-977c-00a0c9b4d50c
(CorSym_LanguageType_ILAssembly).

The managed ilasm now does the same:

- BeginDocument defines each input file as a document and makes it the
  current document, so a .line without a file name before any named file
  is in the input file, and each input file starts in its own document.
- A .line or #line with a non-empty file name defines that file and makes
  it current wherever the directive appears.
- The document table keeps documents in encounter order, keyed by name;
  the PDB lists them in that order.
- Documents defined before any .language directive have the IL assembly
  language; .language applies to documents defined after it.
- The CLI passes the full path of each input file, which is the input
  document's name. Library callers get SourceText.Path as given. Error
  messages from the CLI now name the input file by its full path too.

Existing expectations that change because the input file is now a
document, each now asserting the complete ordered list of document names
(input file first) where it asserted a count or a name: SourceDirectiveTests
(PdbGeneration_WithLineAndLanguageDirectives_CreatesValidPdb,
PdbGeneration_LanguageWithVendorAndDocumentType_CreatesValidPdb,
LanguageDirective_SyntaxVariant_EmitsDocumentLanguage,
ClassScopedLanguageAndLineDirectives_ApplyToMethodSequencePoint,
MultiDocumentCompile_WithLineDirectivesAcrossDocuments_EmitsPdbDocumentsForEachSource,
MalformedLanguageDirective_DoesNotPartiallyUpdateGuidState),
CompilerOptionsTests.PdbOption_ProducesPortablePdbWithoutLineDirectives
(one document, the input file, instead of none) and
ModuleTests.GlobalMethodAndModuleAttribute_EmitModuleMetadataAndPdb.
MultiDocumentCompile_DoesNotReusePreviousDocumentPath now expects the
second file's .line without a file name to be a point in the second input
file instead of no point at all, and checks the three documents; it still
checks that the first file's .line file is not reused.

Tests: PdbDocumentTests checks the input file as the first document, the
encounter order (not MethodDef order), files named at top level, class
level or by a replaced directive, the default language, .language for
later documents and for documents of the next input file, a file named
again keeping its first language, names that differ only in case being
different documents (native compares with strcmp), one document per input
file, and identical PDB bytes for a deterministic multi-document program.
PdbCaseGenerator generates 200 seeded programs, in one input file or two,
whose methods (some of them abstract) move between a few documents with
named, '', unnamed and hidden .line directives in the full form native
ilasm accepts, class-level directives and .language, and computes the
expected documents and points; PdbGeneratedCaseTests checks each point's
document, the method's Document, the InitialDocument and the document
table against it.
The sequence points blob starts with LocalSignature, which the Portable PDB
spec (docs/design/specs/PortablePdb-Metadata.md, MethodDebugInformation
Table) defines as the row id of the method's local signature in the
StandAloneSig table. The managed ilasm wrote 0 for every method; native
ilasm writes RidFromToken(m_LocalsSig).

When EntityRegistry writes a method body it now records the local
signature handle that the body references in the method's debug
information, and EncodeSequencePoints writes its row id, or 0 when the
body has no locals. With /FOLD, a method whose body is shared with an
earlier one gets the same handle, because the shared body's bytes include
the local signature token.

Tests: PdbDocumentTests checks the row id for two methods with different
local signatures (rows 1 and 2), 0 for a method without locals, the row
id of methods whose bodies /FOLD shares, and that a method with locals but
no .line directive has no blob at all. A seeded theory checks that the
blob's LocalSignature equals the body's for every generated method with
sequence points, and that abstract methods have no body.
CommandLinePdbDocumentTests runs the ilasm command line in process, as
CommandLinePdbFileTests does, and reads the image and PDB it writes. It
covers what the command line decides and the library tests cannot see:

- Without any .line directive, the PDB has one document: the input file,
  named by its full path.
- An input given by a path relative to the current directory is named by
  the full path it resolves to. The test reads the current directory and
  never sets it.
- Two input files are two documents, by full path, in command-line order,
  before the document of a .line directive in the second.
- The TestDocuments2 case of the runtime's PortablePdb tests: '' on a
  method's first .line keeps the previous method's document, so both
  methods' points are in the named file and the second method does not
  span documents.

PortablePdbTestReader gets an Open(imagePath, pdbPath) factory beside
Compile, so its helpers serve the file-based tests too. MethodTests uses
it for the PDB side of the entry point:

- The Portable PDB header's entry point is the MethodDef of the method
  that carries .entrypoint, not the first MethodDef, and is nil when no
  method carries it.
…d narrow the test reader's blob check

BuildPdbMetadata now quotes docs/design/specs/PortablePdb-Metadata.md where it
writes a method's MethodDebugInformation row: the Document column names a
document only when every sequence point of the method is in it, and otherwise
it is nil and the sequence points blob carries the InitialDocument and the
document-records. The comment on the branch for methods without an IL body is
removed; the method's remarks already state that rule.

In the tests, PortablePdbTestReader.ReadBlobRecords, which returned every
record of a blob, becomes AssertNoDocumentRecordInSequencePointsBlob, the one
check its only caller needed. HiddenPoint_HasNoDocumentRecordAndBelongsToTheCurrentDocument
used to compare the whole record list with point@0, hidden@1, point@2; it now
asserts that the blob has no document-record, and its assertions on the
points' hidden flags and documents are unchanged. A new test checks that the
assertion fails for a method whose blob does have a document-record.
…e CodeView entry

The PDB names each input file by its full path, and without --deterministic
the image's CodeView entry records the full path of the PDB, so the output
depends on where the tree is. --pathmap (-PATHMAP=...) maps path prefixes in
both, so that with --deterministic, which already records only the PDB's file
name, the same tree assembled in two places gives byte-identical image and
PDB files.

The rules are the C# compiler's -pathmap. The value is a list of
<path>=<sourcePath> entries separated by ',', where ',,' and '==' stand for
the character itself; an entry without exactly one '=', or with an empty side,
is an error that names it, and an empty entry is skipped. Each path and
replacement that does not end with '/' or '\' gets the separator it already
uses (the platform's when it uses neither or both), so an entry matches whole
leading directories only, and the first entry that matches a path wins, with
the result's separators normalised to the replacement's when it uses only one
kind. The option can be given more than once and the entries apply in order.

Options.PathMap holds the map. PdbDocumentTable maps every document name
before it looks it up, so two names that map to one name are one document; this
covers the input files and the .line and #line file names. GetPdbFilePath maps
Options.PdbFilePath for the CodeView entry. Files are still read and written at
their real paths, and diagnostics still name them by those paths. An invalid
--pathmap fails the run before any file is read or written.

Tests: PathMapTests for the parsing and mapping rules; PdbDocumentTests and
CompilerOptionsTests for the documents and the CodeView path; and
CommandLinePathMapTests, which run the command line in process: the same source
in two directories, each mapped to /_/, gives identical files, both option
forms are accepted and accumulate, a malformed value fails and writes nothing,
and the PDB is written at its real path. CommandLineTests' native
value-option data gains PAT. No existing expectation changed.
The three-letter options that NativeCommandLine recognises exist only for
compatibility with the command lines of native ilasm, which has no path-map
option, so --pathmap gets no -PATHMAP form. The table entry is removed, with
the command-line tests' data rows for it and the test of the native form; the
remaining command-line tests pass the option as --pathmap <value>. A row in
UnrecognizedNativeOption_Throws checks that -PATHMAP= is rejected like any
other unknown native option.
@pcshrosbree
pcshrosbree force-pushed the ilasm-goodwill/pdb-documents branch from 8e01ead to aec8b70 Compare October 11, 2026 00:42

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-ILTools-coreclr community-contribution Indicates that the PR has been added by a community member

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants