Repository navigation
Managed ilasm: record sequence points as instructions are emitted, on the lines of the .il source when no .line directive is in effect - #135312
Open
pcshrosbree wants to merge 20 commits into
Conversation
|
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. |
Contributor
|
Tagging subscribers to this area: @JulieLeeMSFT, @dotnet/jit-contrib |
This was referenced Oct 6, 2026
pcshrosbree
force-pushed
the
ilasm-goodwill/implicit-sequence-points
branch
from
October 7, 2026 16:13
2f7cfb6 to
22a31f5
Compare
pcshrosbree
force-pushed
the
ilasm-goodwill/implicit-sequence-points
branch
5 times, most recently
from
October 9, 2026 20:23
8e48652 to
410f012
Compare
With /DEBUG or /PDB, the managed ilasm embedded the Portable PDB in the image and named "assembly.pdb" in the CodeView entry. Native ilasm writes the PDB as a separate file named after the output, and records the full path of that file in the CodeView entry. Match native ilasm: - The assembler returns the serialized PDB in CompilationResult.PortablePdb instead of embedding it, and the CLI writes it to <output>.pdb beside the output. - The CodeView entry names the PDB path given in the new Options.PdbFilePath. The CLI passes the full path of <output>.pdb. Library callers that do not set it get OutputFileName with its extension replaced by .pdb, or assembly.pdb when there is no output file name. - A PdbChecksum entry follows the CodeView entry. It holds the SHA-256 hash of the PDB with its 20-byte id zeroed, which is the hash the PortablePdbBuilder id provider computes, so the deterministic id and the checksum come from one hash. - With /DET, a Reproducible entry follows, as native ilasm and the C# compiler write it. - An image built without a PDB has no debug directory. ManagedPEBuilder adds a Reproducible-only directory to a deterministic image when it is given none; the new ILAssemblerPEBuilder suppresses that, for the standard and the vtfixup/export builders. The CLI's file output moves into OutputWriter, which the tests compile: - The image is written and closed first. The PDB is then written to a new temporary file in the same directory and renamed over <output>.pdb, so <output>.pdb never holds a partial PDB. - When the image is written without a PDB, an existing <output>.pdb is deleted only if it belongs to the image that was replaced: that image names the PDB's id in its CodeView entry. Any other file is kept. When the assembly fails and produces no image, nothing is written or deleted; with /ERR, an image written despite errors is handled as after a successful assembly. Native ilasm deletes <output>.PDB whether or not it belongs to the replaced image, and also when the assembly fails, and on Linux that is a different file from .pdb; that can remove a PDB that ilasm did not write, so it is not reproduced. - An output named like its PDB (-OUTPUT=Min.pdb with /DEBUG) is refused instead of being overwritten by the PDB. /PDB still produces only the PDB: it adds no DebuggableAttribute and leaves the JIT settings unchanged. Tests now pin that. The README documents the PDB behaviour, and the doc comments state the DebuggableAttribute modes correctly: /DEBUG=IMPL is 0x103, Default | IgnoreSymbolStoreSequencePoints | DisableOptimizations, and does not enable Edit and Continue. Tests: the unit tests read the PDB from the compilation result and pin the debug directory entries and their order, the CodeView path and id, the checksum, deterministic output and the output writer's file handling, including which PDB it deletes and that it closes the image before touching the PDB. Seeded theories check the same rules over generated programs and option combinations, and the output writer over an enumeration of output names and pre-existing output and PDB states. src/tests/ilasm/PortablePdb/IlasmPdbFileTester.cs runs native and managed ilasm end to end. Its managed cases are conditional on CORE_ROOT having the managed ilasm, which is built only where the SDK tools are; cases native ilasm does not satisfy run against the managed ilasm only.
The managed ilasm produced a PDB whenever the source had .line directives, even without /DEBUG or /PDB. Native ilasm produces a PDB only when one of those switches is given. Match native ilasm: a PDB is produced only with --debug, --debug-mode or --pdb. Without them the image has no debug directory, with or without --deterministic, and the .line directives are still parsed and validated but their sequence points are not emitted. --debug-mode alone counts as --debug, as it already did for the DebuggableAttribute. Tests that relied on the implicit PDB now pass Debug = true. New unit tests pin that .line alone produces no PDB, that --debug-mode alone produces one, and that an image without a PDB has no debug directory and no debug data, deterministic or not, with or without .line. Seeded theories over generated programs and options pin that a PDB is produced exactly when a switch requests it and that an image without one has no debug directory and no debug data. IlasmPdbFileTester checks both ilasms end to end without a switch, with and without .line and /DET (the managed ilasm when CORE_ROOT has it). The README and the doc comments say which switches produce a PDB.
…rip harness The "Test PDB determinism" step assembled with -DEBUG to -output=<name>.pdb and hashed that file. With native ilasm the PDB is <output without extension>.pdb, the same path, so the PDB overwrote the image and the step hashed the PDB. With the previous managed ilasm the step hashed the image, which had the PDB embedded, so it also checked that the image assembled with -DEBUG repeats. The managed ilasm now writes the PDB beside the output and refuses an output path the PDB would overwrite, so the step failed with "ILASM failed with exit code 1" in the managedilasmroundtrip scenario. Assemble to IL-RT/<name>.pdbcheck.dll instead and hash both that image and the PDB that ilasm writes beside it, IL-RT/<name>.pdbcheck.pdb, and fail the step if either differs between the two runs. Native and managed ilasm both write that pair, so the step checks the -DEBUG image and the PDB for both again. The step also no longer overwrites the test's own <name>.pdb, the C# compiler's PDB, in the test directory. The rest of the round trip is unchanged.
… path-based type in ilasm OutputWriter moves to the ILAssembler library and works on streams only: it writes and closes the image, then writes the PDB, or, when no PDB was produced, deletes the existing PDB only if the replaced image's CodeView entry names its id. It refuses to write a PDB over the output. The caller supplies the output and the PDB through the new IOutputStreams interface, so every decision can be tested with in-memory streams. OutputFileWriter in the ilasm tool backs those streams with files: it resolves <output>.pdb, opens the existing image and PDB, creates the output, writes the PDB to a temporary file in the same directory and renames it into place, and deletes a stale PDB. Program calls it. The behaviour of the tool is unchanged. The tests that need no file system now run on streams in OutputWriterTests; OutputFileWriterTests keeps the path, temporary file, rename, deletion and same-file tests, and the seeded output-writer theories run against OutputFileWriter.
The end-to-end PDB tests that ran the managed ilasm as a process from src/tests/ilasm now run the command line in process in ILAssembler.Tests (CommandLinePdbFileTests): Program.cs is linked into the test project, as the command-line parsing already is, and each test assembles inline IL sources into its own temporary directory. They cover the PDB beside the output and its CodeView entry, the debug directory order, the checksum, no PDB without a switch (with and without .line and -DET), the DebuggableAttribute, stale-PDB replacement and deletion, an unrelated PDB kept, failed builds, -ERR, the refusal of an output named like its PDB, and byte-identical -DET repeats. The rows that ran native ilasm as a control are dropped; native ilasm is not part of ILAssembler.Tests. src/tests/ilasm is back to what it was before this change.
… PDB ids The IOutputStreams remarks now say exactly when OutputWriter.Write calls each member: OpenExistingOutput only when no PDB is produced and the PDB path is not the output path; CreateOutput unless the PDB would overwrite the output; WritePdb when a PDB is produced; otherwise OpenExistingPdb only when the replaced output yielded a PDB id, and TryDeletePdb only when that id equals the existing PDB's id. The stream test that keeps a PDB the replaced image does not reference again asserts its precondition, which the file-based test had before the split: the replaced image's CodeView entry and the PDB at the PDB path carry different ids. The ids are read with PEReader and MetadataReaderProvider rather than the writer's own readers. The two pairs differ by construction: they are compiled deterministically from different sources, and a deterministic PDB id is a hash of the PDB.
…ests that cannot run as skipped Review feedback on the output file writer: - IsOutputPath compared the output and PDB paths as strings, so an output that is a symbolic link to <output>.pdb was not recognized: the image was written through the link into the PDB's file, the PDB was then renamed over it, and the image was lost although the run reported success. IsOutputPath now also follows the chain of symbolic links that starts at the output path (relative targets against the link's directory, dangling targets included, at most 40 links) and compares each target with the PDB path, so such an output gets the existing "would overwrite the output file" refusal. A missing path counts as no link; any other failure to read a link propagates, so an output that cannot be inspected is not written. A link at the PDB path and a hard link between the two names need no check, because the PDB is renamed into place rather than written through either name; tests pin both. - The rename test returned early on Windows under a plain [Fact], so it was reported as passed there without running. It now uses [ConditionalFact] and is reported as skipped on Windows. The new symbolic-link tests are conditioned on whether the process can create a symbolic link, so they run on Windows where that is permitted and are reported as skipped otherwise; the hard-link test runs everywhere. The test project references Microsoft.DotNet.XUnitExtensions for the attribute, as the coreclr tool test projects do.
The review asked that a deterministic build not record the PDB's full path, in the way the native linker's /PDBALTPATH:%_PDB% does, and that a non-deterministic build keep it. The image's CodeView entry now names the PDB's file name and extension (Min.pdb) with --deterministic, and its full path otherwise. The PDB file is still written beside the output in both modes. Native ilasm records the full path in both modes, so the deterministic rule is a deliberate difference from it. So a deterministic image no longer depends on the directory it is written to: the same input assembled to two directories gives byte-identical images, as it already gave byte-identical PDBs. The command line makes the choice, because it is the one place that knows the PDB's full path; the assembler records Options.PdbFilePath as given. New command-line tests pin, under -DET: the file name with -DEBUG and -PDB; the file name for a relative output path (reported as skipped where no relative path reaches the temporary directory); that System.Reflection.Metadata finds the PDB beside the image from that name, with the matching id; and identical images and PDBs across two output directories. The existing full-path test now states that it covers builds without -DET. The README and the doc comments state what is recorded in each mode.
The README is for build instructions, not for the tool's features, so this restores it to main's text. The behaviour it described is stated in the doc comments of the options, which are unchanged.
…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.
…ical block
The managed ilasm appended every .locals declaration as the next slot of the
local signature and dropped the [n] of `.locals ([n] type name)`. It also
added the names declared in a nested { } block to the enclosing scope's names
when that scope had a .locals of its own, so an inner declaration of an outer
name resolved to the outer slot, and inner names stayed visible after the
block. Both change the IL of the body.
Locals now go through a per-method slot table, as in native ilasm's
EmitLocals: a declaration without [n] takes the slot after the highest one;
[n] takes slot n, padding the table up to it; the local signature has one
entry per slot in slot order. Redeclaring a slot that is in use reports
"Local var slot n is in use" (warning); as in native ilasm, a slot is in use
from a declaration until any scope that declared it closes. Redeclaring a
slot with another type reports "Local var slot n: type conflict", and the
slot takes the new type; a slot that no declaration types reports "Undefined
type of local var slot n in method m" and is written as int32. [-1] means no
explicit slot, as in native; an explicit slot outside the 16-bit range is
reported as written and the local takes the next slot, and a local whose next
slot would be past 65535 is reported and not declared. The range is checked
on the literal as written, so a literal too large for 32 bits is reported
too, rather than narrowed to a slot in range or to -1. A ... sentinel
standing alone in a .locals list no longer takes a slot, as in native ilasm.
On parameters, [n] keeps its meaning as a raw attribute value.
Each { } block, including .try, catch, filter, finally and fault bodies, now
opens a name scope that closes with it. A name resolves to the first
declaration of that name in the innermost open scope that declares it; names
are case-sensitive.
Tests: unit tests for each rule, and seeded generated methods with nested
blocks, explicit, padded and reused slots and shadowed names, checked against
a model of native ilasm's rules.
The managed ilasm wrote no LocalScope or LocalVariable rows, so debuggers saw
no local names. Each lexical scope of a method body (the body itself and each
{ } block, including .try, catch, filter, finally and fault bodies) is now
recorded when it closes, with its IL offsets and the named locals it declares,
and the PDB gets the rows native ilasm writes: one LocalScope per scope that
declares a named local, spanning the offsets at its { and } (the whole body
for the method's own scope), followed by one LocalVariable per named local
with the local's slot as Index and attributes 0. Unnamed locals get no row.
The scopes are recorded whenever a PDB is requested, by any of the switches
that request one.
The rows are sorted by start offset, then longest first, as the table
requires; scopes with the same range keep source order. Two cases where
native ilasm writes rows the specification does not allow are left out: a
scope without instructions (zero length), and a second variable with the
same name or index in one scope. Within a scope, each name gets a row at the
slot of its first declaration, the declaration the name refers to, unless an
earlier row of the scope already describes that slot; later declarations of
a name, and unnamed locals, take no part. So a row never describes a local
that its name does not refer to. Import scopes and local constants are not
recorded, as in native ilasm.
Tests: LocalScopeTests and LocalTests, and seeded generated cases that compare the
local signature, named references and LocalScope/LocalVariable rows with a model
of native ilasm's rules.
… no .line directive is active With a PDB switch (-DEBUG in any mode, or -PDB), native ilasm records a sequence point when it emits an instruction: on the instruction's own line of the .il source, columns 1 to 2, when no .line or #line directive is in effect, and with the coordinates of the directive in effect otherwise. An instruction gets a point only when its coordinates differ from those of the last point recorded, or a directive has been applied since. The managed ilasm recorded points only when a .line directive was applied, so a .il file without .line directives had a PDB with no sequence points, a .line after a method's last instruction gave a point at the end offset, and a class-level .line gave the next method's first instruction no point. Points are now recorded in StartInstruction, which every instruction passes through before its bytes are written. A .line or #line directive only updates the state of the source it appears in (the input file, or one inclusion of an #include'd file): its coordinates and, when it names one, its file. That state lasts to the end of the source, across methods, as in native ilasm; an included file starts without a directive and does not change the state of the file that includes it. The last point is compared across methods, as native ilasm does, so a later method without a .line of its own gets no point while an earlier directive's span is still current. An instruction produced by a #define macro is on the line where the macro is used. An included file becomes a document when one of its instructions gets a point, and the CLI names included files by full path, as native ilasm does. Five differences from native ilasm are listed in MANAGED-ILASM-FIXES.md. A reference operand with a syntax error emits nothing and records no point. A point recorded at the offset of the previous one replaces it, so the sequence points blob never holds two points at one offset. Without a PDB switch no points are recorded. Changed expectations: - CompilerOptionsTests.PdbOption_ProducesPortablePdbWithoutLineDirectives asserted only the single input document and a non-empty MethodDebugInformation table; it now also asserts the method's document and its two points on lines 6 and 7, columns 1 to 2. - The PdbCaseGenerator document model recorded points when directives were applied and did not model the lines of the .il source; it now records them at instructions, with implicit points, instructions sharing a line, one- and two-byte instructions, blank and comment lines, directives after the last instruction, and methods or runs of instructions in included files. - PdbDocumentTests.MethodWithLocalsButNoLineDirective_HasNoBlob is unchanged except for a comment saying why the method has no points.
…embler.Tests CommandLineSequencePointTests runs the ilasm command line in process with -DEBUG, as CommandLinePdbFileTests does, and reads the PDB it writes, for what only the command line establishes: - An input without a .line directive has its points on the instructions' own lines of the file, columns 1 to 2, with two instructions on one line sharing a point, in a document named by the input's full path, when the command line gives a path relative to the current directory or a full path with a ".." segment. - A .line directive after an implicit point makes the method span the input's document and the file it names, and the method's MethodDebugInformation row names no document. - The instructions of an #include'd file are in a document named by the included file's full path, and the including file's later instructions are back in the input's document. The include path is relative to the current directory (read, not set).
pcshrosbree
force-pushed
the
ilasm-goodwill/implicit-sequence-points
branch
from
October 9, 2026 21:10
410f012 to
5f62a5e
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of making the managed ilasm (
src/tools/ilasm) a drop-in replacement 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.Depends on #135311 (local scopes), which depends on #135297 (PDB documents) and #135289 (the separate
.pdbfile). This branch is stacked on all three: the first eighteen commits here are theirs and will drop out of this diff as they merge. The two commits of this PR are the last two: the change with its unit and generated tests, and the command-line tests.With
-DEBUG(in any mode) or-PDB, native ilasm makes the.ilfile itself debuggable: when it emits an instruction that no.linedirective covers, it records a sequence point on that instruction's line of the.ilsource, columns 1 to 2 (SetupInstr,EmitOpcode). The managed ilasm recorded points only when a.linedirective was applied, which gives these differences:.ilsource. Before: a.ilfile without.linedirectives had a PDB with no sequence points, so it could not be stepped through;TestLocalScopes4.ilhad 0 points against native's 36. After, as in native ilasm: each instruction is mapped when it is emitted, to its own line of the.ilfile it is in, unless a.lineis in effect. An instruction gets a point only when its coordinates differ from the last point's, so instructions on one line share one. The first instruction after a directive always gets a point..lineafter a method's last instruction. Before: a point at the method's end offset. After: no point in that method; the directive applies to the next instruction, as in native..lineat class or top level. Before: its file became the current document, but its line and columns were dropped. After: the next instruction, the first of the next method, gets them.#included files. Before: an included file was not a document, and a file named by a.lineinside it stayed current after the include. After: instructions in an included file are on its own lines, in a document named by its full path. An included file starts without a.line, and its.linedoes not apply after it.An instruction with a malformed operand emits nothing and records no point. A point recorded at the offset of the previous one replaces it, so the sequence points blob never holds two points at one offset, which a reader would misread as a document change.
Behaviour changes to be aware of:
.ilfile that is not fully covered by.linedirectives now has a point per instruction line.TestLocalScopes4.il's PDB grows from 548 to 732 bytes (native: 736). With--deterministic, the PDB, and through its id the image, now also depend on the.ilfile's line layout..linestays in effect to the end of its file, across methods, and the last point is compared across methods. A method without a.lineof its own, after one with a.line, maps to that directive's coordinates, and gets no point at all while that directive's span is the last point's. That is the shape ildasm writes for a method that had no sequence points, when it writes.linedirectives at all. I could not run that round trip here: ildasm on Linux writes no.linedirectives.Deliberate differences from native, five of them listed in
MANAGED-ILASM-FIXES.md:#include, the including file keeps its.linedirective and that directive's file. Native makes the including.ilthe current file again but keeps the directive's line and columns, which then point into the.ilfile.#includebefore a.languagejust before it has been applied.#definemacro is on the line where the macro is used. Native reads the macro's text as a new source starting at line 1, so such instructions are mapped to line 1 and share one point.A method that ends up with no sequence points keeps a nil
Document, as in the document change (#135297) and as the spec says; native names the current document. With the cross-method rule above this is now a common shape.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:
#line's line auto-increment (parsed, not applied; native's own implementation is broken); end-column defaults for a partly specified.line(#46328); validation of sequence points, including0xFEEFEEstart lines without0,0columns, which this change treats as hidden and native rejects.Questions:
.linelasts to the end of the file across methods, so that a later method without its own.linemay get no point, is kept for parity. Would you rather reset per method, so that every method's first instruction gets a point?Testing:
SequencePointTests, read back with System.Reflection.Metadata:.linebefore the first instruction or in the middle of a body; the instruction after a.linegetting a point even with the same coordinates; a.lineafter the last instruction and a class-level.lineapplying to the next method; a later method without a.linegetting no point, as in native ilasm; hidden.line;.iland then to a.linefile having a nil Document and the.ilas InitialDocument;.linenot crossing an include in either direction;#definemacros;/OPTIMIZEoffsets and/DETidentical PDBs;callisignature,ldtokenowner), both followed by another instruction and at the end of a body.PdbOption_ProducesPortablePdbWithoutLineDirectivesnow also checks the points of a-PDBcompile without.line..linedirectives before instructions, after the last one and at class level, methods without directives, and whole methods or runs of instructions in included files. The model computes every point (offset, lines, columns, document) from the source it writes; it follows native ilasm's rules with the differences listed above. Six theories (1,200 rows) compare the PDB with it, and a seventh check ensures each of 14 shapes occurs at least 20 times.CommandLineSequencePointTestsinILAssembler.Testsruns the ilasm command line in process with-DEBUGand reads the PDB it writes, end to end. It covers an input without.line, given by a path relative to the current directory (written under it, so the argument is never rooted, without setting the current directory) and by a full path with a..segment: points on each instruction's own line, columns 1 to 2, two instructions on one line sharing a point, all in a document named by the input's full path. It covers a.lineafter an implicit point, so the method spans the input's document and the named file with a nil Document. And it covers an#included file given by a path relative to the current directory: its instructions are in a document named by the included file's full path, and the including file's later instructions are back in the input's document. I broke each of these rules in turn and checked that the test fails.ILAssembler.Tests, I measured them as two extra cases in the runtime's PortablePdb tests (src/tests/ilasm), run against both ilasms on Linux x64, out of tree. All 65 rows of that run, native and managed cases together, passed; with the managed ilasm at Managed ilasm: honour explicit local slots, scope local names lexically, and emit LocalScope/LocalVariable rows #135311's head before this change, the case without.linedirectives failed. Those cases are not part of this PR.NoLine,MixedLineandEmptyDocNameinputs and forTestLocalScopes4,TestMethodDebugInformation,TestDocuments1andTestImplicitLines1. Documents, method documents and every sequence point are identical. On other inputs, the only differences I found are the ones listed above, including the nil Document of methods without points.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.