Skip to content

Test process crashes (0xC0000409 / 0xC0000005) after opening a workshared model: the managed WriteFile detour leaves a stale runtime Thread when Revit terminates its worksharing log thread #136

Closed

Description

@basilevs7

Steps to reproduce

  1. A test (here a [Before(Class)] hook of a RevitApiTest class) opens a workshared model:
    var options = new OpenOptions { DetachFromCentralOption = DetachFromCentralOption.DetachAndPreserveWorksets };
    options.SetOpenWorksetsConfiguration(new WorksetConfiguration(WorksetConfigurationOption.OpenAllWorksets));
    var document = application.OpenDocumentFile(ModelPathUtils.ConvertUserVisiblePathToModelPath(path), options);
    DetachAndDiscardWorksets behaves the same.
  2. Run the test project many times (dotnet run -c Release.R25). It is a race, so most runs pass.

✔️ Expected Behavior

Every run finishes with a summary.

❌ Actual Behavior

About 1 run in 13 (11 of 142) ends without a summary: exit 0xC0000409 (fail-fast in coreclr!__report_gsfailure),
sometimes 0xC0000005 or a stack overflow. The crash comes at the next garbage collection after the open, so it shows up
in whatever test runs then.

Environment: Nice3point.TUnit.Revit 2025.1.8 and the release before it, TUnit 1.76.45, Revit 2025 (25.4.30.30),
.NET 8 (coreclr 8.0.32), Windows 11 x64.

Cause

Found with full dumps and a small debugger that logs every thread's start and end and stops at each TerminateThread:

  1. The injector detours kernelbase!WriteFile, WriteConsoleA and WriteConsoleW with PolyHook2 X64Detour and
    managed callbacks. So every native thread that writes any file enters the runtime: it gets a runtime Thread the
    runtime did not create (state 0x20220), and an InlinedCallFrame on its stack while the original WriteFile runs.
  2. Opening a workshared model starts Revit's worksharing log writer thread. It writes the .slog with LockFileEx and
    WriteFile and lives about 4 s. When the open finishes, ~WorksharingLog, still inside OpenDocumentFile on the
    RevitDispatcherThread, ends it with TerminateThread. Usually the writer has exited by then.
  3. If the writer is inside the detoured WriteFile at that moment, it dies with the runtime's Frame on its stack. The
    runtime keeps its Thread in the thread store, with m_pFrame pointing into a stack Windows has freed and hands to
    the next new thread.
  4. The next GC (GcScanRoots → StackFrameIterator::Init → CrawlFrame::CheckGSCookies) reads whatever now occupies
    that memory as a Frame: a fail-fast on the GS cookie, an access violation, or a stack overflow on a garbage chain.

Evidence:

  • In a run that crashed, the debugger stopped at TerminateThread with the writer still alive. The dump taken there
    shows the writer inside WriteFile, whose first bytes jump into the PolyHook trampoline. The fail-fast followed 4 s
    later on the dispatcher thread, at the next GC.
  • Three other full dumps of the crash each hold exactly one stale native-origin Thread whose Frame lies 1.2 to 2.4 KB
    below the top of a freed stack.

Workaround

Test models saved without worksets: Revit then starts no writer thread, and 10 runs in 10 passed.

Suggestion

Keep threads that write ordinary files out of managed code: filter the handle in a small native stub and call into
managed code only for the std handles, or hook only WriteConsole*. Then a thread Revit terminates never leaves a
runtime Frame behind.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions