@scriptc/runtime 0.0.22 → 0.0.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scriptc/runtime",
3
- "version": "0.0.22",
3
+ "version": "0.0.23",
4
4
  "description": "scriptc native runtime — C sources, compiled into every scriptc binary",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://scriptc.dev",
package/src/scr_async.c CHANGED
@@ -1802,8 +1802,11 @@ bool scr_loop_run(ScrPromise *top_level) {
1802
1802
  }
1803
1803
  if (scr_ready_len > 0 || scr_nt_head != NULL || scr_nunhandled > 0) continue;
1804
1804
  /* Quiescent between turns (microtasks drained, nothing running):
1805
- * collect any cycles the turn left behind. No-op on an empty buffer. */
1806
- scr_collect_cycles();
1805
+ * collect any cycles the turn left behind. One SCHEDULED pass almost
1806
+ * always a nursery one, sized to the turn's own garbage. A full sweep
1807
+ * here would walk the whole live heap every turn, which is precisely
1808
+ * what the generations exist to avoid. No-op on an empty buffer. */
1809
+ scr_cyc_collect_scheduled();
1807
1810
  /* Stream tick dispatch (scr_stream.c, when linked): the deferred
1808
1811
  * next-tick emissions ('data' flow kicks, 'readable'/'end'/'finish'/
1809
1812
  * 'drain'/'error'/'close') fire now, FIRST — the nextTick station.
package/src/scr_cycle.c CHANGED
@@ -1,4 +1,4 @@
1
- /* Reference-cycle collector: synchronous Bacon–Rajan trial deletion (see
1
+ /* Reference-cycle collector: generational Bacon–Rajan trial deletion (see
2
2
  * "Concurrent Cycle Collection in Reference Counted Systems", the
3
3
  * synchronous algorithm) over cycle-headered objects only — the object
4
4
  * model and the trace/teardown contract live in scr_runtime.h.
@@ -29,6 +29,58 @@
29
29
  * Recursion depth equals the traced structure's depth — same property as
30
30
  * the existing recursive releases (deep lists recurse deeply; acceptable
31
31
  * for now, revisit with an explicit stack if it ever traps).
32
+ *
33
+ * ── generations ──────────────────────────────────────────────────────
34
+ * A pass costs O(objects reachable from its candidates), and a candidate
35
+ * deep in a live structure reaches all of it — so collecting on a fixed
36
+ * candidate count makes total work QUADRATIC in a growing live heap. A
37
+ * splay tree is the worst case: re-linking a node buffers it, the walk
38
+ * from it drags in every node below, and none of it is ever garbage.
39
+ *
40
+ * So the collector is generational, in the shape CPython uses for this
41
+ * same algorithm. Each header carries a `gen`; a pass names the oldest
42
+ * generation it will walk and SKIPS every object above it, exactly as it
43
+ * skips NULL and immortals. Objects that survive a pass are promoted, so
44
+ * the next nursery pass never walks them again: the cost of a nursery pass
45
+ * is proportional to what has been allocated since the last one, not to
46
+ * the live heap.
47
+ *
48
+ * Restricting the walk stays sound because trial deletion is already
49
+ * conservative in the right direction. An edge from a skipped (older)
50
+ * object into the walked set is never trial-deleted, so its target keeps
51
+ * that count, reads as externally referenced, and survives — the same
52
+ * answer the collector gives for a reference held by the stack. Edges the
53
+ * other way are simply not followed; the older object was not a candidate
54
+ * for freeing in this pass. What this gives up is cycles that SPAN
55
+ * generations: those are invisible to a nursery pass and are found by the
56
+ * full pass, which walks every generation and so is exactly the old
57
+ * algorithm.
58
+ *
59
+ * Scheduling. One counter drives the release path: candidates buffered
60
+ * since the last pass. When it trips, the pass runs at the nursery level
61
+ * unless either full-pass condition holds:
62
+ * heap growth the live cycle-headered heap is a fraction past its size
63
+ * after the last full pass — the standard mature-generation
64
+ * rule, which catches garbage made from fresh allocation;
65
+ * mature backlog the mature candidate buffer has reached a fraction of the
66
+ * live heap. This one is not redundant: live data that is
67
+ * unlinked INTO a dead cycle grows no counter at all (it
68
+ * was tallied when it was allocated) and is invisible to a
69
+ * nursery pass, so without it a program that churns its
70
+ * long-lived structures would never collect anything.
71
+ * scheduled age a mature candidate has waited through a nursery's worth
72
+ * of event-loop checkpoints. Candidate COUNT cannot bound
73
+ * the garbage behind one root, and an idle heap does not
74
+ * trip growth, so this keeps a sparse mature backlog from
75
+ * floating forever without putting a full walk on every
76
+ * turn.
77
+ * The first two bound collection work to a constant factor of mutator work —
78
+ * one heap-sized walk per live/N candidates — while the scheduled age is the
79
+ * liveness backstop, limiting its forced full walks to one per nursery's worth
80
+ * of checkpoints while mature roots wait. That is the trade: a fixed root
81
+ * threshold bounds floating garbage tightly and pays unbounded time for it;
82
+ * this bounds ordinary mutator-triggered work and lets garbage float in
83
+ * proportion, but not forever.
32
84
  */
33
85
  #include "scr_runtime.h"
34
86
 
@@ -42,70 +94,210 @@ static void scr_cyc_oom(void) {
42
94
  scr_trap("scriptc: out of memory\n");
43
95
  }
44
96
 
97
+ /* Live cycle-headered objects — what the full-pass trigger watches. */
98
+ static size_t scr_cyc_live = 0;
99
+
45
100
  void *scr_cyc_alloc(size_t size, ScrTraceFn trace, ScrCycFreeFn free_fn) {
46
101
  ScrCycHdr *h = calloc(1, sizeof(ScrCycHdr) + size);
47
102
  if (!h) scr_cyc_oom();
48
103
  h->trace = trace;
49
104
  h->free_fn = free_fn;
50
105
  h->color = SCR_CYC_BLACK;
106
+ h->gen = SCR_CYC_NURSERY;
107
+ scr_cyc_live++;
51
108
  return h + 1;
52
109
  }
53
110
 
54
- void scr_cyc_free(void *obj) { free(scr_cyc_hdr(obj)); }
111
+ void scr_cyc_free(void *obj) {
112
+ scr_cyc_live--;
113
+ free(scr_cyc_hdr(obj));
114
+ }
115
+
116
+ /* A pointer vector that only ever grows (these reuse their capacity across
117
+ * passes rather than churning it). Pointer, count and capacity live in ONE
118
+ * struct on purpose: buffering a candidate is the hot path, and splitting
119
+ * them across three arrays indexed by generation would touch three cache
120
+ * lines per release where the original single buffer touched one. */
121
+ typedef struct {
122
+ void **v;
123
+ size_t n, cap;
124
+ } ScrVec;
55
125
 
56
- /* ── the candidate-root buffer ────────────────────────────────────────── */
126
+ static void scr_cyc_grow(ScrVec *vec) {
127
+ vec->cap = vec->cap ? vec->cap * 2 : 64;
128
+ void **grown = realloc(vec->v, vec->cap * sizeof *grown);
129
+ if (!grown) scr_cyc_oom();
130
+ vec->v = grown;
131
+ }
132
+
133
+ static void scr_cyc_push(ScrVec *vec, void *obj) {
134
+ if (vec->n == vec->cap) scr_cyc_grow(vec);
135
+ vec->v[vec->n++] = obj;
136
+ }
57
137
 
58
- static void **scr_roots = NULL;
59
- static size_t scr_nroots = 0, scr_roots_cap = 0;
138
+ /* ── the candidate-root buffers (one per generation) ──────────────────── */
139
+
140
+ static ScrVec scr_roots[SCR_CYC_NGENS];
60
141
  static bool scr_collecting = false;
61
142
 
62
- static size_t scr_cyc_threshold(void) {
143
+ /* The candidates of the pass in flight, gathered across the generations it
144
+ * collects so the phases can walk them without re-filtering the buffers. */
145
+ static ScrVec scr_cands;
146
+
147
+ /* Nursery survivors awaiting promotion (see scr_scan_black). */
148
+ static ScrVec scr_promote;
149
+
150
+ /* The gathered white set (freed after the walk completes). */
151
+ static ScrVec scr_white;
152
+
153
+ /* Cross-generation targets pinned by the white set (see scr_xg_pin_visit),
154
+ * one entry per skipped edge. Drained after the teardowns. */
155
+ static ScrVec scr_xgen;
156
+
157
+ /* Objects above this generation are invisible to the pass in flight. */
158
+ static unsigned scr_gen_limit = SCR_CYC_MATURE;
159
+
160
+ static size_t scr_cyc_pass(unsigned gen_limit);
161
+
162
+ /* ── when to collect ──────────────────────────────────────────────────── */
163
+
164
+ #define SCR_CYC_NURSERY_CANDIDATES 256 /* nursery trigger */
165
+ #define SCR_CYC_FULL_GROWTH_DIV 4 /* full pass per +1/4 of live heap */
166
+ #define SCR_CYC_FULL_FLOOR 4096 /* ...but not below this many objects */
167
+
168
+ /* Live count as of the end of the last full pass. */
169
+ static size_t scr_cyc_live_after_full = 0;
170
+
171
+ static size_t scr_cyc_nursery_threshold(void) {
63
172
  static size_t cached = 0;
64
173
  if (cached == 0) {
65
174
  const char *env = getenv("SCR_CYCLE_THRESHOLD");
66
175
  long v = env ? strtol(env, NULL, 10) : 0;
67
- cached = v > 0 ? (size_t)v : 256;
176
+ cached = v > 0 ? (size_t)v : SCR_CYC_NURSERY_CANDIDATES;
68
177
  }
69
178
  return cached;
70
179
  }
71
180
 
181
+ /* Buffered candidates across both generations, and the count at which the
182
+ * release path next collects. Keeping a running total (rather than summing
183
+ * the buffers) is what holds the hot path to one load and one compare, as
184
+ * it was when there was a single buffer. The trigger is re-armed at the end
185
+ * of every pass to "whatever survived, plus a nursery's worth": without that
186
+ * hysteresis a large mature buffer — which a nursery pass cannot drain —
187
+ * would re-arm on every single release. Both start at zero, so the first
188
+ * release runs one trivial pass that arms them properly. */
189
+ static size_t scr_cyc_nbuffered = 0;
190
+ static size_t scr_cyc_trigger = 0;
191
+
192
+ /* Scheduled nursery passes for which at least one mature root was waiting.
193
+ * A full pass resets the age; with no mature backlog there is nothing to age. */
194
+ static size_t scr_cyc_scheduled_mature_age = 0;
195
+
196
+ static size_t scr_cyc_buffered(void) {
197
+ return scr_roots[SCR_CYC_NURSERY].n + scr_roots[SCR_CYC_MATURE].n;
198
+ }
199
+
200
+ /* A full pass is due once the live heap has grown a fraction past what it
201
+ * was when the last one finished. Since a pass resets the baseline to the
202
+ * live count it leaves behind, an unproductive full pass cannot re-trigger
203
+ * itself — the next one waits for another fraction of growth. */
204
+ static bool scr_cyc_full_due(void) {
205
+ size_t base = scr_cyc_live_after_full;
206
+ if (base < SCR_CYC_FULL_FLOOR) base = SCR_CYC_FULL_FLOOR;
207
+ return scr_cyc_live > base + base / SCR_CYC_FULL_GROWTH_DIV;
208
+ }
209
+
210
+ /* Cold half of the release path: pick the generation and collect.
211
+ *
212
+ * Heap growth is not the only thing that owes a full pass. Live data that
213
+ * TURNS INTO garbage — a mature structure unlinked into a dead cycle —
214
+ * grows no counter at all: those objects were already tallied in
215
+ * scr_cyc_live when they were allocated, so scr_cyc_full_due stays false
216
+ * forever, and a nursery pass cannot see them because they are mature. Left
217
+ * at that, a program that churns its long-lived structures without
218
+ * allocating would never collect anything. So the mature buffer's own size
219
+ * is the second full-pass trigger, at a fraction of the live heap: that
220
+ * bounds uncollected mature candidates proportionally and keeps the
221
+ * amortized cost linear (one heap-sized walk per live/N candidates). */
222
+ static size_t scr_cyc_mature_threshold(void) {
223
+ size_t t = scr_cyc_live / SCR_CYC_FULL_GROWTH_DIV;
224
+ return t < SCR_CYC_NURSERY_CANDIDATES ? SCR_CYC_NURSERY_CANDIDATES : t;
225
+ }
226
+
227
+ static void scr_cyc_collect_due(void) {
228
+ bool full = scr_cyc_full_due()
229
+ || scr_roots[SCR_CYC_MATURE].n >= scr_cyc_mature_threshold();
230
+ scr_cyc_pass(full ? SCR_CYC_MATURE : SCR_CYC_NURSERY);
231
+ }
232
+
233
+ /* One scheduled pass, for callers that reach a natural collection point
234
+ * (the event loop between turns) rather than a threshold. Deliberately NOT
235
+ * scr_collect_cycles: that one is the exit-time full sweep, and running it
236
+ * per turn would walk the whole live heap every turn — which is exactly the
237
+ * cost the generations exist to avoid. */
238
+ void scr_cyc_collect_scheduled(void) {
239
+ if (scr_cyc_buffered() == 0) {
240
+ scr_cyc_scheduled_mature_age = 0;
241
+ return;
242
+ }
243
+ if (scr_roots[SCR_CYC_MATURE].n == 0) {
244
+ scr_cyc_scheduled_mature_age = 0;
245
+ } else if (++scr_cyc_scheduled_mature_age
246
+ >= scr_cyc_nursery_threshold()) {
247
+ scr_cyc_pass(SCR_CYC_MATURE);
248
+ return;
249
+ }
250
+ scr_cyc_collect_due();
251
+ }
252
+
72
253
  void scr_cyc_on_dead(void *obj) {
73
254
  ScrCycHdr *h = scr_cyc_hdr(obj);
74
255
  if (!h->buffered) return;
75
- /* O(1) removal: swap the last entry into the hole. */
256
+ /* O(1) removal: swap the last entry into the hole. A buffered object's
257
+ * generation never changes (promotion runs only on objects the pass has
258
+ * already drained from the buffers), so `gen` names the right one. */
259
+ ScrVec *b = &scr_roots[h->gen];
76
260
  size_t i = h->buf_index;
77
- void *last = scr_roots[--scr_nroots];
78
- scr_roots[i] = last;
261
+ void *last = b->v[--b->n];
262
+ b->v[i] = last;
79
263
  if (last != obj) scr_cyc_hdr(last)->buf_index = i;
80
264
  h->buffered = 0;
265
+ scr_cyc_nbuffered--;
266
+ /* A death must not consume room reserved for a future candidate. */
267
+ scr_cyc_trigger--;
268
+ if (h->gen == SCR_CYC_MATURE && b->n == 0)
269
+ scr_cyc_scheduled_mature_age = 0;
81
270
  }
82
271
 
272
+ /* THE hot path — every release that leaves an object alive lands here, so
273
+ * it stays what it has always been: buffer inline, then one compare. */
83
274
  void scr_cyc_on_release(void *obj) {
84
275
  ScrCycHdr *h = scr_cyc_hdr(obj);
85
276
  h->color = SCR_CYC_PURPLE;
86
277
  if (!h->buffered) {
87
- if (scr_nroots == scr_roots_cap) {
88
- scr_roots_cap = scr_roots_cap ? scr_roots_cap * 2 : 64;
89
- scr_roots = realloc(scr_roots, scr_roots_cap * sizeof *scr_roots);
90
- if (!scr_roots) scr_cyc_oom();
91
- }
278
+ ScrVec *b = &scr_roots[h->gen];
279
+ if (b->n == b->cap) scr_cyc_grow(b);
92
280
  h->buffered = 1;
93
- h->buf_index = scr_nroots;
94
- scr_roots[scr_nroots++] = obj;
95
- }
96
- /* Threshold trigger. Never re-entered: a teardown's releases of untraced
97
- * children can buffer new candidates mid-collection, but they only wait
98
- * for the next pass. */
99
- if (!scr_collecting && scr_nroots >= scr_cyc_threshold()) {
100
- scr_collect_cycles();
281
+ h->buf_index = b->n;
282
+ b->v[b->n++] = obj;
283
+ scr_cyc_nbuffered++;
101
284
  }
285
+ /* Never re-entered: a teardown's releases of untraced children can buffer
286
+ * new candidates mid-collection, but they only wait for the next pass. */
287
+ if (!scr_collecting && scr_cyc_nbuffered >= scr_cyc_trigger)
288
+ scr_cyc_collect_due();
102
289
  }
103
290
 
104
291
  /* ── trial deletion ───────────────────────────────────────────────────── */
105
292
 
106
- /* Child filter shared by every phase: nothing to do for NULL (unassigned
107
- * slots) or immortal (interned statics have no header at all). */
108
- #define SCR_CYC_SKIP(child) ((child) == NULL || SCR_RC(child) == SIZE_MAX)
293
+ /* Child filter shared by every phase and it MUST be shared by every
294
+ * phase: scan restores exactly the trial decrements markGray made, so the
295
+ * two must agree on which edges are internal. Nothing to do for NULL
296
+ * (unassigned slots), immortal (interned statics have no header at all), or
297
+ * a generation this pass does not collect. */
298
+ #define SCR_CYC_SKIP(child) \
299
+ ((child) == NULL || SCR_RC(child) == SIZE_MAX \
300
+ || scr_cyc_hdr(child)->gen > scr_gen_limit)
109
301
 
110
302
  static void scr_mark_gray(void *obj);
111
303
  static void scr_mg_visit(void *child, void *ctx) {
@@ -129,8 +321,15 @@ static void scr_sb_visit(void *child, void *ctx) {
129
321
  if (scr_cyc_hdr(child)->color != SCR_CYC_BLACK) scr_scan_black(child);
130
322
  }
131
323
  static void scr_scan_black(void *obj) {
132
- scr_cyc_hdr(obj)->color = SCR_CYC_BLACK;
133
- scr_cyc_hdr(obj)->trace(obj, scr_sb_visit, NULL);
324
+ ScrCycHdr *h = scr_cyc_hdr(obj);
325
+ h->color = SCR_CYC_BLACK;
326
+ /* Blackening is what proves a walked object survives, so this is where
327
+ * promotion is decided — but it is only RECORDED here. Raising `gen` now
328
+ * would hide the object from SCR_CYC_SKIP partway through the pass, and
329
+ * this phase has trial decrements left to restore. */
330
+ if (h->gen < SCR_CYC_MATURE)
331
+ scr_cyc_push(&scr_promote, obj);
332
+ h->trace(obj, scr_sb_visit, NULL);
134
333
  }
135
334
 
136
335
  static void scr_scan(void *obj);
@@ -151,10 +350,6 @@ static void scr_scan(void *obj) {
151
350
  h->trace(obj, scr_scan_visit, NULL);
152
351
  }
153
352
 
154
- /* The gathered white set (freed after the walk completes). */
155
- static void **scr_white = NULL;
156
- static size_t scr_nwhite = 0, scr_white_cap = 0;
157
-
158
353
  static void scr_collect_white(void *obj);
159
354
  static void scr_cw_visit(void *child, void *ctx) {
160
355
  (void)ctx;
@@ -164,54 +359,200 @@ static void scr_cw_visit(void *child, void *ctx) {
164
359
  static void scr_collect_white(void *obj) {
165
360
  ScrCycHdr *h = scr_cyc_hdr(obj);
166
361
  if (h->color != SCR_CYC_WHITE || h->buffered) return;
167
- h->color = SCR_CYC_BLACK; /* visited marker prevents re-gathering */
362
+ h->color = SCR_CYC_DOOMED; /* gathereddon't gather twice */
168
363
  h->trace(obj, scr_cw_visit, NULL);
169
- if (scr_nwhite == scr_white_cap) {
170
- scr_white_cap = scr_white_cap ? scr_white_cap * 2 : 64;
171
- scr_white = realloc(scr_white, scr_white_cap * sizeof *scr_white);
172
- if (!scr_white) scr_cyc_oom();
364
+ scr_cyc_push(&scr_white, obj);
365
+ }
366
+
367
+ /* ── cross-generation edges ───────────────────────────────────────────── */
368
+
369
+ /* A restricted pass leaves one edge unaccounted, and it is the one edge that
370
+ * nothing else accounts either. markGray does not trial-delete an edge into a
371
+ * generation it refused to walk (SCR_CYC_SKIP), and the teardown contract has
372
+ * free_fn release exactly the UNTRACED children — because every traced edge
373
+ * was supposed to have been decremented by markGray. So when a white object
374
+ * holds a traced edge into an older generation, freeing it drops the edge
375
+ * while the target keeps the count: a phantom reference that reads as an
376
+ * external one to every later pass, full ones included. The target and
377
+ * everything under it would never be reclaimable again.
378
+ *
379
+ * So the white set pays those edges off explicitly. The walk cannot do it
380
+ * inline — a release there could free a survivor that scr_promote still
381
+ * points at, or cascade into the white set mid-teardown — so it runs in two
382
+ * halves: PIN each target with a retain while `gen` still holds walk-time
383
+ * values, then DROP THE PIN AND GIVE UP THE EDGE after the teardowns, when
384
+ * generations have settled and the white set is gone. Two decrements per
385
+ * entry, because the pin is one of them — a single release would only undo
386
+ * the pin and leave the phantom edge exactly where it was. The pin is what
387
+ * makes the second decrement safe: a teardown's releases of untraced
388
+ * children can reach a cycle-headered object (that is how they re-buffer
389
+ * survivors), so without it a target could be freed before the drain runs.
390
+ *
391
+ * One entry per skipped edge, so several edges sharing a target settle it
392
+ * once each.
393
+ *
394
+ * Nothing pinned here can be in the white set: a skipped older object's
395
+ * edges were never trial-deleted, so anything it references kept that count
396
+ * and was scan-blacked rather than whitened — the same conservatism that
397
+ * makes restricting the walk sound in the first place. */
398
+ static void scr_xg_pin_visit(void *child, void *ctx) {
399
+ (void)ctx;
400
+ if (child == NULL || SCR_RC(child) == SIZE_MAX) return;
401
+ if (scr_cyc_hdr(child)->gen <= scr_gen_limit) return; /* markGray had it */
402
+ SCR_RC(child) += 1; /* pin across the teardowns */
403
+ scr_cyc_push(&scr_xgen, child);
404
+ }
405
+
406
+ /* Freed by the cross-generation drain, for the caller's fixpoint. */
407
+ static size_t scr_xg_freed = 0;
408
+
409
+ static void scr_xg_release(void *obj);
410
+ static void scr_xg_child_visit(void *child, void *ctx) {
411
+ (void)ctx;
412
+ if (child == NULL || SCR_RC(child) == SIZE_MAX) return;
413
+ scr_xg_release(child);
414
+ }
415
+
416
+ /* One genuine release, generically: the header carries everything needed. */
417
+ static void scr_xg_release(void *obj) {
418
+ ScrCycHdr *h = scr_cyc_hdr(obj);
419
+ if (SCR_RC(obj) > 1) {
420
+ SCR_RC(obj) -= 1;
421
+ scr_cyc_on_release(obj); /* lost a reference: a possible cycle root */
422
+ return;
173
423
  }
174
- scr_white[scr_nwhite++] = obj;
424
+ SCR_RC(obj) = 0;
425
+ scr_cyc_on_dead(obj); /* out of its candidate buffer before the block goes */
426
+ h->trace(obj, scr_xg_child_visit, NULL); /* the traced children */
427
+ h->free_fn(obj); /* the untraced ones, then the block */
428
+ scr_xg_freed++;
175
429
  }
176
430
 
177
- void scr_collect_cycles(void) {
178
- if (scr_collecting || scr_nroots == 0) return;
431
+ /* One pass over every candidate at or below `gen_limit`; returns how many
432
+ * objects it freed. */
433
+ static size_t scr_cyc_pass(unsigned gen_limit) {
434
+ if (scr_collecting) return 0;
179
435
  scr_collecting = true;
436
+ scr_gen_limit = gen_limit;
180
437
 
181
438
  /* markRoots: keep live candidates (still purple), drop the rest — an
182
439
  * object re-retained since buffering is black; one grayed by an earlier
183
- * candidate's walk is already covered by that candidate's subgraph. */
184
- size_t out = 0;
185
- for (size_t i = 0; i < scr_nroots; i++) {
186
- void *obj = scr_roots[i];
187
- ScrCycHdr *h = scr_cyc_hdr(obj);
188
- if (h->color == SCR_CYC_PURPLE) {
189
- scr_mark_gray(obj);
190
- h->buf_index = out;
191
- scr_roots[out++] = obj;
192
- } else {
440
+ * candidate's walk is already covered by that candidate's subgraph. The
441
+ * buffers are drained up front (markGray only touches counts directly,
442
+ * so nothing can re-buffer underneath us) which leaves collectWhite free
443
+ * to gather members of an earlier root's cycle. Candidates ABOVE the
444
+ * limit keep their buffer slots and wait for a full pass. */
445
+ scr_cands.n = 0;
446
+ for (unsigned g = 0; g <= gen_limit; g++) {
447
+ for (size_t i = 0; i < scr_roots[g].n; i++) {
448
+ void *obj = scr_roots[g].v[i];
449
+ ScrCycHdr *h = scr_cyc_hdr(obj);
193
450
  h->buffered = 0;
451
+ if (h->color == SCR_CYC_PURPLE)
452
+ scr_cyc_push(&scr_cands, obj);
194
453
  }
454
+ scr_roots[g].n = 0;
195
455
  }
196
- scr_nroots = out;
456
+ for (size_t i = 0; i < scr_cands.n; i++) scr_mark_gray(scr_cands.v[i]);
457
+
458
+ for (size_t i = 0; i < scr_cands.n; i++) scr_scan(scr_cands.v[i]);
459
+
460
+ scr_white.n = 0;
461
+ for (size_t i = 0; i < scr_cands.n; i++) scr_collect_white(scr_cands.v[i]);
462
+
463
+ /* Pin the cross-generation targets of everything about to be freed, while
464
+ * `gen` still holds the values the walk filtered on (promotion below is
465
+ * what changes them). A full pass skips no edge, so it pins nothing. */
466
+ scr_xgen.n = 0;
467
+ if (gen_limit < SCR_CYC_MATURE) {
468
+ for (size_t i = 0; i < scr_white.n; i++) {
469
+ void *obj = scr_white.v[i];
470
+ scr_cyc_hdr(obj)->trace(obj, scr_xg_pin_visit, NULL);
471
+ }
472
+ }
473
+
474
+ /* Every phase that consults SCR_CYC_SKIP has run, so the recorded
475
+ * survivors can graduate now. They are externally referenced, so none of
476
+ * them is in the white set about to be freed. */
477
+ for (size_t i = 0; i < scr_promote.n; i++)
478
+ scr_cyc_hdr(scr_promote.v[i])->gen = SCR_CYC_MATURE;
479
+ scr_promote.n = 0;
197
480
 
198
- for (size_t i = 0; i < scr_nroots; i++) scr_scan(scr_roots[i]);
481
+ /* A restricted pass may have spared a candidate only because the edge
482
+ * keeping it alive came from a generation it refused to walk — the
483
+ * deliberate conservatism above. But the candidate buffer is the ONLY
484
+ * root set this collector has, and markRoots consumed the entry. Dropping
485
+ * it would retire the one record that this object is a possible cycle
486
+ * root, and a dead cycle whose members all got spared that way would
487
+ * never be walked again by any pass, full ones included. So a restricted
488
+ * pass hands its survivors back as candidates in the generation they were
489
+ * just promoted into, where a full pass — which skips nothing, and so
490
+ * judges them on real reference counts — will settle them. A full pass
491
+ * needs none of this: it walked every edge, so rc > 0 there means a
492
+ * genuine outside reference and Bacon-Rajan's own reasoning retires the
493
+ * candidate. Re-buffering costs a walk, never correctness: an object that
494
+ * is truly live gets re-blackened by the next retain and dropped then. */
495
+ if (gen_limit < SCR_CYC_MATURE) {
496
+ for (size_t i = 0; i < scr_cands.n; i++) {
497
+ void *obj = scr_cands.v[i];
498
+ ScrCycHdr *h = scr_cyc_hdr(obj);
499
+ if (h->color == SCR_CYC_DOOMED) continue; /* being freed below */
500
+ h->color = SCR_CYC_PURPLE;
501
+ if (!h->buffered) {
502
+ h->buffered = 1;
503
+ h->buf_index = scr_roots[h->gen].n;
504
+ scr_cyc_push(&scr_roots[h->gen], obj);
505
+ }
506
+ }
507
+ }
199
508
 
200
- /* collectWhite over a drained buffer: clear every buffered flag first so
201
- * the recursion can gather buffered members of an earlier root's cycle. */
202
- size_t n = scr_nroots;
203
- scr_nroots = 0;
204
- for (size_t i = 0; i < n; i++) scr_cyc_hdr(scr_roots[i])->buffered = 0;
205
- scr_nwhite = 0;
206
- for (size_t i = 0; i < n; i++) scr_collect_white(scr_roots[i]);
207
509
  /* Teardowns run after the full walk. They may release untraced children
208
510
  * (plain RC) — which can re-buffer survivors for the NEXT pass — but
209
511
  * never touch traced (white, already-accounted) edges. */
210
- for (size_t i = 0; i < scr_nwhite; i++) {
211
- void *obj = scr_white[i];
512
+ size_t freed = scr_white.n;
513
+ for (size_t i = 0; i < scr_white.n; i++) {
514
+ void *obj = scr_white.v[i];
212
515
  scr_cyc_hdr(obj)->free_fn(obj);
213
516
  }
214
- scr_nwhite = 0;
517
+ scr_white.n = 0;
215
518
 
519
+ /* Now drain the pins: the white set is gone and generations have settled,
520
+ * so each of those edges can be given up for real. A target that survives
521
+ * lands back in the candidate buffer — it just lost a reference, which is
522
+ * exactly what makes an object a possible cycle root. */
523
+ scr_xg_freed = 0;
524
+ for (size_t i = 0; i < scr_xgen.n; i++) {
525
+ void *obj = scr_xgen.v[i];
526
+ /* Drop the pin first. The phantom edge is still counted, so this cannot
527
+ * reach zero and the release below is the one that settles the object. */
528
+ SCR_RC(obj) -= 1;
529
+ scr_xg_release(obj);
530
+ }
531
+ scr_xgen.n = 0;
532
+ freed += scr_xg_freed;
533
+
534
+ if (gen_limit >= SCR_CYC_MATURE) {
535
+ scr_cyc_live_after_full = scr_cyc_live;
536
+ scr_cyc_scheduled_mature_age = 0;
537
+ }
538
+ /* markRoots drained buffers in bulk and teardowns moved the count around;
539
+ * resync from the buffers themselves and re-arm. */
540
+ scr_cyc_nbuffered = scr_cyc_buffered();
541
+ scr_cyc_trigger = scr_cyc_nbuffered + scr_cyc_nursery_threshold();
216
542
  scr_collecting = false;
543
+ return freed;
544
+ }
545
+
546
+ void scr_collect_cycles(void) {
547
+ /* The full pass, run to a fixpoint. A teardown's releases can drop the
548
+ * last reference to a further cycle, so one pass does not always drain
549
+ * everything reclaimable — and the callers that matter (program exit,
550
+ * library session reset) are immediately followed by the RC audit and
551
+ * want nothing reclaimable left behind. Only a pass that freed something
552
+ * can have buffered anything new, so a pass that comes up empty ends
553
+ * this; and since each repeat needs a strictly smaller live heap to
554
+ * continue, it terminates. */
555
+ while (scr_cyc_pass(SCR_CYC_MATURE)
556
+ && (scr_roots[SCR_CYC_NURSERY].n || scr_roots[SCR_CYC_MATURE].n)) {
557
+ }
217
558
  }