Skip to content

fix(compile): make the fs facade emit IL-verifiable methods (#1246)#1248

Merged
nickna merged 2 commits into
mainfrom
worktree-issue-1246
Jul 4, 2026
Merged

fix(compile): make the fs facade emit IL-verifiable methods (#1246)#1248
nickna merged 2 commits into
mainfrom
worktree-issue-1246

Conversation

@nickna

@nickna nickna commented Jul 4, 2026

Copy link
Copy Markdown
Owner

Closes #1246.

Problem

Compiling any program that imports fs produced 68 IL verification errors in the emitted facade module. The program ran fine (the JIT tolerates the mismatches), but --compile … --verify couldn't be a clean gate on any fs-using program.

$ dotnet run -- --compile repro.ts -o repro.dll --verify
IL verification found 68 error(s):
  [IL Error] $Program.$M_fs_cpSync: StackUnexpected {Found=ref 'object', Expected=ref 'string'} ...
  [IL Error] $Program.$M_fs_readFile: StackUnexpected {Found=ref '$Promise', Expected=ref 'Task`1<object>'} ...

Both shapes are the same underlying defect — a value stored into a narrower slot without the bridge the verifier requires — surfacing in two forms.

Root causes & fixes

1. objectstring (sync helpers + display-class Invokes).
A $M_fs helper stages an any-typed argument (e.g. __joinPath(src, names[i]) in cpSync, where names[i] is an object) into a string parameter temp. EmitConversionForParameter handled only value-type targets (unbox) and fell through for reference targets, leaving object on the stack where string was expected — no castclass. Fixed by emitting castclass on string parameter coercion (in both the ILEmitter override and the shared base). Restricted to string: it's the one non-object reference slot whose runtime value is a genuine CLR instance of the slot. Delegates ($TSFunction), dynamic collections ($Array/$Map/$Set), and Task ($Promise) carry a boxed-differently runtime value, so a castclass would throw — they keep their prior (JIT-tolerated) object store.

2. $PromiseTask<object> (async callback/promise helpers).
The promise primitives return a wrapped $Promise, which the callback helpers (__cbData/__cbVoid) took in a Promise<any>Task<object> parameter slot. ParameterTypeResolver.CoerceParamSlotType already widens other unsound parameter slots to object (Date/RegExp/array/Map/Set, unions admitting undefined, symbols) — and ResolveReturnType widens non-async Promise<T> returns to object (#393) — but the symmetric widening for Promise<T> parameters was never added. Fixed by widening Promise<T> (→ Task/Task<object>) parameter slots to object, mirroring #393. A Promise<T> param always receives the runtime $Promise object regardless of whether the enclosing function is async (only the function's return builds a real Task), so the widening is unconditional.

With the parameter slots honest (object), no runtime bridging is needed and the $Promise stores cleanly.

Testing

  • New ILVerificationTests: FsFacadeImport_PassesILVerification (the issue repro, whole facade), plus a minimal plain-TS repro of each class (ObjectArgumentToStringParameter_*, PromiseValueToPromiseParameter_*). Each was confirmed to fail pre-fix and pass post-fix.
  • xUnit: 15415/0 · Test262: 22/0/4skip (interp + compiled) · TSConformance: 31/0
  • --verify and --standalone --verify clean on the repro; interp/compiled output identical across a broad fs exercise (sync + callback + fs/promises).

No behavior change for correctly-typed programs — the fix only makes the already-working IL verifiable. Found incidentally while running --verify during #1242; not a regression from it.

nickna added 2 commits July 4, 2026 14:03
Compiling any program that imports `fs` produced 68 IL verification errors
in the emitted facade module. The program ran (the JIT tolerates the
mismatches), but `--compile … --verify` could not gate any fs-using program.
Two shapes, both a value stored into a narrower slot without the bridge the
verifier requires:

1. `object` → `string`: a $M_fs helper stages an `any`-typed argument (e.g.
   `__joinPath(src, names[i])` in cpSync) into a `string` parameter temp, but
   `EmitConversionForParameter` handled only value-type targets (unbox) and
   fell through for reference targets, leaving `object` where `string` was
   expected. Fix: emit `castclass` on string parameter coercion. Restricted to
   `string` — the one non-object reference slot whose runtime value is a genuine
   CLR instance; delegates ($TSFunction), collections ($Array), and Task
   ($Promise) keep their prior object store.

2. `$Promise` → `Task<object>`: the promise primitives return a wrapped
   `$Promise`, which the callback helpers (`__cbData`/`__cbVoid`) took in a
   `Promise<any>` → `Task<object>` parameter slot. `ParameterTypeResolver`
   widens other unsound slots to object but never the non-async `Promise<T>`
   parameter case — the symmetric gap to the return-slot widening #393 already
   applies. Fix: widen `Promise<T>` (→ Task/Task<object>) parameter slots to
   object in `CoerceParamSlotType`.

Adds regression tests: the whole-fs-facade verify (the issue repro) plus a
minimal plain-TS repro of each class.

xUnit 15415/0, Test262 22/0/4skip, TSConf 31/0, --verify clean.
…y CI)

Process_Uptime_IncreasesOverTime(mode: Interpreted) intermittently failed on
CI with `up2 >= up1` == false — uptime() ran backwards. Root cause is
independent of this branch's IL-compiler changes (it's the interpreter path):
`uptime()` returns `now - (_scriptStartTimestamp ?? _startTimestamp)`, and
`_scriptStartTimestamp` was a *shared* mutable static that the in-process test
runner toggles per run (SetScriptArguments sets a recent timestamp,
ClearScriptArguments nulls it). Because xUnit parallelizes collections across
one CLR process, a concurrent script's SetScriptArguments could land between
another script's two uptime() reads: the first read used the null →
process-start baseline (large elapsed), the second the just-set recent baseline
(~0 elapsed), so uptime appeared to decrease.

The earlier Stopwatch switch fixed clock monotonicity but not this baseline
race. Fix: make `_scriptStartTimestamp` [ThreadStatic] so each script gets a
stable per-thread baseline — same precedent as the ThreadArgv/ThreadEnv fields
directly above it. Not a production bug: a real single-script process sets the
baseline once on its own thread and has no concurrent script to clear it.

Full suite green under the CI filter (Category!=LiveNetwork&Category!=LoadSensitive):
15408/0.
@nickna

nickna commented Jul 4, 2026

Copy link
Copy Markdown
Owner Author

CI investigation: Ubuntu failure was a pre-existing flaky test (now fixed)

The first CI run failed only on ubuntu-latest (windows-latest passed), on a single test: ProcessGlobalTests.Process_Uptime_IncreasesOverTime(mode: **Interpreted**)Expected "true", Actual "false". The Compiled variant of the same test passed in the same run.

This is unrelated to the fix in this PR (which is IL-compiler-only): the failing test exercises the interpreter path, in Runtime/BuiltIns/ProcessBuiltIns.cs.

Root cause

uptime() returns now - (_scriptStartTimestamp ?? _startTimestamp). _scriptStartTimestamp was a shared mutable static that the in-process test runner toggles on every interpreted run — SetScriptArguments sets it to a recent timestamp, ClearScriptArguments nulls it (in a finally). Because xUnit parallelizes test collections across one CLR process, a concurrent script's SetScriptArguments can land between this test's two uptime() reads:

  1. up1 read while the baseline is null → falls back to _startTimestamp (process start) → large elapsed (~80s into the CI run).
  2. A concurrent RunInterpretedWithArgs calls SetScriptArguments → baseline becomes a recent timestamp.
  3. up2 read against the recent baseline → ~0 elapsed.
  4. 0 >= 80false. Fail.

up2 >= up1 uses >=, so a mere failure-to-advance wouldn't fail it — uptime genuinely went backwards. The earlier "use Stopwatch" change (see the code comment naming this exact test) fixed clock monotonicity but not this baseline-switching race. It's timing- and interleaving-dependent, which is why Windows passed, Compiled passed, and it's green locally.

Fix (commit 8114f87)

Make _scriptStartTimestamp [ThreadStatic] — the same precedent as the ThreadArgv/ThreadEnv fields directly above it — so each concurrently-running script gets a stable per-thread baseline and can't stomp another's. Interpret() sets the per-thread baseline before any user code runs, so both reads in a script see the same baseline → monotonic. Not a production bug: a real single-script process sets the baseline once on its own thread with no concurrent script to clear it.

Full suite green under the CI filter (Category!=LiveNetwork&Category!=LoadSensitive): 15408/0.

@nickna nickna merged commit 433621b into main Jul 4, 2026
2 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.

Compiled fs facade emits IL-unverifiable methods (68 --verify errors: $Promise vs Task<object>, object vs string) although it runs

1 participant