Skip to content

Avoid blocking file finalizer I/O while all mutators are stopped #331

Closed

Description

@dimensionscape

File cleanup can perform blocking I/O while the collector has every Haxe thread stopped. An unreachable output file with buffered bytes reaches fio::finalize -> fio::destroy -> fclose. If flushing waits for a pipe reader or slow storage, the entire GC pause includes that wait.

Reproduced at 920fa85 on Linux x86_64 through WSL, Haxe 4.3.3, g++ 13.3.0, default collector, with HXCPP_GC_PROFILE_COLLECT for phase timing.

A local FIFO provides a controlled flush delay:

  1. Create a FIFO, open its read end in nonblocking mode, set pipe capacity to 4096 bytes with F_SETPIPE_SZ, and fill it with 4096 bytes through a temporary native writer.
  2. On a joined, attached native worker, call Haxe sys.io.File.write on the FIFO and write one additional byte without closing the output. Joining the worker removes its stack roots.
  3. Start an ordinary native reader thread, independent of the Haxe scheduler. It waits for the chosen delay, drains the full pipe, then reads the final buffered byte.
  4. Force cpp.vm.Gc.run(true) and measure its duration. Check that the reader received all 4097 bytes, including the final marker, and remove the FIFO.

Two fresh processes produced:

Reader delay Collection duration, run 1 Collection duration, run 2
0 ms 0.4939 ms 0.4214 ms
100 ms 100.5191 ms 100.5876 ms
200 ms 200.5090 ms 200.4808 ms

All six data checks passed. For the 200 ms trials, the collector reports 200.08 ms and 200.03 ms in fin, with one finalizer. Setup reports 0.00 ms, and reclaim is below 0.21 ms. This isolates finalizer I/O rather than a large live graph or slow marking. The delays are deliberately controlled, not claims about typical disk or pipe latency.

MarkAll calls hx::RunFinalizers before Collect clears gPauseForCollect and resumes other threads. Putting fclose in a GC-free zone inside this callback alone would not release the collection's global stop.

Consider detaching the file's native cleanup payload while stopped and performing the potentially blocking flush/close after mutators resume. Keep the native stream owned until cleanup completes, prevent double close with the explicit close path, preserve buffered data, and define completion behavior for an explicit Gc.run. This should be scoped to native resource cleanup; moving arbitrary Haxe finalizers out of the existing phase has different rooting and resurrection implications. A bounded queue also needs a policy for cleanup that remains blocked.

Applications should explicitly close output files, but fallback cleanup should not turn one abandoned stream's flush into a pause for every independent worker. The runtime can address this without compiler or stdlib changes.

A local conventional test/regression/FileFinalizerFlush checks that abandoned buffered files are flushed and released, and that a reachable file remains writable across collection. It passes on Windows x64 with default and moving GC, and on Linux x86_64 with default GC. This is correctness coverage for a future change, not a timing assertion. The Linux FIFO probe is separate. No runtime implementation changes were made.

Related: #302 concerns scanning finalizer registrations during minor collections. This report concerns executing a dead file's blocking cleanup during the global pause.

Activity

  1. added a commit that references this issue on Oct 27, 2026
    c1b8f6a
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions