Skip to content

Harden the Native AOT release path from the #1324 review - #1344

Merged
nickna merged 6 commits into
mainfrom
feat/aot-review-hardening
Aug 1, 2026
Merged

Harden the Native AOT release path from the #1324 review#1344
nickna merged 6 commits into
mainfrom
feat/aot-review-hardening

Conversation

@nickna

@nickna nickna commented Jul 31, 2026

Copy link
Copy Markdown
Owner

Summary

An architectural review of the #1324 epic (before the first tagged release) confirmed the design is sound but found a handful of concrete defects and unenforced guarantees. This PR fixes all of them.

Release blockers

  • Compile errors print to stderr. The generic compile-error branch in Program.cs and the explicit-bundler failure branch wrote to stdout. The tag-time macOS native smoke greps stderr for PE-Packer's Mach-O refusal (cannot target), so the first osx-* release job would have failed on a stream mismatch. One test asserted the old stdout behavior and was updated to the new contract.
  • publish.yml now validates before it publishes. Previously the publish job pushed NuGet and created the GitHub Release first, and the artifact matrices merely uploaded afterwards — a failing RID left a silently missing asset behind an already-public package. New shape: build (pack + test) → binaries + native-binaries (all twelve artifact smokes) → tag-only release job that pushes NuGet and attaches every archive. Also:
    • workflow_dispatch runs the full matrix as a no-publish dry run (0.0.0-dryrun.N versions) — run it once before the first real tag to validate the windows-11-vs2026-arm / macos-15-intel runner labels.
    • win-arm64/linux-arm64 managed artifacts now build and smoke on real arm runners instead of shipping never-executed cross-publishes.
    • Native release smokes gain the five Managed-SKU-only refusal assertions (fork, -r, .NET interop, --gen-decl, --verify), features.ts, the per-RID publish-warning ratchet, and CRLF normalization so grep -qx works on Windows RIDs.

Native-SKU behavior fixes

  • ManagedStructuralClrReflection probes return null under Native AOT instead of throwing. Every call site (Proxy traps, FromAnyObject, vm context, KeyObject export…) is a structural probe with its own guest-level fall-through error, and the open CLR universe is empty by construction in a native process (arbitrary CLR objects can only enter via the .NET interop boundary, which rejects native first). A plain-TypeScript program running natively now gets its ordinary "…is not a function" error, never a reflection PlatformNotSupportedException. Methods renamed TryGet* to make the probe semantics explicit.
  • ManagedEmittedShapeReflection.IsShape answers false for every shape under native, so RuntimeCallableDispatcher.IsCallable/Invoke can no longer disagree (predicate said yes, action threw). RequireManagedShape checks native before shape so unguarded callers get the clear named diagnostic.
  • Native-SKU extraction failure of the embedded managed runtime is now fatal instead of warning-and-continue — it is the only soft-dependency mechanism the native SKU has, and it was the one native limitation that didn't fail fast.

Guardrails

  • Mandatory AOT flags own their home in SharpTS.csproj behind PublishAot=true (TrimMode=partial, IlcTrimMetadata=false, IlcGenerateCompleteTypeMetadata=true, StackTraceSupport=true); ci.yml/publish.yml no longer duplicate them, and a local dotnet publish -p:PublishAot=true matches CI instead of producing a broken binary.
  • Payload validation: a Native AOT publish without SharpTSManagedRuntimePayloadPath is a build error (opt-out: SharpTSAllowMissingManagedRuntimePayload=true for probe builds), and the payload's PE Machine word must be AnyCPU/I386 — the arch-stamped-payload failure mode was previously reproduced in the wild ("SharpTS runtime not present" under a different arch's dotnet).
  • features.ts now forces the TypeBuilderInstantiation fallback (Promise<Counter> + typed Counter[] close runtime open generics over TypeBuilder args). Previously only the async builder's MethodBuilderInstantiation fallback executed in CI, despite comments claiming both. Both gates assert the new 2 2 33 7 11 output.
  • Architecture tests (SharpTS.Tests/Architecture/) pin the seam routing: raw MakeGenericType/MakeGenericMethod outside EmitGenerics/TypeProvider/ManagedDotNetInterop, or any resurrected CustomAttributeBuilder use, fail the suite — a copy-pasted suppression can no longer satisfy the analyzer ratchet while breaking the native compiler.
  • CustomAttributeEncoder gets byte-exact §II.23.3 blob tests plus a persisted-assembly round trip; null Type args now encode as the 0xFF null SerString and null value-type args throw a named error instead of NRE. EmbeddedManagedRuntime's atomic-write discipline (temp + rename, overwrite, cleanup, locked destination) is unit-tested via a stream overload split from the resource lookup.
  • Ratchet scripts print their per-record diff before failing (Write-Error under ErrorActionPreference=Stop terminated before the detail printed) and fail on a zero-line parse when the baseline expects warnings — closing the silent-green vector from localized runner output or diagnostic-format drift.
  • test-examples.ps1 -MinimumExecuted fails the corpus gate if executed (non-skipped) cases drop below a floor (CI: 50 ≈ losing a whole path), so a refactor that mass-skips can't go green.
  • Docs: TypeProvider is documented as the fifth, deliberately unguarded seam with its invariant stated; stale NodeRegistry references in CONTRIBUTING.md and docs/plans/native-aot.md rewritten to describe the shipped catalog + dispatch-switch design; native-aot.md updated for the flag ownership, gated release flow, and probe semantics.

Known follow-up (not in this PR)

The extraction/co-location path ships only SharpTS.dll, no dependency closure — eval/vm code reaching zlib needs ZstdSharp.dll, which the test harness copies explicitly but production does not. Which dependencies the soft-dep contract promises is a design decision; deliberately left for its own issue.

Test plan

  • Full suite: 16,283/16,283 pass (Category!=LiveNetwork&LoadSensitive)
  • scripts/aot-analyzer-report.ps1 -EnforceBaseline — inventory still exactly zero
  • features.ts interpreted + compiled under JIT both print 2 2 33 7 11
  • csproj gates exercised locally: AnyCPU payload accepted; arch-stamped payload rejected (caught the arm64 apphost at 0xAA64); PublishAot=true without payload rejected with guidance
  • Ratchet scripts exercised against synthetic logs: diff detail prints, empty parse fails
  • Both workflow files YAML-validated
  • After merge: trigger the publish.yml workflow_dispatch dry run to validate the six native runner labels before the first tag

Part of #1324.

nickna added 6 commits July 31, 2026 00:50
Release blockers found by reviewing the epic before the first tag:

- Compile errors now print to stderr (Program.cs generic catch + explicit
  bundler failure). The tag-time macOS smoke greps stderr for PE-Packer's
  Mach-O refusal, which previously landed on stdout and would have failed
  the first release run.
- publish.yml is reordered so nothing publishes before it is validated:
  build/pack -> all twelve managed+native artifact smokes -> a final
  tag-only release job that pushes NuGet and attaches every archive. A
  failing RID now blocks the release instead of leaving a missing asset
  behind a public package. workflow_dispatch runs the full matrix as a
  no-publish dry run to validate runner labels before the first tag.
  win/linux arm64 managed artifacts now smoke on real arm runners; native
  smokes gain the five Managed-SKU-only refusal assertions, the per-RID
  publish-warning ratchet, and CRLF normalization for Windows RIDs.

Native-SKU behavior fixes:

- ManagedStructuralClrReflection probes (TryGet*) return null under Native
  AOT instead of throwing: every call site is a structural probe with its
  own guest-level error path, and the open CLR universe is empty by
  construction in a native process. Plain-TypeScript programs hitting
  Proxy traps or descriptor coercion natively now get their ordinary
  "not a function"-style errors, not a reflection PNSE.
- ManagedEmittedShapeReflection.IsShape answers false for every shape
  under native, so IsCallable/Invoke can no longer disagree;
  RequireManagedShape checks native before shape for the clearer error.
- A native-SKU extraction failure of the embedded managed runtime is now
  a fatal compile error instead of warning-and-continue - it is the only
  soft-dependency mechanism the native SKU has.

Guardrails:

- The mandatory AOT publish flags (TrimMode=partial, IlcTrimMetadata=false,
  IlcGenerateCompleteTypeMetadata=true, StackTraceSupport=true) move into
  SharpTS.csproj behind PublishAot=true; workflows no longer duplicate
  them and a local publish matches CI. A Native AOT publish without
  SharpTSManagedRuntimePayloadPath is an error (opt-out property for probe
  builds), and the payload is validated as AnyCPU by PE Machine word -
  the arch-stamped-payload failure was previously reproduced in the wild.
- features.ts gains Promise<UserClass> and typed user-class array shapes,
  so the TypeBuilderInstantiation fallback (not just the async builder's
  MethodBuilderInstantiation) executes on every CI run; both gates assert
  the new '2 2 33 7 11' output.
- New architecture tests pin the seam routing: raw MakeGenericType/
  MakeGenericMethod outside EmitGenerics/TypeProvider/ManagedDotNetInterop
  and any resurrected CustomAttributeBuilder use fail the suite.
- CustomAttributeEncoder gets byte-exact blob tests plus a persisted-
  assembly round trip; null Type args encode as the 0xFF SerString and
  null value-type args throw a named error instead of NRE.
  EmbeddedManagedRuntime's atomic-write discipline is unit-tested via a
  stream overload split from the resource lookup.
- Ratchet scripts print their per-record diff before failing (Write-Error
  under ErrorActionPreference=Stop swallowed it) and fail on a zero-line
  parse when the baseline expects warnings, closing the silent-green
  localization/format-drift vector. test-examples.ps1 gains
  -MinimumExecuted so a mass-skip cannot pass the corpus gate (CI floor 50).
- TypeProvider is documented as the fifth, deliberately unguarded seam;
  stale NodeRegistry references in CONTRIBUTING.md and native-aot.md are
  rewritten to describe the shipped catalog + dispatch-switch design.

Part of #1324.
Check() and CheckWithRecovery() share a copy-pasted prologue that had
diverged: only the recovery path called HoistClassDeclarations, so a
function body forward-referencing a class declared later in the file
type-checked on the CLI path but failed through workers and Test262,
which use Check() directly.
RealPackageSmokeTests runs a real npm install against registry.npmjs.org
but its "npm" category was not in the test filters, so every CI and
release run hit the live registry — the same hermeticity hazard the
LiveNetwork quarantine exists for. The host-literal guardrail cannot
catch package-name installs, so the category filter is the enforcement.
FileDiscovery documents itself as the single upward-walk policy (temp
root and user profile are exclusive ceilings), but this resolver carried
its own verbatim copy of the ceiling logic in FindVisibleTypeRoots, and
its Ancestors loop -- used by ResolveLib -- had no ceilings at all, so
lib resolution would ascend into %TEMP%/%USERPROFILE% that every other
loader refuses. Both now derive parents via FileDiscovery.AmbientParent.
Checker messages carry no prefix by convention -- Diagnostic prepends it
on render. These two carried a leading-space " Type Error: " that also
defeated RecordTypeError''s exact-prefix stripping, so users saw doubled
"Type Error:  Type Error: Inferred type ..." output.
@nickna
nickna merged commit e92988f into main Aug 1, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant