Skip to content

builtin: fix -gc boehm_leak freeing live arrays with the Windows tcc libgc, and print GC aborts to stderr on Windows - #28917

Open
medvednikov wants to merge 4 commits into
masterfrom
fix/28896-boehm-leak-closure-lifetime-free
Open

medvednikov wants to merge 4 commits into
masterfrom
fix/28896-boehm-leak-closure-lifetime-free

Conversation

@medvednikov

@medvednikov medvednikov commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

Summary

Root cause. V array data points one pointer-width past the allocation header. v3_gcboehm_runtime_init registers that offset with GC_register_displacement(sizeof(voidptr)). With -gc boehm_leak, GC_DEBUG puts Boehm's debug header in front of every object, so the data pointer is sizeof(oh) + 8 bytes into the object, and that offset was never registered.

This only matters for a libgc that does not recognize all interior pointers, and that is the one Windows + tcc links. thirdparty/tcc/lib/libgc.a on the thirdparty-windows-amd64 tccbin branch is built by build.ps1 as tcc gc.c -DGC_NOT_DLL -DGC_WIN32_THREADS -DGC_THREADS -DGC_BUILTIN_ATOMIC, without -DALL_INTERIOR_POINTERS. In that archive, GC_all_interior_pointers is initialized to 0. The gcc/msvc builds compile gc.c with -DALL_INTERIOR_POINTERS=1, and the macOS/Linux libgc is built by configure, which enables it. So only Windows + tcc + boehm_leak is affected, which matches the report.

With that library, the leak detector treats array buffers that are referenced only from the heap as garbage. The closure lifetime's records and frames are such buffers: they live inside the ClosureLifetimeState. Boehm frees them while they are still in use (reported leaks are GC_freed, the rest are reclaimed). When every object in a block looks dead, the whole block is released. Lifetime.dispose() then calls array.free() on such a buffer. GC_base() returns NULL for a released block, so GC_debug_free aborts with "Invalid pointer passed to free()".

Fix 1 (builtin: keep array buffers alive ...): use GC_REGISTER_DISPLACEMENT. Under GC_DEBUG that is GC_debug_register_displacement, which registers both offset and sizeof(oh) + offset (gc.h says to use the debugging variant when debug allocation is used). Without GC_DEBUG it is the same GC_register_displacement call as before.

Fix 2 (builtin: print Boehm GC fatal errors to stderr on Windows): on Windows, Boehm's default abort handler shows a modal "Fatal error in GC" message box (MSGBOX_ON_ERROR) and waits for a click before DebugBreak(). That is why closure_lifetime_api_test.v hung v test. Boehm also writes its own messages to <exe>.gc.log there, not to stderr. For Windows -gc boehm* builds, builtin now installs a handler with GC_set_abort_func after GC_INIT. The handler:

  • writes Fatal error in GC: <msg> to stderr with WriteFile, without allocating (not even through the UTF-16 console path, whose HeapAlloc could wait for a stopped thread that holds the process heap lock) or taking CRT stdio locks;
  • prints a backtrace on tcc, like tcc's own handler did after the click;
  • exits with code 1 without running at-exit handlers.

Only an executable installs the handler (builtin: install the Windows GC abort handler only from executables). A V DLL runs the same startup code from _vinit. If it shares the collector with its host (-d dynamic_boehm), the host would keep a dangling handler after FreeLibrary, and V DLLs have no unload hook to restore it. VirtualQuery on one of builtin's globals shows whether builtin is in the executable image (AllocationBase == GetModuleHandleW(NULL)).

With a debugger attached, it returns instead, so DebugBreak() still stops in the debugger. The msg == NULL call before exit(1) never shows a box, so it still goes to Boehm's default handler. GC_set_abort_func, GC_get_abort_func and GC_debug_register_displacement are all exported by the Windows tcc libgc.a (checked with nm), and gc.h declares them.

Test: test_closure_lifetime_boehm_leak_runtime_without_interior_pointers makes this failure reproducible on every platform. Its child program includes a C header whose constructor calls GC_set_all_interior_pointers(0) before V's GC_INIT(). The child runs the issue's lifetime loop under -gc boehm_leak and calls gc_collect() every 16 frames. The test checks that the child exits with status 0, that its output has no GC corruption messages, and that the program finishes. On MSVC the header is a no-op.

What is verified and what is reasoned: I had no Windows machine. The libgc configuration above was read from the actual Windows tcc archive. The mechanism was reproduced on macOS by building the bundled gc.c with the Windows tcc flags. There, a GC mark-end hook shows the frames buffer unmarked while its ClosureLifetimeState is marked. The macOS symptom is different: the double free leaves a free-list cycle, and the next collection spins in GC_set_fl_marks, or reports "found smashed location ... sz= 40". I did not get the exact "Invalid pointer passed to free()" abort, because that depends on the whole block being released, which depends on heap layout. On Windows, the reported abort following from this is reasoned from Boehm's source. The new abort handler has not been run on Windows.

Fixes #28896

Validation

Everything was run on macOS arm64. Nothing was run on Windows. This checkout has no thirdparty/tcc, so boehm builds used VFLAGS='-d use_bundled_libgc', or the generated C was compiled by hand. For tcc, the tcc.exe and libgc.dylib came from another checkout.

  • Windows-like libgc: I built the bundled gc.c without ALL_INTERIOR_POINTERS and ran the issue repro against it, 6 runs each. On master every run hangs at exit in GC_finish_collection. With the fix every run prints done and the same leak report as an interior-pointer build. Registering the debug displacement in master's generated C alone also fixes it.
  • New test: it passes. With master's vlib, it hangs in GC_set_fl_marks at exit; I sampled the process to confirm. The same child program against the prebuilt macOS libgc.a: master prints "GC_debug_free: found smashed location ... sz= 40" and hangs. With the fix it passes. With the macOS tcc libgc.dylib, master hangs and the fix passes.
  • Issue repro, with the fix: clang with -gc boehm_leak and -gc boehm, and tcc with -gc boehm_leak and -gc boehm, all print done.
  • Windows C: -os windows still generates C for -gc boehm and boehm_leak. V3 drops the collector for cross builds, so that C has no GC calls. To inspect the GC path, I built a temporary compiler with that check disabled and generated Windows C with it. I compiled that C with MinGW-w64 gcc 16.2 for -gc boehm and -gc boehm_leak, against the bundled gc.c built with the Windows tcc flags. Both link, and the new code has no warnings. The tcc-targeted C contains the handler with the print_backtrace() call.
  • ./v -silent vlib/v/compiler_errors_test.v: 1724 passed, 1 skipped. Same on master.
  • VJOBS=6 ./v -silent test vlib/builtin/: 33 passed, 5 skipped, 2 failed. Same with -gc boehm_leak, and the same on master. The failures are builtin_stdout_flushed_by_default_test.v and builtin_print_write_error_test.v: their child builds look for the missing thirdparty/tcc/lib/libgc.a. Both pass when run directly with the bundled libgc.
  • VJOBS=6 ./v -silent test vlib/v/tests/fns/: 163 passed, 5 failed. The same 5 fail on master:
    • 3 fail for the same missing-libgc reason and pass when run directly: closure_context_skip_unused_test.v, closure_lifetime_api_test.v, multiline_fn_signature_omitted_comma_test.v.
    • 2 fail the same way on master: fn_call_mut_ref_args_test.v (segfault) and fn_with_opt_or_res_of_multi_return_test.v (checker error).
  • The 22 closure tests in vlib/v/tests/fns/, with -gc boehm_leak and with -gc boehm: the only failures are the missing-libgc child builds above.
  • After the review changes (executable-only handler and the exit status check), with thirdparty/tcc present:
    • ./v -silent vlib/v/compiler_errors_test.v: 1720 passed, 5 skipped.
    • VJOBS=6 ./v -silent test vlib/builtin/: 35 passed, 5 skipped.
    • VJOBS=6 ./v -silent test vlib/v/tests/fns/: 166 passed, 2 failed. The failures are fn_call_mut_ref_args_test.v and fn_with_opt_or_res_of_multi_return_test.v, which fail the same way on master.
    • closure_lifetime_api_test.v: 16/16. The new test's child really runs and exits 0.
    • Windows C: generated with the temporary compiler for -gc boehm and -gc boehm_leak executables, and for a -gc boehm -shared library, all with -arch amd64. All three compile with MinGW-w64 gcc 16.2 (-Wall), with no warnings in the new code. Not run on Windows.
  • After the no-allocation change (builtin: write GC abort messages without allocating on Windows):
    • ./v -silent vlib/v/compiler_errors_test.v: 1720 passed, 5 skipped.
    • VJOBS=6 ./v -silent test vlib/builtin/ vlib/v/tests/fns/closure_lifetime_api_test.v: 36 passed, 5 skipped.
    • Windows C (-arch amd64) for -gc boehm, -gc boehm_leak, -gc none and -gc boehm -shared compiles with MinGW-w64 gcc 16.2 (-Wall), with no warnings in the changed code.

…pointers

V array data points one pointer-width past the allocation header, and
builtin registers that offset with GC_register_displacement. With
-gc boehm_leak, GC_DEBUG gives every allocation Boehm's debug header
too, so the data pointer sits sizeof(oh) + 8 bytes into the object.
That offset was never registered.

This only matters for a libgc that does not recognize all interior
pointers. The bundled Windows tcc libgc.a is built without
ALL_INTERIOR_POINTERS (GC_all_interior_pointers is 0 in it), while the
other bundled builds have it. With that library, the leak detector
treats array buffers that are only referenced from the heap (like the
closure lifetime `records` and `frames`) as garbage. It frees them while
they are still used, and releases whole blocks when all their objects
look dead. closure.Lifetime.dispose() then frees such a buffer again,
and GC_debug_free aborts with "Invalid pointer passed to free()" (#28896).

Use GC_REGISTER_DISPLACEMENT, which is GC_debug_register_displacement
under GC_DEBUG, and registers both offsets.

Add a test that turns off interior pointer recognition before GC_INIT,
and collects while the lifetime bookkeeping is live. Without the fix it
reports smashed objects or hangs in the collector on macOS.
On Windows, Boehm's default abort handler shows a modal "Fatal error in
GC" message box, and waits for a click before it calls DebugBreak().
Unattended runs hang on it; closure_lifetime_api_test.v blocked
`v test vlib/v/tests` that way in #28896. Boehm writes its own error
output to <exe>.gc.log there, so stderr did not show the reason either.

For Windows -gc boehm* builds, install a handler with GC_set_abort_func
after GC_INIT. It writes the message to stderr without allocating and
without the CRT stdio locks, prints a backtrace for tcc builds (like
tcc's own handler did after the click), and exits with code 1, without
running the at-exit handlers. With a debugger attached, it returns, so
Boehm's DebugBreak() still stops there. The call with a nil message
before exit(1) shows no message box, and still goes to Boehm's default
handler.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-09-24T06:29:10.963888Z b54e7a8 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: dc8f276067

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread vlib/builtin/gc_startup_d_gcboehm.c.v Outdated
C.GC_REGISTER_DISPLACEMENT(sizeof(voidptr))
$if windows {
g_gc_default_abort_func = C.v_gc_get_abort_func()
C.v_gc_set_abort_func(gc_abort_without_message_box)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Restore the GC callback before unloading a shared library

For a Windows V DLL linked to a shared/system collector (-shared -d dynamic_boehm), _vinit runs this initializer and replaces libgc's process-global abort callback with a function located inside the DLL. dl.close can subsequently unload that DLL through FreeLibrary, but no cleanup restores g_gc_default_abort_func, leaving the host collector with a dangling callback that it will call on its next fatal error. Avoid installing the callback from shared builds or restore it during library cleanup.

Useful? React with 👍 / 👎.

for gc_error in ['Invalid pointer', 'smashed', 'deallocated', 'Fatal error in GC'] {
assert !res.output.contains(gc_error), res.output
}
assert res.output.contains('lifetime done'), res.output

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check the regression child's exit status

After the missing-libgc case is excluded, this test never asserts res.exit_code == 0; a child that prints lifetime done and then crashes or aborts with output not matching the four selected strings is therefore accepted. This is particularly relevant for platform-dependent libgc failures during shutdown, so assert a successful exit in addition to checking the marker.

Useful? React with 👍 / 👎.

A V DLL runs the Boehm startup code from `_vinit` too. When it shares the
collector with its host (`-d dynamic_boehm`), replacing the process wide
abort handler with a function inside of the DLL leaves the collector with
a dangling handler, once the DLL is unloaded with `FreeLibrary`. V DLLs
have no unload hook on Windows, so the old handler can not be restored
reliably.

Install the handler only when builtin is part of the executable image,
which `VirtualQuery` reports for the address of one of its globals.

Also check the exit status of the child process in the regression test
without interior pointers, so that a crash after its last output fails it.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 40ba2b95ad

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread vlib/builtin/gc_startup_d_gcboehm.c.v Outdated
// corrupted, and stopped threads may hold those locks.
prefix := 'Fatal error in GC: '
newline := '\n'
write_buf_to_fd_kernel32(2, prefix.str, prefix.len)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid process-heap allocation in the GC abort path

When stderr is attached to a console and Boehm aborts while another thread is suspended holding the Windows process-heap lock, this handler can deadlock instead of terminating: write_buf_to_fd_kernel32() first calls write_buf_to_console_kernel32(), which allocates with HeapAlloc at builtin_windows.c.v:173. This contradicts the handler's no-allocation requirement and is especially unsafe while the collector has stopped other threads; use a direct, allocation-free WriteFile path for these fatal messages.

Useful? React with 👍 / 👎.

`write_buf_to_fd_kernel32` first tries `write_buf_to_console_kernel32`,
which converts the text to UTF-16 in a `HeapAlloc` buffer. Boehm aborts
while other threads may be stopped, and one of them may hold the lock of
the process heap, so the abort handler could deadlock on a console.

Move the `WriteFile` loop into `write_buf_to_std_handle_kernel32`, and use
that for the abort message. The messages are ASCII, so a console shows
them the same way.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant