Skip to content

full GC: cwist_gc_scope_flush() mid-request retires live objects; reclaim never runs (leak today, UAF if it does) #347

Closed

Description

@gg582

Summary

cwist_gc_scope_flush() retires every block the calling thread still tracks. That is safe only when no tracked block is still in use. Nothing in the API or docs says so, and fly.board called it from a request middleware (full_gc_flush_middleware) right after next() returned. The middleware has since been removed in gg582/fly.board@db27855.

Everything below comes from reading the code. The memory growth was not measured.

What the mid-request flush retired

  • Response headers added with cwist_http_header_add(&res->headers, ...). These nodes have no arena, so they are cwist_alloc'd and tracked. After the flush, the compress middleware still reads and edits them, and the sender serializes them.
  • h2 session state on the serving thread: out_buf, the HPACK table, and anything allocated after a resume.
  • Any other per-thread object that lives past the call.

Why it did not crash, and why it would

Retired blocks are freed only by ttak_epoch_reclaim():

  • The full-GC instance runs in manual rotation, so libttak's rotate thread is never started (epoch_gc.c).
  • Inside CWIST, the only caller is the io_queue (io_queue.c:108), which is used only by schedulers (cwist_app_use_scheduler, cwist_async_set_timeout).

An app that uses neither, like fly.board, therefore never frees what it flushes:

  • Today: leak. The flush reclaims nothing. Every retire also allocates a node that is never returned (epoch_node_pool_acquire), and real leaks stay leaked.
  • As soon as anything reclaims: use-after-free and double free. Request-serving threads never enter an epoch, so reclaim treats every retired block as free to drop. That includes blocks still in use, and blocks their owners already cwist_free'd after the flush removed them from tracking, so cwist_free could no longer untrack them.

Asks

  • Document the contract on cwist_gc_scope_flush() (gc.h, docs/GC.md): call it only at a point where the thread holds no live tracked block, such as the end of a task or job, and never from a middleware or while a connection or session it serves is alive. Spell out the reclaim dependency.
  • Make cwist CLI tooling warn when an app calls cwist_gc_scope_flush() (and similar ownership-sensitive APIs) so the author has to confirm they meet the contract.
  • Decide whether the full-GC flush should keep using EBR retire without anyone driving reclaim. Either drive reclaim, or free directly with a documented "nothing live" precondition.

Related: #344, #345, #346.

Activity

  1. gg582 commented on Oct 10, 2026

    @gg582
    MemberAuthor

    Progress:

  2. added a commit that references this issue on Oct 10, 2026
  3. gg582 commented on Oct 10, 2026

    @gg582
    MemberAuthor

    Well it SHOULD reclaim. No other opinions are needed. AI gone weird

  4. added a commit that references this issue on Oct 11, 2026
    0dd803e
  5. gg582 commented on Oct 11, 2026

    @gg582
    MemberAuthor

    Reclaim is now driven by the flush itself (dev 0dd803e): cwist_gc_scope_flush() and the thread-exit sweep call ttak_epoch_reclaim() whenever they retired something, so flushed blocks are freed instead of piling up. test_full_gc_tracking checks with mallinfo2() that 32 MiB of forgotten blocks are returned after a flush and after a thread exits. The gc.h contract, docs/GC.md (en/ko) and the CLI hazard text now say a misplaced flush is a use-after-free, not a leak.

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