Build template models in Jint's hidden-class representation, prepare scripts once, and bound script execution - #11084
Open
lahma wants to merge 4 commits into
Open
Conversation
lahma
marked this pull request as ready for review
July 28, 2026 07:34
…scripts once, and bound script execution Four independent improvements to the Jint-backed template preprocessor, and the Jint bump they need: 4.6.1 to 4.15.3. Build model objects in the shape representation. ConvertObjectToJsValue used FastSetDataProperty per property, which stores a raw property descriptor and so builds each model as a property dictionary, forfeiting the shape inline cache. JsObject.CreateFromEntries drives the incremental hidden-class path instead, so the objects built for documents of one document type - which all share a property layout - share one interned shape and property reads in the template script stay monomorphic. The entries are streamed rather than materialized into a temporary array. Layouts the representation cannot express fall back to the dictionary representation silently and correctly. Parse template sources once. Templates and required modules were executed from text, so the preprocessor pool re-parsed every source once per parallelism slot. They are prepared once with Engine.PrepareScript and shared: a Prepared<Script> is immutable and safe to execute from several engines and threads. Bound script execution. The engine was created with no options at all, so an accidental infinite loop in a .tmpl.js pinned a render thread forever. A timeout, a statement budget and the build's cancellation token are now registered. The values are real rather than saturated, since a saturated value registers no constraint. Read transform model options directly. Template.GetOptions round-tripped the preprocessor result through JObject.FromObject().ToObject<T>() once per document per template to read two fields. Values that are not script-produced property bags still go through the converter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f
…odel representation Stop copying a function object between engines. require built each module's engine with CreateEngine(engine, "require"), which read the require function out of one engine and installed that same JsValue into another. A JsValue holds a hard reference to the engine and realm that created it and passing one across is not a supported arrangement. Each engine now registers require from the CLR delegate itself, which is the same delegate, the same module cache and the same resolution - only without the cross-engine value. The module exports object is still returned into the engine that called require; removing that needs the CommonJS wrapper-function change, which is an observable semantic change and is left for its own pull request. Register console and templateUtility lazily. Options.AddLazyGlobal defers building a global until a script reads the name. Most template scripts never mention either, and engines are not scarce here: the preprocessor pool builds one per parallelism slot and require builds one more per module. exports stays eager because it is read back off the engine whether or not the script mentions it. Assert the model representation. CreateFromEntries falls back to the property-dictionary representation silently, so without an assertion a later change could deoptimize every model unnoticed. The tests now pin both directions - a plain model reaches the hidden-class representation, an integer-index-like key set falls back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f
…call Constraints registered on module engines were never rearmed, because a module engine is never entered through a public Jint API. Jint resets an engine's constraints when the engine is entered through a public API such as Engine.Invoke, which covers the template's own engine. It does not cover the engines require builds: a module's exported function belongs to the module's engine, so common.transform(model) is an ordinary function call that charges the module engine's constraint instances without passing through any entry point that resets them. A timeout deadline is armed only on reset and a statement counter is only cleared on reset, so a module engine carried a deadline fixed at preprocessor construction - every document handled by a pool slot older than the 30 s timeout failed with a spurious TimeoutException - and a statement budget spanning the whole build. The engine cache moves from a SetupEngine local to a field, and GetOptions and TransformModel reset every engine in it before calling into the script. The constraints stay registered on module engines rather than being dropped from them: a runaway loop inside a module can only be bounded by the module engine's own constraints, since the template engine's are checked only while template-engine statements execute. Two tests pin both directions, both written red first. The stale-deadline test fails before this change with TimeoutException thrown from TimeConstraint.Check inside the module function's loop. They construct a preprocessor with a short timeout through the internal constructor rather than sleeping for 30 s, which is why Docfx.Build.Tests joins the InternalsVisibleTo list alongside Docfx.Build.Common.Tests. Neither asserts which of the two limits fires, since that is a race decided by machine speed. Also notes on the integer-index-like key test that its representation assertion pins a current Jint limitation, so it is expected to fail on a future Jint that shapes such keys - good news arriving as a red test rather than a docfx defect. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f
… added Two assertions about properties this PR depends on but could not previously state, both diagnostics-only and neither branched on in production code. Pin that no CLR array crosses the model marshal. Jint's default for CLR arrays became a live view in 4.14, and this PR moves docfx across that change. A live view writes through to the CLR array, so a model array reaching script that way would let a template mutate docfx's own model object. It does not happen, because ConvertObjectToJsValue builds every array itself through the JsArray ownership-transfer constructor. What keeps that true is narrower than it looks: model arrays qualify as IList<object> because ConvertToObjectHelper produces object[], so a model conversion that ever yielded a typed array such as int[] would fall through to Jint's converter and silently start handing out a live view. GetInteropConversionDiagnostics (4.15.1) makes it observable; the test asserts zero conversions under either mode and includes a control that a CLR array which does reach the converter is counted, so the zeros cannot pass vacuously. The control sums the two modes rather than naming one, keeping it independent of which is the current default. Pin the shared shape through the predicate meant for it. The claim this PR rests on is not merely that a model is shaped but that documents of one type share a layout, and the only way to check it was GetObjectRepresentation - explicitly not part of Jint's compatibility contract, so a host could not commit the check. HasSharedShape (4.15.3) is documented as stable for exactly that question and may be pinned for the documented success case of the factory called, so the positive assertion now uses it. The integer-index-like fallback keeps the finer-grained diagnostic, because naming the exact fallback representation is that assertion's whole point. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f
lahma
force-pushed
the
jint-model-marshalling-and-engine-hardening
branch
from
July 29, 2026 11:17
83f4a3a to
502fef9
Compare
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.
docfx's Jint usage lives in three files under
src/Docfx.Build/TemplateProcessors/Preprocessors/, plus one drive-by inTemplate.cs. This PR bumps Jint 4.6.1 → 4.15.3 and reworks that usage against the embedding surface 4.15 added.The version jump itself is large — 4.6.1 is from ~2026-03 — and carries a lot of interop and interpreter work plus spec-conformance fixes that docfx gets for free. Some of it is directly relevant to how docfx uses Jint: resolved CLR members are now cached process-wide on the shared
TypeResolverrather than per engine, CLR property/field access is compiled instead of reflected per hit, and host delegates no longer re-reflect their parameter metadata on every call. That last one is whatrequireand theconsole/templateUtilitymembers were paying.1. Build model objects in Jint's hidden-class representation
JintProcessorHelper.ConvertObjectToJsValuebuilt every model object with ajsObject.FastSetDataProperty(...)loop. Despite the name that is not the fast way to project host data into a new object: it stores a rawPropertyDescriptor, which is a dictionary-mode operation, so each model gets a property dictionary and a descriptor per property, and the object permanently forfeits Jint's shape (hidden class) inline cache.JsObject.CreateFromEntriesdrives the incremental hidden-class path instead. This matters specifically for docfx because every document of a given document type is handed to the template with the same model layout, so repeated calls presenting the same key sequence intern and share one shape — which is what keeps the property reads intransform/getOptionsmonomorphic across a whole docset rather than megamorphic.The
IEnumerable<KeyValuePair<...>>overload is used deliberately: the source is anIDictionarythat cannot be viewed as a span, and materialising a temporary array of the model's size just to call the span overload would cost the very allocation the shaped representation saves. The entries are streamed through a local iterator and walked exactly once.Key sets the representation cannot express — integer-index-like keys, objects past the shape property cap, excessive layout fan-out — fall back to the ordinary dictionary representation mid-build, silently and correctly. That fallback is exercised by the existing
TestJObjectConvertWithJTokentest (itsValueDictis keyed"1"/"key") and by a new test that pins the resulting own-key order.Because the fallback is silent, the tests assert it directly. The claim this rests on is not merely that a model is shaped but that documents of one type share a layout, and
Engine.Advanced.HasSharedShape(4.15.3) is documented as the stable predicate for exactly that question — one a host may pin for the documented success case of the factory it called. The positive assertion uses it. The integer-index-like fallback keeps the finer-grainedGetObjectRepresentation, which is deliberately not a compatibility contract, because naming the exact fallback representation is that assertion's whole point; it is annotated as expected to go red on a future Jint that learns to shape such keys — good news arriving as a red test, not a docfx defect.One behavioural risk the version jump carries, now pinned. Jint's default for CLR arrays became a live view in 4.14 — script writing to such an array writes through to the CLR array itself — and this bump crosses that change. It is inert for docfx only because
ConvertObjectToJsValuebuilds every array itself, via theJsArrayownership-transfer constructor, and never hands a CLR array to Jint's converter. What keeps that true is narrow: model arrays arrive asIList<object>(ConvertToObjectHelperproducesobject[], which qualifies), so a model conversion that ever yielded a typed array such asint[]would fall through and start handing script a live view onto docfx's own model object. 4.15.1'sEngine.Advanced.GetInteropConversionDiagnosticsmakes that observable, and a new test asserts a representative model crosses zero CLR arrays under either mode — with a control line proving the counters are live rather than vacuously zero.2. Prepare template scripts once instead of once per pool slot
Template sources and
required module sources were executed from text viaengine.Execute(source, path).PreprocessorWithResourcePoolcreates one preprocessor — and therefore one engine — per parallelism slot, so that was P parses of the template plus P parses of every module it requires.They are now parsed once through
Engine.PrepareScriptand shared across the pool: aPrepared<Script>is immutable and documented as reusable and thread-safe, and Jint re-derives any per-node cached state when a prepared script runs against a different realm. Preparation also attaches static analysis data and constant folding, so the shared copy executes slightly faster too.The cache is per template (created in
PreprocessorLoader.Loadand handed to each pooled instance), keyed by resource path — no process-wide static state, nothing that outlives a build. The existing publicTemplateJintPreprocessorconstructor is unchanged; aninternaloverload takes the shared cache.3. Give the preprocessor engines execution constraints
CreateDefaultEnginecallednew Jint.Engine()with no options at all — no timeout, no statement limit, no cancellation. Awhile (true) {}in a.tmpl.jsfrom the docset being built would pin a render thread for the lifetime of the process.Every engine the preprocessor owns now registers a 30 s timeout, a 50,000,000 statement budget, and the build's
CancellationToken(reachable viaDocumentBuildContext, and correctly absent when there is no context).TemplateJintPreprocessorresets them all at the top ofGetOptions/TransformModel, so they bound one document rather than the lifetime of a pooled preprocessor.That reset is not optional, and getting it wrong is not a subtle inefficiency — it is a build-breaking bug, so it is worth stating why. Jint resets an engine's constraints when the engine is entered through a public API such as
Engine.Invoke, and that covers the template's own engine. It does not cover the enginesrequirebuilds: a module's exported function belongs to the module's engine, socommon.transform(model)is an ordinary function call that charges the module engine's constraint instances without passing through any entry point that resets them. A timeout deadline is armed only on reset and a statement counter is only cleared on reset, so without an explicit reset a module engine would carry a deadline fixed at preprocessor construction — meaning every document handled by a pool slot older than 30 s would fail with a spuriousTimeoutException— and a statement budget spanning the whole build. Two tests pin both directions: a module call still succeeds after the preprocessor has been aged past its own timeout, and a runaway loop inside a module is still stopped (which is why the constraints stay registered on module engines rather than being dropped from them).The sample docsets keep every pool slot's active phase well under 30 s, so this failure mode is structurally invisible to the snapshot suite; the tests above are what cover it.
The values are deliberately real numbers: Jint treats saturated values (
MaxStatements(int.MaxValue),TimeoutInterval(TimeSpan.MaxValue)) as "remove the constraint", so spelling "effectively unlimited" that way would silently produce an engine with no limit. The statement budget is orders of magnitude above what transforming even a very large model costs; a runaway loop hits it in about a second.On cost: the timeout and cancellation constraints are amortized (checked at a bounded cadence, not per statement). The statement counter is exact, but because it is the only exact constraint registered here Jint charges it inline rather than disarming the interpreter's tight-loop lanes, so ordinary template execution is not slowed by having it. If you would rather not carry a statement budget at all, dropping that one line leaves the timeout doing the same job less deterministically.
A new test asserts that a runaway
transformfails the document instead of hanging.4. Register
requireper engine instead of copying a function object between enginesrequiregives every module its ownEngine, and built it withCreateEngine(engine, RequireFuncVariableName)— which read therequirefunction out of one engine and installed that sameJsValueinto another. AJsValuethat is anObjectInstanceholds a hard reference to the engine and realm that created it; passing one to a different engine is not a supported arrangement in Jint, and is neither validated nor made safe. It happened to work because aDelegateWrappermostly just forwards to its CLR delegate.Each engine now registers
requirefrom the CLR delegate itself. Same delegate, same module cache, same resolution — only without a value crossing an engine boundary.CreateEngineis gone.This does not finish the job: the module's
exportsobject is still created in the module's engine and returned into the engine that calledrequire, and the shipped templates then call functions off it —ManagedReference.html.primary.jsdoesoverwrite.transform(model)on a function that belongs to a different engine. Removing that needs the CommonJS wrapper-function change described under Follow-up, which is an observable semantic change and belongs in its own PR. This change removes the half that can be removed without one.Two notes on what remains. First, the value side of that crossing does hold together under 4.15: the member inline caches key on tokens carried by the object (shape identity, properties version) rather than on the reading engine, mutations route through the object's own engine, and the shared
TypeResolvercache is keyed by interop profile, which both engines share. What does not cross safely is execution-scoped state, and this PR introduces exactly that — which is why §3's reset has to walk every engine rather than relying on the entry point. Second, one small behaviour change falls out of registeringrequirefrom the delegate: a module engine'srequireis nowNonEnumerable, where copying theJsValuepreviously made it enumerable. It is observable only to a module script enumerating its own globals, no shipped template does, and the root and module engines now agree — but it is a behaviour change, so it is recorded rather than left implicit.5. Register
consoleandtemplateUtilitylazilyOptions.AddLazyGlobaldefers building a global until a script reads the name. Most template scripts never mentionconsoleortemplateUtility, and engines are not scarce in this design: the preprocessor pool builds one per parallelism slot andrequirebuilds one more per module — a bundle with 5 shared modules atMaxParallelism16 is 96 engines.The flags match:
AddLazyGlobal's default is the configurable/enumerable/writable combinationSetValue(string, object)produces, which is what these two used.exportsstays eager, because it is read back off the engine after the script runs whether or not the script ever mentioned the name.6. Drop the Newtonsoft round trip in
Template.GetOptionsGetOptionsdidJObject.FromObject(obj).ToObject<TransformModelOptions>()— once per document, per template — to read two fields off a value the script engine already handed back as a property bag. It now readsisSharedandbookmarksdirectly, matching names case-insensitively as the converter did. Anything that is not a property bag (a preprocessor that is not script based) still goes through the converter, so nothing that worked before stops working.Jint 4.15 feature audit
Everything 4.15.0, 4.15.1 and 4.15.3 added that could plausibly apply here, and what was done with it. 4.15.2 is absent because it adds no API — it was a fix release (async/generator suspension state, bound-of-bound functions, inherited-accessor receivers, a built-in shape probe fast-miss). The version notes name the release each item landed in, not the pin.
JsObject.CreateFromEntriesEngine.Advanced.HasSharedShape(4.15.3)Engine.Advanced.GetObjectRepresentationAppContextswitchJint.EnableHostContractVerification(4.15.3)TryGetOwnPropertyValue(OwnValueHook), the built-in-shape probe, anOrdinarysemantics claim,ArrayLikeObject.HasIndex— and docfx implements none of them; its models are plain engine-owned objects with no host contract to contradict. Enabling it would verify nothing while reading to a future maintainer as though it did. Worth adding the day docfx gains its first host object.Engine.Advanced.AddLazyGlobal(post-construction) (4.15.3)require, which is the one nearly every shipped template does use, so deferring it would rarely pay; the two that genuinely often go untouched,consoleandtemplateUtility, are already deferred through the options-time API.Options.AddImmutableCrossing(4.15.3)ObjectWrapper, and docfx's hot traffic — the models — deliberately does not cross as wrappers at all. The only wrapped objects areconsoleandtemplateUtility, read a handful of times per document at most and now lazy besides, and declaring compiler-generated anonymous types by runtimeGetType()would be a fragile registration for that.PropertyDescriptorCreateLazy(4.15.3)PropertyDescriptors; it hands Jint values, not descriptors.Engine.Advanced.WithRestoredGlobals(4.15.3)Options.AddLazyGlobalEngine.Advanced.GetInteropConversionDiagnostics(4.15.1)requirefunction no longer crosses; the moduleexportsobject still does, pending the follow-up.TypeResolvermember cache, compiled property/field access, delegate metadata caching, built-in fast-call lane, constraints no longer disarming the tight-loop laneconsole/templateUtility/requirecheap enough that reworking them by hand is not worth it — seeJsObjectShapebelow.JsObjectLayout+JsObject.CreateCreateFromEntrieswhen the key set is known before the loop. docfx's is not: the model arrives as anIDictionarywhose keys are data-driven, so a layout could only be found by a lookup keyed on the very key sequence you would have to walk to build it, and the values would have to be materialised into an array first.CreateFromEntriesinterns the same shape across documents by transition, without either cost.JsObjectLayout.CreateBuilder().AddLazy(...)— lazy slots (4.15.1)ConvertObjectToJsValuerecurses eagerly over the whole model graph, so a template reading only part of a large model pays for the rest. But lazy slots are declarable only on a layout, and Jint states the boundary directly — "CreateFromEntriesis deliberately not extended: lazy members are declared on a layout, and a runtime key set has nowhere to declare them." docfx's key sets are runtime-derived, so the factory it can use is precisely the one that cannot carry lazy slots. Worth noting the value would be limited anyway: the CLR-side values are already materialised before Jint sees them, so a factory could only defer the conversion, and the shipped templates iteratemodel.children— the large part — on every document.JsObjectShapeconsole/templateUtility. Three reasons not to: a shape'sMethoddelegates takeJsValuearguments, so converting would change host-boundary argument coercion on a surface third-party templates depend on;templateUtilitycloses over per-build state and so cannot be the process-sharedstatic readonlyshape the API targets; and the per-call reflection that motivated the idea is already gone with the bump.Engine.Advanced.CaptureGlobalSnapshot/RestoreGlobalSnapshotPrepared<T>.ReferencedGlobalsrequirehas to be installed on every module engine transitively.JsonParser.Parse(ReadOnlySpan<char>)/(ReadOnlySpan<byte>),JsonSerializer.Serialize(…, IBufferWriter<byte>)transformresult feeds the Mustache renderer, not a serializer. 4.15.1's added docs reinforce the skip rather than change it: anIBufferWritercaller who wants a string re-pays the transcode the overload exists to avoid, and the string overloads returnJsValue.Undefinedfor a value with no JSON representation — soSerialize(v).ToString()would silently yield the literal text"undefined". Neither trap is reachable from here.Engine.Advanced.GetPropertyAccessSemantics(4.15.1)PropertyFlag.NonWritable/OnlyConfigurable(4.15.1)AddLazyGlobalis taken at its default, which remainsConfigurableEnumerableWritable, the combinationSetValue(string, object)produces.ObjectInstance.GetOwnPropertiesrouting docs (4.15.1)GetOwnPropertiesships keys no script-visible enumeration can see. docfx overrides nothing, so the gotcha cannot bite; the method is on its path only as a caller (CLR conversion, i.e. theToObject()on every transform result), which is unchanged.ArrayLikeObjectJsArrayonce — whichConvertObjectToJsValuealready does, using the ownership-transfer constructor — is what that API's own guidance recommends instead.AddObjectConverter(converter, params Type[])Options.Interop.EnumConversion/EnumConversionModeNullPropagatingReferenceResolver+ReferenceResolverInterestsTypeErroron a nullish base intoundefinedfor every template in the ecosystem. That is a language-semantics change for third-party template authors, not an optimization.PropertyAccessSemantics,TryGetOwnPropertyValue,ProbeOwnProperty,JsObjectShape.SetHostStateObjectInstancesubclass, and no custom Jint type at all.Not done here — follow-up
requirestill creates a whole extraEngineper module, and the module'sexportsobject — created in the module's engine — is still returned into the calling engine's script, which then calls functions off it. §4 removed the other half of that (therequirefunction object no longer crosses), but the object graph still does.The conventional CommonJS shim — wrapping the module source in
(function (exports, require, module) { ... })and executing it in the same engine, with the exports object cached before the body runs so the existing "unfinished copy" cycle behaviour is preserved — would remove both the extra engines and the remaining cross-engine values. It is left out of this PR because it is an observable semantic change (module code would see the host engine's globals, and reassigningexportsrather than mutating it would behave differently), which deserves its own PR and its own discussion.Measured effect
BenchmarkDotNet, default job,
[MemoryDiagnoser], .NET 10.0.10, x64. 64 ManagedReference-shaped models per operation throughTemplateJintPreprocessor.TransformModel— a deep marshal in, the JS call, and a deep unmarshal back out, once per document. BEFORE is this PR's merge base (2a436495ed, Jint 4.6.1); AFTER is the PR tip (Jint 4.15.1). Both sides ran back to back on an otherwise idle machine from identical harness sources.required moduleThe pin has since moved to 4.15.3; the table above was measured at 4.15.1 and has not been re-run, since neither later release changes a default execution path (4.15.2 was fixes, 4.15.3 additive API) and re-measuring would only add noise.
StdDev was ≈0.6 % of the mean on every row, so the time deltas are several times the run-to-run spread; the allocation figures are deterministic. Gen1 collections roughly halved (15.6 → 7.8 per 1 000 ops on the first lane, 19.5 → 7.8 on the second), which is the property-descriptor-per-property churn going away.
Four honesty notes:
CreateFromEntriesdoes". Four minor versions of engine and interop work are in the same number, and none of it is separated out here.requirelane exists for fairness, not decoration. §3's constraint reset is per-call work proportional to the number of engines a preprocessor owns, and a template with no modules owns exactly one — the cheapest possible case for the AFTER side. Every shipped template usesrequire, so that lane is the representative one. It is also where the older arrangement was relatively worse: BEFORE, adding a module cost ~2 % (2.078 → 2.118 ms) because the module engine re-parsed its own source and a function object was copied across the engine boundary; AFTER, the two lanes are indistinguishable.Prepared<Script>across a pool is a construction-time effect that only materialises across concurrent pool rents, which is not what this benchmark exercises. Treat §2 as unmeasured here rather than as measured-and-small.Verification
jint.4.15.3.nupkgrecordscommit="a304aa5dac…", which is what thev4.15.3tag onsebastienros/jintpoints at.Docfx.Build.Tests, Release,net10.0: green (119 passed). Ten new tests cover this area — own-key order and nesting; zero CLR arrays crossing the model marshal, with a live-counter control; models sharing a layout, plus the hidden-class representation; the integer-index-like key fallback, plus the dictionary representation;getOptionsreading; a chainedrequire(a template requiring a module that itself requires another, which is the shipped-template pattern);consolereaching the logger through the lazy global; the runaway-script guard; a module call surviving a preprocessor aged past its own timeout; and a runaway loop inside a module still being stopped. The last two were written red first — before the per-call reset, the aged-preprocessor test fails withTimeoutExceptionthrown fromTimeConstraint.Check()inside the module function's loop.Docfx.Build.Testsis grantedInternalsVisibleTo(followingDocfx.Build.Common.Tests, already in that list) so the two constraint tests can construct a preprocessor with a short timeout instead of sleeping for 30 s. The timeout is an optional parameter on the existinginternalconstructor; the public constructor and the default are unchanged.docfx.Snapshot.Testsis the important gate for a change to model marshalling and torequire, since it renders the shipped templates —requirechains and all — over the sample docsets. Because those testsAutoVerifylocally, the suite was run repeatedly on the same machine, with and without the changes, and the resulting verified trees compared by content hash: 784 of 785 rendered snapshots are byte-identical across every pair. The one exception,BuildFromProject.SourceGenerator.html.view.verified.json, carries git-remote and absolute-path detection that varies between runs; re-running at unchanged source reproduced both of its observed contents, so the variation is environmental rather than behavioural. (Comparing against the committed baseline instead is not meaningful — a local run legitimately differs from the CI-produced files in_key/_tocKeypaths, which is why the comparison is run-to-run.) The suite's blind spot is anything depending on wall clock or cumulative scale, which is exactly the constraint-reset failure mode above; the two unit tests cover that instead. It was re-run for the 4.15.1 and 4.15.2 pin moves — both trees byte-identical to their predecessor. 4.15.2 earned its run because two of its fixes changed existing property read and probe lanes and docfx's models are shape-mode receivers read on every document. 4.15.3 was not re-run, and deliberately so: all seven of its commits are additive API or documentation, every new behaviour is opt-in, and none of them alters an execution path docfx is already on.dotnet format --no-restore --verify-no-changes) fails ontest/Docfx.Dotnet.Tests/SymbolUrlResolverUnitTest.cs(5,1): IDE0005. A clean worktree ofmainat this PR's merge base reproduces it exactly, and PRs chore(deps-dev): bump js-yaml from 4.1.1 to 4.2.0 in /templates #11081, chore(deps): bump dompurify from 3.3.1 to 3.4.11 in /templates #11080, chore(deps): bump esbuild and tsx in /templates #11077 and chore(deps): bump uuid and mermaid in /templates #11073 all fail Lint the same way.Docfx.Dotnet.Testsreferences onlyDocfx.Dotnet, neither of which this PR touches;dotnet format --verify-no-changeson the two projects it does touch exits 0. The repo pins noglobal.json, so this looks like SDK/analyzer drift on the runner image. Left out of this PR deliberately rather than smuggled in; it is sent separately as chore: remove unnecessary using to fix the Lint check #11085, which turns the whole-solutiondotnet format --verify-no-changesgreen again. This PR's Lint check should pass once that merges.XRefArchiveBuilderTest.TestDownload, which downloadshttp://dotnet.github.io/docfx/xrefmap.yml— a network flake. Every new test in this PR passed on that runner (Docfx.Build.Testsreported 117 passed / 1 failed / 3 skipped of 121, the one failure beingTestDownload), anddocfx.Snapshot.Testswas green there.Docfx.Build.ManagedReference.Tests,Docfx.Build.RestApi.Tests,Docfx.Build.RestApi.WithPlugins.Tests,Docfx.Build.SchemaDriven.Tests,Docfx.Build.UniversalReference.Tests,Docfx.Build.OverwriteDocuments.Tests,Docfx.Build.Common.Tests, anddocfx.Tests(115/115).🤖 Generated with Claude Code
https://claude.ai/code/session_016uV6H9cTntzsoKiaJRBn4f