GcWatch
GcWatch derives garbage-collector health from the only signal Roblox exposes — the heap size — and turns it into allocation rates, cycle cadence, and reclaim sizes. It’s a shared module: it attaches to a server kernel or a client kernel the same way. For the sibling module that captures script errors instead of GC health, see ErrorWatch.
Mental model
Section titled “Mental model”Roblox exposes no GC pause timings — collectgarbage is restricted to "count", and there is no callback for collection events. What is readable is gcinfo(), the current Lua heap in KB. GcWatch samples it on a recurring Scheduler task (default every 0.1s, at Priority.Background) and reconstructs GC behavior from the deltas between samples:
- Heap grew — allocation happened. The growth accumulates into the current rate window. If the heap had been shrinking until now, the grow marks the end of one GC cycle: the cycle count increments and the shrink run’s total becomes
LastReclaimKb. - Heap shrank — a sweep is reclaiming. Consecutive shrinking samples accumulate into one reclaim run; the run stays open until growth resumes.
- Every window (default 5s), the accumulated allocation and cycle counts convert into
AllocKbPerSecandCyclesPerMin, then the window resets. Rates describe the last window, not a forever-average — a leak that started ten minutes in shows up in the next window, not diluted across the session.
The numbers are approximate by nature: Luau’s GC is incremental, so sweeps interleave with allocation and one sample interval can net a grow and a shrink into a single delta. The shape is what matters, and the shape is reliable — the specs drive the sampler with injected heap curves and assert exact cycle bookkeeping.
Allocation rate is the lever. Every KB allocated must later be marked and swept by the incremental GC, and allocation spikes trigger GC assists inside your frames. Cut allocation and you cut GC time — there is no other knob.
The two stats together separate the two memory failure modes:
| Heap curve | Cycles / reclaims | Diagnosis |
|---|---|---|
| Climbing | Reclaims near zero | Leak — the GC runs but has nothing to free. Look for insert-only tables, undisconnected connections, per-player state not cleared (session:bind exists for exactly this). |
| Stable | Busy cycles, steady reclaims | Churn — tables/strings created per frame and thrown away. Pool or hoist them; see Pool. |
-- src/Server/Bootstrap.luaulocal ReplicatedStorage = game:GetService("ReplicatedStorage")local GcWatch = require(ReplicatedStorage.ChloeKernel.GcWatch)local Logger = require(ReplicatedStorage.ChloeKernel.Logger)
return function(kernel) local Log = Logger.scope("Health") local Gc = GcWatch.attach(kernel)
kernel.Scheduler:every(30, function() local Stats = Gc:stats() Log.info(`heap {math.round(Stats.HeapKb / 1024)}MB, alloc {math.round(Stats.AllocKbPerSec)}KB/s, {Stats.CyclesPerMin} cycles/min`) if Stats.AllocKbPerSec > 4096 then Log.warn("allocation rate over 4MB/s — GC assists will start eating frames") end end, kernel.Priority.Background)endOr skip the manual wiring entirely: kernel:enableDiagnostics() attaches a GcWatch for you (see Diagnostics integration).
API reference
Section titled “API reference”| Member | Description |
|---|---|
GcWatch.attach(kernel, options?) → watch |
Starts sampling on kernel.Scheduler:every(SampleSeconds, ..., Priority.Background). Reading gcinfo() is a counter read — effectively free. |
watch:stats() → Stats |
Current snapshot (table below). |
watch:destroy() |
Cancels the recurring sampler. |
Options (all optional):
| Field | Default | Description |
|---|---|---|
SampleSeconds |
0.1 |
Sampling interval. |
WindowSeconds |
5 |
Rate window — AllocKbPerSec and CyclesPerMin recompute and reset on this cadence. |
Heap |
gcinfo |
Injectable heap reader, for specs. |
Clock |
os.clock |
Injectable clock, for specs. |
SkipLoop |
false |
Do not schedule the sampler; the caller drives _sample(now) manually (specs do this for determinism). |
Stats fields:
| Field | Meaning |
|---|---|
HeapKb |
Most recent heap sample. |
PeakHeapKb |
High-water mark since attach. |
AllocKbPerSec |
Allocation rate over the last closed window. |
CyclesPerMin |
GC cycle cadence over the last closed window. |
Cycles |
Total grow→shrink→grow cycles observed since attach. |
LastReclaimKb |
KB freed by the most recent complete sweep run. |
AvgReclaimKb |
ReclaimedKb / Cycles (0 before the first cycle). |
ReclaimedKb |
Total KB reclaimed since attach. |
Diagnostics integration
Section titled “Diagnostics integration”kernel:enableDiagnostics(intervalSeconds?) attaches one GcWatch automatically (reusing the kernel’s existing one if present) and publishes three of its stats as ReplicatedStorage attributes every interval:
| Attribute | Source |
|---|---|
ChloeKernelDiag{Role}GcAllocKbS |
AllocKbPerSec, rounded |
ChloeKernelDiag{Role}GcCyclesMin |
CyclesPerMin, one decimal |
ChloeKernelDiag{Role}GcReclaimKb |
LastReclaimKb, rounded |
The debug panel reads these into graded GC rows in both the CLIENT and SERVER windows — its leak-vs-churn tooltips are this module’s diagnosis table made hoverable. kernel:shutdown() destroys the diagnostics-attached watch. See Kernel & Boot for the rest of the diagnostics surface.
GcWatch is fully spec-verified with an injected Clock and Heap reader and a SkipLoop double — the cycle bookkeeping and rate-window math above are asserted behavior, not documentation optimism.