xbintsc 0.3.65 → 0.3.68

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/README.md CHANGED
@@ -67,7 +67,11 @@ reclaimed by a non-moving mark-sweep collector: roots are explicit value slots
67
67
  (module globals, prototype caches), runtime root providers (the event loop,
68
68
  the promise microtask queue) and a conservative scan of the active C stack.
69
69
  Collection is armed automatically once the live heap crosses a threshold
70
- (`XT_GC_THRESHOLD`) and can also run on demand.
70
+ (`XT_GC_THRESHOLD` pins it) and can also run on demand. After every collection the
71
+ threshold becomes twice the surviving heap (floor 1 MiB), so a program with a
72
+ small live set collects often and keeps a small resident set;
73
+ `XT_GC_TRACE=1` prints one line per collection (heap and live-allocation counts,
74
+ the next trigger and the live set by object kind) to stderr.
71
75
 
72
76
  ## Usage
73
77
 
@@ -0,0 +1,14 @@
1
+ /**
2
+ * End-to-end test for the `gui` extension — DOM memory retention.
3
+ *
4
+ * A game loop mutates the document every frame: `innerHTML = …` rebuilds a
5
+ * sprite, `textContent = …` refreshes a HUD field. The engine used to park every
6
+ * replaced subtree in the document's detached-node pool and keep an element
7
+ * handle (plus its slot) for every node it ever handed out, so both grew with
8
+ * the frame count and a long run leaked tens of megabytes per minute.
9
+ *
10
+ * The counters come from `window.stats()`. Everything the churn is allowed to
11
+ * change is the tree itself: the detached pool, the handle table and the handle
12
+ * slots must come back to where they started.
13
+ */
14
+ export {};
@@ -0,0 +1,100 @@
1
+ /**
2
+ * End-to-end test for the `gui` extension — DOM memory retention.
3
+ *
4
+ * A game loop mutates the document every frame: `innerHTML = …` rebuilds a
5
+ * sprite, `textContent = …` refreshes a HUD field. The engine used to park every
6
+ * replaced subtree in the document's detached-node pool and keep an element
7
+ * handle (plus its slot) for every node it ever handed out, so both grew with
8
+ * the frame count and a long run leaked tens of megabytes per minute.
9
+ *
10
+ * The counters come from `window.stats()`. Everything the churn is allowed to
11
+ * change is the tree itself: the detached pool, the handle table and the handle
12
+ * slots must come back to where they started.
13
+ */
14
+ import { afterAll, beforeAll, describe, expect, it } from "vitest";
15
+ import { createGuiHarness, guiAvailable } from "./gui-helpers.js";
16
+ const harness = createGuiHarness();
17
+ describe.skipIf(!guiAvailable)("gui extension — dom retention", () => {
18
+ beforeAll(() => harness.setup());
19
+ afterAll(() => harness.cleanup());
20
+ it("releases the nodes innerHTML/textContent/replaceChild drop", () => {
21
+ const { value } = harness.compileAndRun("dom-retention", `
22
+ import { createWindow, run } from "gui";
23
+
24
+ const HTML = \`
25
+ <html><head><style>
26
+ body { margin: 0; }
27
+ #box { width: 100px; height: 50px; }
28
+ .row { display: block; width: 40px; height: 10px; }
29
+ </style></head><body>
30
+ <div id="box"></div>
31
+ </body></html>\`;
32
+
33
+ const win = createWindow({ title: "retention", width: 400, height: 300 });
34
+ win.on("ready", () => {
35
+ const doc = win.document;
36
+ const box = doc.getElementById("box");
37
+ /* Growth of the pooled nodes, the handles and the handle slots. The
38
+ document tree itself is allowed to change; these three are not. */
39
+ const leak = (a: any, b: any): string =>
40
+ (b.detachedRoots - a.detachedRoots) + ":" + (b.detachedNodes - a.detachedNodes) +
41
+ ":" + (b.handles - a.handles) + ":" + (b.handleSlots - a.handleSlots);
42
+
43
+ const before = win.stats();
44
+ console.log("empty=" + before.treeNodes + ":" + leak(before, before));
45
+ const sprite = "<div class='row'><span>a</span></div><div class='row'><span>b</span></div>";
46
+ for (let i = 0; i < 500; i++) box.innerHTML = sprite;
47
+ const afterInner = win.stats();
48
+ console.log("inner=" + leak(before, afterInner));
49
+ const treeAfterInner = afterInner.treeNodes;
50
+ for (let i = 0; i < 500; i++) box.innerHTML = sprite;
51
+ console.log("innerAgain=" + leak(afterInner, win.stats()) + ":" +
52
+ (win.stats().treeNodes === treeAfterInner));
53
+ for (let i = 0; i < 500; i++) box.textContent = "value " + i;
54
+ console.log("text=" + leak(afterInner, win.stats()));
55
+
56
+ /* A node the script removed itself is *not* dropped: DOM semantics keep
57
+ it alive and usable until it is re-attached. */
58
+ const held = doc.createElement("b");
59
+ held.textContent = "held";
60
+ box.appendChild(held);
61
+ box.removeChild(held);
62
+ console.log("removed=" + held.isConnected + ":" + held.textContent + ":" + win.stats().detachedRoots);
63
+ box.appendChild(held);
64
+ console.log("reattached=" + held.isConnected + ":" + win.stats().detachedRoots);
65
+
66
+ /* replaceChild drops the node it replaces, so its handle goes stale. */
67
+ const first = box.firstElementChild;
68
+ const replacement = doc.createElement("i");
69
+ box.replaceChild(replacement, first);
70
+ console.log("replaced=" + replacement.isConnected + ":" + first.isConnected + ":" +
71
+ win.stats().detachedRoots);
72
+
73
+ /* A create/drop loop must reuse handle slots rather than grow the table:
74
+ each iteration creates an element, attaches it and replaces it with a
75
+ plain text node, which releases it again. */
76
+ const slotsBefore = win.stats().handleSlots;
77
+ for (let i = 0; i < 200; i++) {
78
+ const node = doc.createElement("u");
79
+ box.appendChild(node);
80
+ box.innerHTML = "x";
81
+ }
82
+ const recycled = win.stats();
83
+ console.log("recycled=" + (recycled.handleSlots - slotsBefore) + ":" + recycled.detachedNodes);
84
+ win.close();
85
+ });
86
+ win.loadHTML(HTML);
87
+ run();
88
+ `);
89
+ expect(value("empty")).toBe("10:0:0:0:0");
90
+ // 500 sprite writes, then 500 more: no pooled node, handle or slot survives.
91
+ expect(value("inner")).toBe("0:0:0:0");
92
+ expect(value("innerAgain")).toBe("0:0:0:0:true");
93
+ expect(value("text")).toBe("0:0:0:0");
94
+ expect(value("removed")).toBe("false:held:1");
95
+ expect(value("reattached")).toBe("true:0");
96
+ expect(value("replaced")).toBe("true:false:0");
97
+ expect(value("recycled")).toBe("0:0");
98
+ });
99
+ });
100
+ //# sourceMappingURL=gui-retention.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"gui-retention.test.js","sourceRoot":"","sources":["../../../tests/e2e/gui-retention.test.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,QAAQ,EAAE,SAAS,EAAE,QAAQ,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,QAAQ,CAAC;AACnE,OAAO,EAAE,gBAAgB,EAAE,YAAY,EAAE,MAAM,kBAAkB,CAAC;AAElE,MAAM,OAAO,GAAG,gBAAgB,EAAE,CAAC;AAEnC,QAAQ,CAAC,MAAM,CAAC,CAAC,YAAY,CAAC,CAAC,+BAA+B,EAAE,GAAG,EAAE;IACnE,SAAS,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC,CAAC;IACjC,QAAQ,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC,CAAC;IAElC,EAAE,CAAC,4DAA4D,EAAE,GAAG,EAAE;QACpE,MAAM,EAAE,KAAK,EAAE,GAAG,OAAO,CAAC,aAAa,CACrC,eAAe,EACf;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAmEC,CACF,CAAC;QAEF,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QAC1C,6EAA6E;QAC7E,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACvC,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QACjD,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QACtC,MAAM,CAAC,KAAK,CAAC,SAAS,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC9C,MAAM,CAAC,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAC3C,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,cAAc,CAAC,CAAC;QAC/C,MAAM,CAAC,KAAK,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IACxC,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
package/doc/gui.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # xbintsc GUI extension (self-hosted HTML/CSS renderer)
2
2
 
3
- Status: **M11** — features (M1–M11) are complete: HTML parsing, CSS selector
3
+ Status: **M12** — features (M1–M11) are complete: HTML parsing, CSS selector
4
4
  matching, the cascade, computed styles and layout (block, inline, Flexbox and
5
5
  CSS positioning) are
6
6
  in place, and the engine *paints*: it builds a display list of rectangles, images
@@ -13,7 +13,9 @@ DOM: element handles with stable identity, mutation (`appendChild`, `textContent
13
13
  **M9** compiles `<script>` bodies ahead of time (inline and `<script src>`) — see
14
14
  `doc/gui-scripts.md`. **M10** adds `requestAnimationFrame` plus a few DOM helpers.
15
15
  **M11** adds `position: relative`/`absolute`/`fixed` with their offsets, which is
16
- what `examples/gui/pelican-bike` — a playable 2D game — is built on.
16
+ what `examples/gui/pelican-bike` — a playable 2D game — is built on. **M12** makes
17
+ DOM node lifetime explicit (what a mutation releases, what it keeps) and gives the
18
+ engine bounded memory: see "Node lifetime" and "Memory & GC".
17
19
  This document records the locked decisions, the architecture, the milestone plan
18
20
  and the current progress of a cross-platform GUI extension that renders an
19
21
  HTML/CSS UI with its own GPU-accelerated engine.
@@ -191,8 +193,16 @@ win.fontMetrics(fontSize?, family?) // { ascent, descent, lineHeight, ready }
191
193
  win.hitTest(x, y) // deepest element descriptor, or ""
192
194
  win.sendEvent(type, options?) // synthesise input (testing)
193
195
  win.advance(ms) // step the CSS transition clock (testing)
196
+ win.stats() // engine/runtime counters (memory debugging)
194
197
  ```
195
198
 
199
+ `win.stats()` returns `{ heapBytes, heapAllocations, treeNodes, detachedRoots,`
200
+ `detachedNodes, handles, handleSlots, listeners }`: the live runtime heap, the
201
+ size of the DOM tree, the detached-node pool, and the element-handle tables. In a
202
+ steady state every one of them is constant — a climbing `detachedNodes` or
203
+ `handles` means the program removes nodes faster than it releases them (see
204
+ "Node lifetime" below).
205
+
196
206
  They are used by `tests/e2e/gui-*.test.ts` to assert parsing, selector matching,
197
207
  specificity, inheritance, `!important` and layout geometry. They will stay useful
198
208
  afterwards for debugging.
@@ -230,6 +240,31 @@ bubble up to `document`/`window`. The legacy `win.on(type, fn)` payload keeps it
230
240
  **string** `e.target` (`div#id.class`); the element `Event.target` is a handle
231
241
  whose descriptor matches the same string.
232
242
 
243
+ #### Node lifetime
244
+
245
+ A mutation decides whether the nodes it removes can still be reached, and the
246
+ engine releases exactly those it cannot:
247
+
248
+ | Mutation | Removed nodes |
249
+ | --- | --- |
250
+ | `innerHTML = …`, `inner = …`, `textContent = …`, `replaceChild` | **released** — the script has no way to refer to them again |
251
+ | `removeChild`, `remove` | **kept** in the document's detached pool — the DOM keeps a removed node alive and usable, so it can be re-attached |
252
+
253
+ Released nodes free their C++ subtree as well as their handle, so the counters
254
+ from `win.stats()` (`detachedNodes`, `handles`, `handleSlots`) return to their
255
+ previous values. That is what makes the usual game pattern — rewriting a sprite
256
+ with `innerHTML` every frame — run in constant memory; before, every replaced
257
+ subtree stayed pooled forever and a loop writing ten nodes per frame leaked
258
+ several megabytes per second.
259
+
260
+ The one case the engine cannot see is a node the script creates and then drops
261
+ without ever attaching it: `document.createElement("div")` parks the node in the
262
+ pool *and* keeps its handle reachable, because the mark-sweep collector has no
263
+ weak references, so the engine cannot tell that the script threw the handle
264
+ away. A loop that creates elements without attaching them therefore grows the
265
+ pool. Reuse the elements (or build the content with `innerHTML`, which releases
266
+ what it replaces) instead of creating fresh ones per frame.
267
+
233
268
  ### AOT scripts (M9)
234
269
 
235
270
  Import an `.html` file that contains inline `<script lang="ts">` bodies; the
@@ -650,6 +685,19 @@ used by default; Wayland is enabled too but not yet exercised.
650
685
  participates in the layout instead of being dropped (`layout_flex.cpp`).
651
686
  - Drives `examples/gui/pelican-bike` and e2e coverage in
652
687
  `tests/e2e/gui-layout.test.ts`.
688
+ 12. **M12 — node lifetime + memory** ✅
689
+ - What a mutation releases and what it keeps is now explicit: the
690
+ `innerHTML`/`inner`/`textContent`/`replaceChild` paths free the subtrees
691
+ they drop (`XtDocument::discard`, `domapi::discard_subtree`), while
692
+ `removeChild`/`remove` keep DOM semantics (`document.*`, `dom_api_*.cpp`).
693
+ - Handle slots are recycled with a per-slot generation, so a stale handle
694
+ stays inert while the table stops growing with churn (`dom_api.cpp`,
695
+ `gui_engine.h`).
696
+ - The collector's trigger follows the live set instead of only ratcheting up,
697
+ so a leaking or spiking program cannot spiral (`runtime/xt_alloc.c`).
698
+ `XT_GC_TRACE=1` reports every collection.
699
+ - `win.stats()` exposes the engine counters; e2e coverage in
700
+ `tests/e2e/gui-retention.test.ts` (see "Memory & GC").
653
701
 
654
702
  ## Progress log
655
703
 
@@ -707,6 +755,55 @@ used by default; Wayland is enabled too but not yet exercised.
707
755
  content so they never affect it, shrink-to-fit/fill-available width rules are
708
756
  applied, and flex items now account for their margins. e2e coverage in
709
757
  `tests/e2e/gui-layout.test.ts`; `examples/gui/pelican-bike` is built on it.
758
+ - **M12** ✅ DOM node lifetime and memory. `innerHTML`/`textContent`/
759
+ `replaceChild` now release the subtrees they drop instead of parking them in
760
+ the detached pool forever, `removeChild` keeps its documented DOM semantics,
761
+ handle slots are recycled with a per-slot generation so stale handles stay
762
+ inert, and the collector's trigger follows the live set instead of only
763
+ ratcheting upwards. `win.stats()` exposes the engine counters and
764
+ `tests/e2e/gui-retention.test.ts` pins them; a game loop rewriting its sprite
765
+ every frame now runs in constant memory (see "Memory & GC").
766
+
767
+ ## Memory & GC
768
+
769
+ The engine owns most of a GUI program's memory outside the runtime heap: the DOM
770
+ tree, the detached-node pool, the element-handle tables, the layout box tree and
771
+ the display list are C++, so the mark-sweep collector cannot see them. `stats()`
772
+ (see "Diagnostics") reports the parts that can grow:
773
+
774
+ | Counter | Expected in a steady state |
775
+ | --- | --- |
776
+ | `treeNodes` | constant — the authored document |
777
+ | `detachedRoots`, `detachedNodes` | 0, or constant for nodes the script removed itself and still holds |
778
+ | `handles`, `handleSlots` | constant — one per element the script still refers to |
779
+ | `listeners` | constant — one entry per element with listeners |
780
+ | `heapBytes`, `heapAllocations` | sawtooth around the collector's threshold |
781
+
782
+ Everything else is per-frame scratch (boxes, display list, vertex staging) that is
783
+ freed before the next frame. A counter that climbs with the frame count is the
784
+ leak signature to look for first; `tests/e2e/gui-retention.test.ts` asserts that
785
+ the usual mutation patterns leave all of them unchanged.
786
+
787
+ Two engine details matter when reading those numbers:
788
+
789
+ - **What releases nodes.** `innerHTML`/`inner`/`textContent`/`replaceChild`
790
+ release the subtrees they drop; `removeChild`/`remove` keep them (see "Node
791
+ lifetime"). A game that rebuilds a sprite with `innerHTML` therefore runs in
792
+ constant memory.
793
+ - **What can still accumulate.** A node created with `createElement` and never
794
+ attached stays in the pool, because the collector has no weak references and
795
+ the engine cannot tell that the script dropped its handle. The same applies to
796
+ a node removed and then forgotten by the script. Both are bounded by the number
797
+ of *live* handles, but a program that creates elements per frame without
798
+ attaching them grows the pool with the frame count.
799
+
800
+ The collector behind `heapBytes` is a non-moving mark-sweep (`runtime/xt_alloc.c`).
801
+ Its trigger follows the live set: after every collection the threshold becomes
802
+ twice the surviving heap (floor 1 MiB), so a small live set means small, frequent
803
+ collections and a bounded resident set. `XT_GC_THRESHOLD=<bytes>` pins the
804
+ threshold instead, and `XT_GC_TRACE=1` prints one line per collection (heap and
805
+ live-allocation counts before and after, the next trigger, and the live set by
806
+ object kind) to stderr — that is how the DOM leak was traced to the node pool.
710
807
 
711
808
  ## Known issues
712
809
 
@@ -725,9 +822,15 @@ used by default; Wayland is enabled too but not yet exercised.
725
822
  handles, element listeners and the queued/currently-running
726
823
  `requestAnimationFrame` callbacks. They are now marked by GC root providers
727
824
  (`xt_gui_script_gc_scan` and `xt_gui_gc_scan_roots`), which removes that
728
- class of use-after-free. `tests/e2e/gui-example.test.ts` still treats either
729
- crash spelling as an acceptable outcome — the archive is built per platform
730
- and the run is timing-sensitive — and the behavioural assertions live in
825
+ class of use-after-free. Since the M12 node-lifetime work it has not
826
+ reproduced at all: 44 auto-close runs of `examples/gui/pelican-bike` (deadlines
827
+ 800 ms–4 s, with the default, a 1 MiB and a 64 MiB collector threshold, where
828
+ the old report said roughly two in five) all exited cleanly, which points at
829
+ the same root cause — a detached-node pool and handle table that grew with the
830
+ frame count while the collector's threshold ratcheted up. It stays listed
831
+ because the archive is built per platform and the failure is timing-sensitive;
832
+ `tests/e2e/gui-example.test.ts` still treats either crash spelling as an
833
+ acceptable outcome, and the behavioural assertions live in
731
834
  `tests/e2e/gui-example-browser.test.ts` (no GPU, deterministic).
732
835
  - **`display: none -> flex` restyle.** A hidden element being shown by a
733
836
  *class change* on a flex container has also been observed to crash the same
@@ -247,7 +247,7 @@ These features **compile and run**, but the result does not fully match ECMAScri
247
247
 
248
248
  | Item | Status |
249
249
  | --- | --- |
250
- | GC (garbage collection) | ✓ non-moving mark-sweep in `xt_alloc.c`, armed by a live-heap threshold (`XT_GC_THRESHOLD`) or `xt_gc_collect()`; explicit roots + root providers + conservative active-stack scan. No compaction, weak references or background collection |
250
+ | GC (garbage collection) | ✓ non-moving mark-sweep in `xt_alloc.c`, armed by a live-heap threshold (`XT_GC_THRESHOLD` pins it) or `xt_gc_collect()`; after each collection the threshold becomes twice the surviving heap (floor 1 MiB), so the resident set follows the live set instead of only growing. `XT_GC_TRACE=1` prints one line per collection to stderr. Explicit roots + root providers + conservative active-stack scan. No compaction, weak references or background collection — without weak references an extension must keep a strong reference to every value it caches (the GUI's element handles do) |
251
251
  | Self-hosting | ✓ the compiler compiles itself: `xbintsc build src/cli/main.ts` produces a working binary, and the emitted IR is stable from generation 1 onward. The runtime is still C |
252
252
  | Type checker | ✗ only diagnostic codes are defined; no checker |
253
253
  | Full standard library | partial: Math / JSON / Date / Map / Set / RegExp / `Error` / `BigInt` / `Symbol` implemented; String.normalize / structuredClone missing |
package/doc/zh-CN/gui.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > 语言 / Language:[English](../gui.md) | **简体中文**
4
4
 
5
- 状态:**M11** —— 功能(M1–M11)已全部完成:HTML 解析、CSS 选择器匹配、层叠(cascade)、计算样式与布局(块级、行内、Flexbox 与 CSS 定位)均已就位,引擎还会**绘制**:它构建一个由矩形、图像与排版后的文本 run 组成的显示列表,并通过 SDL_GPU 渲染。输入事件会经过命中测试并投递给原生 TS 处理器,`:hover`/`:focus` 会被动态匹配,`<img>` 依据其固有尺寸确定大小并从纹理绘制,CSS transition 会为绘制属性做动画。**M8** 增加了交互式 DOM:具有稳定标识的元素句柄、变更操作(`appendChild`、`textContent`、`classList`、`style` 等)以及支持捕获/冒泡的元素级事件。**M9** 会提前编译 `<script>` 主体(内联与 `<script src>`)——参见 `gui-scripts.md`。**M10** 增加 `requestAnimationFrame` 以及若干 DOM 辅助方法。**M11** 增加 `position: relative`/`absolute`/`fixed` 及其偏移,`examples/gui/pelican-bike`(一个可玩的 2D 游戏)就构建在它之上。本文档记录了一个跨平台 GUI 扩展的锁定决策、架构、里程碑计划与当前进度;该扩展使用自有的 GPU 加速引擎渲染 HTML/CSS UI。
5
+ 状态:**M12** —— 功能(M1–M11)已全部完成:HTML 解析、CSS 选择器匹配、层叠(cascade)、计算样式与布局(块级、行内、Flexbox 与 CSS 定位)均已就位,引擎还会**绘制**:它构建一个由矩形、图像与排版后的文本 run 组成的显示列表,并通过 SDL_GPU 渲染。输入事件会经过命中测试并投递给原生 TS 处理器,`:hover`/`:focus` 会被动态匹配,`<img>` 依据其固有尺寸确定大小并从纹理绘制,CSS transition 会为绘制属性做动画。**M8** 增加了交互式 DOM:具有稳定标识的元素句柄、变更操作(`appendChild`、`textContent`、`classList`、`style` 等)以及支持捕获/冒泡的元素级事件。**M9** 会提前编译 `<script>` 主体(内联与 `<script src>`)——参见 `gui-scripts.md`。**M10** 增加 `requestAnimationFrame` 以及若干 DOM 辅助方法。**M11** 增加 `position: relative`/`absolute`/`fixed` 及其偏移,`examples/gui/pelican-bike`(一个可玩的 2D 游戏)就构建在它之上。**M12** 明确了 DOM 节点生命周期(哪些变更释放、哪些保留),并让引擎内存有界:参见"节点生命周期"与"内存与 GC"。本文档记录了一个跨平台 GUI 扩展的锁定决策、架构、里程碑计划与当前进度;该扩展使用自有的 GPU 加速引擎渲染 HTML/CSS UI。
6
6
 
7
7
  ## 目标
8
8
 
@@ -161,8 +161,15 @@ win.fontMetrics(fontSize?, family?) // { ascent, descent, lineHeight, ready }
161
161
  win.hitTest(x, y) // 最深的元素描述符,或 ""
162
162
  win.sendEvent(type, options?) // 合成输入(测试用)
163
163
  win.advance(ms) // 推进 CSS transition 时钟(测试用)
164
+ win.stats() // 引擎/运行时计数器(内存排查用)
164
165
  ```
165
166
 
167
+ `win.stats()` 返回 `{ heapBytes, heapAllocations, treeNodes, detachedRoots,`
168
+ `detachedNodes, handles, handleSlots, listeners }`:运行时存活堆、DOM 树规模、
169
+ 分离节点池(detached pool)以及元素句柄表。稳定状态下它们都应保持常量——若
170
+ `detachedNodes` 或 `handles` 持续上升,说明程序移除节点的速度快于释放速度
171
+ (见下文"节点生命周期")。
172
+
166
173
  它们被 `tests/e2e/gui-*.test.ts` 用来断言解析、选择器匹配、特异性、继承、
167
174
  `!important` 与布局几何。它们在之后仍可用于调试。
168
175
 
@@ -198,6 +205,26 @@ console.log(box.contains(inner)); // 后代判断
198
205
  旧式 `win.on(type, fn)` 负载保留其**字符串** `e.target`(`div#id.class`);
199
206
  元素 `Event.target` 是一个句柄,其描述符与同一字符串匹配。
200
207
 
208
+ #### 节点生命周期
209
+
210
+ 一次变更会决定它移除的节点是否仍可到达,引擎只释放那些确实不可到达的节点:
211
+
212
+ | 变更 | 被移除的节点 |
213
+ | --- | --- |
214
+ | `innerHTML = …`、`inner = …`、`textContent = …`、`replaceChild` | **被释放**——脚本再也无法引用它们 |
215
+ | `removeChild`、`remove` | **保留**在文档的分离节点池中——DOM 语义要求被移除的节点继续存活且可用,以便重新插入 |
216
+
217
+ 被释放的节点会同时释放其 C++ 子树与句柄,因此 `win.stats()` 的计数器
218
+ (`detachedNodes`、`handles`、`handleSlots`)会回到先前的数值。这正是常见游戏
219
+ 写法——每帧用 `innerHTML` 重写精灵——能保持常量内存的原因;在此之前,每个被
220
+ 替换的子树都会永久留在池中,每帧写入十个节点的循环会以每秒数 MB 的速度泄漏。
221
+
222
+ 引擎唯一无法察觉的情况是:脚本创建了一个节点,随后从未插入就丢弃它。
223
+ `document.createElement("div")` 会把节点放进池中,**并且**让它的句柄保持可达,
224
+ 因为标记-清扫收集器没有弱引用,引擎无法知道脚本已经丢弃了该句柄。因此,
225
+ 只创建、不插入的循环会让池持续增长。请复用元素,或用 `innerHTML` 构建内容
226
+ (它会释放被替换的节点),而不要每帧新建元素。
227
+
201
228
  ### AOT 脚本(M9)
202
229
 
203
230
  导入一个包含内联 `<script lang="ts">` 主体的 `.html` 文件;加载器会把每个
@@ -574,6 +601,17 @@ xvfb-run -a --server-args="-screen 0 1280x720x24" \
574
601
  (`layout_flex.cpp`)。
575
602
  - `examples/gui/pelican-bike` 基于它实现,e2e 覆盖见
576
603
  `tests/e2e/gui-layout.test.ts`。
604
+ 12. **M12 — 节点生命周期 + 内存** ✅
605
+ - 明确区分变更释放与保留的内容:`innerHTML`/`inner`/`textContent`/
606
+ `replaceChild` 会释放被丢弃的子树(`XtDocument::discard`、
607
+ `domapi::discard_subtree`),而 `removeChild`/`remove` 保留 DOM 语义
608
+ (`document.*`、`dom_api_*.cpp`)。
609
+ - 句柄槽位带"每槽代次"地复用,过期句柄保持惰性失效,同时句柄表不再随
610
+ 变更次数增长(`dom_api.cpp`、`gui_engine.h`)。
611
+ - 收集器触发阈值改为跟随存活集而非只升不降,避免泄漏或存活集尖峰演变成
612
+ 内存失控(`runtime/xt_alloc.c`)。`XT_GC_TRACE=1` 会报告每次回收。
613
+ - `win.stats()` 暴露引擎计数器;e2e 覆盖见
614
+ `tests/e2e/gui-retention.test.ts`(见"内存与 GC")。
577
615
 
578
616
  ## 进度日志
579
617
 
@@ -628,6 +666,45 @@ xvfb-run -a --server-args="-screen 0 1280x720x24" \
628
666
  在正常流内容之后布局,因此不影响它们;实现了 shrink-to-fit / 填满可用宽度
629
667
  两种规则;flex 项现在会计入外边距。e2e 覆盖在
630
668
  `tests/e2e/gui-layout.test.ts`;`examples/gui/pelican-bike` 基于它实现。
669
+ - **M12** ✅ DOM 节点生命周期与内存。`innerHTML`/`textContent`/`replaceChild`
670
+ 现在会释放它们丢弃的子树,而不是把它们永久留在分离节点池里;`removeChild`
671
+ 保留其文档化的 DOM 语义;句柄槽位会带"每槽代次"地复用,使过期句柄保持惰性
672
+ 失效;收集器的触发阈值改为跟随存活集,而不再只升不降。`win.stats()` 暴露
673
+ 引擎计数器,`tests/e2e/gui-retention.test.ts` 对其做断言;每帧重写精灵的游戏
674
+ 循环现在内存恒定(见"内存与 GC")。
675
+
676
+ ## 内存与 GC
677
+
678
+ GUI 程序的大部分内存不在运行时堆里:DOM 树、分离节点池、元素句柄表、布局盒树
679
+ 与显示列表都是 C++,标记-清扫收集器看不到它们。`stats()`(见"诊断")报告其中
680
+ 可能增长的部分:
681
+
682
+ | 计数器 | 稳定状态下的期望值 |
683
+ | --- | --- |
684
+ | `treeNodes` | 常量——即编写的文档 |
685
+ | `detachedRoots`、`detachedNodes` | 0;若脚本自己移除了节点并仍持有,则为常量 |
686
+ | `handles`、`handleSlots` | 常量——每个脚本仍引用的元素一个 |
687
+ | `listeners` | 常量——每个注册了监听器的元素一个 |
688
+ | `heapBytes`、`heapAllocations` | 在收集器阈值附近呈锯齿波动 |
689
+
690
+ 其余都是每帧用完即释放的临时数据(盒、显示列表、顶点暂存)。若某个计数器随帧数
691
+ 持续上升,那就是最该先查的泄漏特征;`tests/e2e/gui-retention.test.ts` 断言常见
692
+ 变更模式不会改变这些计数。
693
+
694
+ 读这些数字时有两处引擎细节需要注意:
695
+
696
+ - **谁会释放节点。** `innerHTML`/`inner`/`textContent`/`replaceChild` 会释放它们
697
+ 丢弃的子树;`removeChild`/`remove` 会保留(见"节点生命周期")。因此用
698
+ `innerHTML` 每帧重建精灵的游戏内存恒定。
699
+ - **什么仍会累积。** 用 `createElement` 创建却从未插入的节点会留在池中:收集器
700
+ 没有弱引用,引擎无法知道脚本已丢弃其句柄。脚本移除后又不再引用的节点同理。
701
+ 两者都以*存活句柄*数量为界,但每帧创建元素却不插入的程序会让池随帧数增长。
702
+
703
+ `heapBytes` 背后的收集器是 `runtime/xt_alloc.c` 中的非移动标记-清扫。它的触发
704
+ 阈值跟随存活集:每次回收后阈值变为存活堆的 2 倍(下限 1 MiB),因此存活集小
705
+ 就意味着回收频繁而轻量、常驻内存有界。用 `XT_GC_THRESHOLD=<bytes>` 可以固定
706
+ 阈值;`XT_GC_TRACE=1` 会为每次回收向 stderr 打印一行(回收前后的堆与存活分配数、
707
+ 下一次触发阈值,以及按对象类型统计的存活集)——DOM 泄漏正是这样被定位到节点池的。
631
708
 
632
709
  ## 已知问题
633
710
 
@@ -635,7 +712,15 @@ xvfb-run -a --server-args="-screen 0 1280x720x24" \
635
712
  访问违例(`0xC0000005`)退出,其余运行与正常退出无异。该问题只在**动画帧循环运行中**复现
636
713
  (静态文档可以正常关闭),且**不是示例游戏自身**的代码:没有任何异常信息,崩溃多发生在
637
714
  启动后一秒内、与截止时间无关,而提高 `XT_GC_THRESHOLD` 可以掩盖它,因此看起来是
638
- GC/析构竞态。交互式运行(不设自动关闭)可稳定运行数分钟。`tests/e2e/gui-example.test.ts`
715
+ GC/析构竞态。交互式运行(不设自动关闭)可稳定运行数分钟。此前的一个已知成因是引擎在标记-清扫
716
+ 收集器看不见的 C++ 容器里缓存 `xt_value`(AOT `<script>` 注册表、元素句柄、元素监听器、
717
+ 排队/正在运行的 `requestAnimationFrame` 回调),现在它们由 GC 根提供者标记
718
+ (`xt_gui_script_gc_scan` 与 `xt_gui_gc_scan_roots`),该类 use-after-free 已消除。
719
+ 在 M12 的节点生命周期改动之后它已完全无法复现:对
720
+ `examples/gui/pelican-bike` 做了 44 次自动关闭运行(截止 800 ms–4 s,分别使用默认、
721
+ 1 MiB 与 64 MiB 的收集阈值,而旧记录称约五次两次崩溃),全部正常退出,指向与内存问题
722
+ 相同的根因——分离节点池与句柄表随帧数增长,同时收集器阈值只升不降。此处仍保留该条目,
723
+ 因为该归档按平台构建且故障与时机相关。`tests/e2e/gui-example.test.ts`
639
724
  因此把该退出码视为可接受结果,行为断言放在不依赖 GPU 的
640
725
  `tests/e2e/gui-example-browser.test.ts` 中。
641
726
  - **`display: none -> flex` 的重排。** 通过类名切换让隐藏元素显示在 flex 容器上时,也曾观察
Binary file
@@ -0,0 +1,31 @@
1
+ # 使用xbintsc编译了一款小游戏的体验
2
+ 起因想要用TS/JS做一个桌面端小游戏,但选择框架的时候,但是发现并没有一个很好的选择。
3
+ 1. Electron 过于庞大,内部集成了node.js和chrome谷歌浏览器
4
+ 2. Tauri 虽然客户端小,但是使用系统自带的 WebView 渲染,会导致不同机器运行结果不一致
5
+
6
+ 于是我准备使用xbintsc,去编译这款桌面小游戏。
7
+
8
+ 首先xbintsc优势:
9
+ 1. 构建体积小,最小可达200kb,编译可选node扩展(支持部分node.js的API)最小可达300kb,编译可选GUI扩展(可视化界面HTML渲染)最小可达3MB
10
+ 2. 冷启动速度是node.js的120倍+,且原生支持HTML+CSS渲染。
11
+ 3. 全平台支持(使用LLVM构建不同平台二进制产物,使用GPU的SDL进行渲染并在不同平台使用Vulkan/Metal进行编译)
12
+
13
+ 直接让AI开发了鹈鹕骑车小游戏,并使用xbintsc编译了,截图如下:
14
+ ![鹈鹕骑车小游戏截图](./pelican-bike.png)
15
+
16
+ 编译后的结果大概是5M左右,运行效果很流畅,当然直接打开源码的HTML也可以直接玩。
17
+
18
+ 小游戏源码地址:[https://github.com/zy445566/xbintsc/tree/main/examples/gui/pelican-bike](https://github.com/zy445566/xbintsc/tree/main/examples/gui/pelican-bike)
19
+
20
+ 当然有兴趣下载鹈鹕骑车小游戏编译结果,也编译了多个不同平台windows/mac/ubutun26+的版本。
21
+
22
+ 编译结果地址:[https://github.com/zy445566/xbintsc/releases/tag/v0.3.65](https://github.com/zy445566/xbintsc/releases/tag/v0.3.65)
23
+
24
+ 选择pelican-bike-demo-v0.3.65-multi-platform.zip下载,解压后选择自己的平台就能运行。
25
+
26
+ 由于该游戏目前没有做签名,所以可能会有系统的安全性拦截。
27
+ macOS 打开后需要:打开“系统设置” → “隐私与安全性”,向下滚动到“安全性”,选择“仍要打开”运行。
28
+ Windows 打开后,如出现“Windows 已保护你的电脑”,点击“更多信息”,点击“仍要运行”。
29
+
30
+
31
+ 大家有兴趣,也可以用xbintsc编译一款自己的小游戏,甚至可以跟Ai Agent说“用xbintsc(https://github.com/zy445566/xbintsc)帮我生成一个鹈鹕骑车小游戏”
@@ -211,7 +211,7 @@
211
211
 
212
212
  | 项 | 现状 |
213
213
  | --- | --- |
214
- | GC(垃圾回收) | ✓ `xt_alloc.c` 中的非移动标记-清扫,按存活堆阈值(`XT_GC_THRESHOLD`)或 `xt_gc_collect()` 触发;显式根 + 根提供者 + 保守活动栈扫描。无压缩、弱引用或后台回收 |
214
+ | GC(垃圾回收) | ✓ `xt_alloc.c` 中的非移动标记-清扫,按存活堆阈值(`XT_GC_THRESHOLD` 可固定该阈值)或 `xt_gc_collect()` 触发;每次回收后阈值变为存活堆的 2 倍(下限 1 MiB),因此常驻内存跟随存活集而不是只增不减;`XT_GC_TRACE=1` 会为每次回收向 stderr 打印一行统计。显式根 + 根提供者 + 保守活动栈扫描。无压缩、弱引用或后台回收(缺少弱引用意味着扩展必须对它缓存的每个值做强引用,例如 GUI 的元素句柄) |
215
215
  | 自举(self-hosting) | ✓ 编译器已能自编译:`xbintsc build src/cli/main.ts` 可产出可用二进制,且从第 1 代起产出的 IR 保持稳定。运行时仍为 C |
216
216
  | 类型检查器 | ✗ 仅定义诊断码,无 checker |
217
217
  | 完整标准库 | 部分:Math / JSON / Date / Map / Set / RegExp / `Error` / `BigInt` / `Symbol` 已实现;String.normalize / structuredClone 缺失 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xbintsc",
3
- "version": "0.3.65",
3
+ "version": "0.3.68",
4
4
  "description": "xbintsc - a TypeScript binary compiler (TypeScript -> LLVM IR -> native binary)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -351,4 +351,31 @@ void XtDocument::detach(Node *node) {
351
351
  invalidate();
352
352
  }
353
353
 
354
+ void XtDocument::discard(Node *node) {
355
+ if (node == nullptr || node == root_.get()) return;
356
+ auto it = detached_.find(node);
357
+ if (it == detached_.end()) return;
358
+ /* Children are owned by their parent, never by the pool (takeNode erases the
359
+ * pool entry when a node is attached), so dropping the root drops it all. */
360
+ detached_.erase(it);
361
+ }
362
+
363
+ namespace {
364
+
365
+ size_t countSubtree(const Node *node) {
366
+ size_t total = 1;
367
+ for (const std::unique_ptr<Node> &child : node->children) total += countSubtree(child.get());
368
+ return total;
369
+ }
370
+
371
+ } // namespace
372
+
373
+ size_t XtDocument::treeNodeCount() const { return root_ == nullptr ? 0 : countSubtree(root_.get()); }
374
+
375
+ size_t XtDocument::detachedNodeCount() const {
376
+ size_t total = 0;
377
+ for (const auto &entry : detached_) total += countSubtree(entry.second.get());
378
+ return total;
379
+ }
380
+
354
381
  } // namespace xtgui
@@ -75,6 +75,18 @@ class XtDocument {
75
75
  Node *replaceChild(Node *parent, Node *newChild, Node *oldChild);
76
76
  /** Release a node from the document entirely (still owned, detached). */
77
77
  void detach(Node *node);
78
+ /** Free a detached node and its subtree. The caller must already have
79
+ * forgotten every handle in that subtree (see `domapi::discard_subtree`), so
80
+ * no slot is left pointing at freed memory. Nodes still attached to the tree
81
+ * are ignored: use `removeChild`/`detach` for those. */
82
+ void discard(Node *node);
83
+
84
+ /** Nodes in the document tree (diagnostics). */
85
+ size_t treeNodeCount() const;
86
+ /** Nodes held by the detached pool, subtrees included (diagnostics). */
87
+ size_t detachedNodeCount() const;
88
+ /** Top-level entries in the detached pool (diagnostics). */
89
+ size_t detachedRootCount() const { return detached_.size(); }
78
90
 
79
91
  /** Mark the document as needing a restyle/relayout before the next paint. */
80
92
  void invalidate() { dirty_ = true; }
@@ -64,7 +64,13 @@ xtgui::Node *resolve_node(xt_value self, XtGuiWindow **winOut) {
64
64
  if (!XT_IS_NUMBER(index)) return nullptr;
65
65
  int slot = (int)xt_to_number(index);
66
66
  if (slot < 0 || slot >= (int)win->node_order.size()) return nullptr;
67
- return win->node_order[(size_t)slot];
67
+ /* A recycled slot must not answer for the handle that used to own it. */
68
+ xt_value generation = xt_object_get_cstr(self, "__xt_gui_slot_gen");
69
+ if (!XT_IS_NUMBER(generation) ||
70
+ (int)xt_to_number(generation) != win->node_order[(size_t)slot].generation) {
71
+ return nullptr;
72
+ }
73
+ return const_cast<xtgui::Node *>(win->node_order[(size_t)slot].node);
68
74
  }
69
75
 
70
76
  void mark_dirty(XtGuiWindow *win) {
@@ -123,15 +129,26 @@ xt_value xt_gui_node_handle(XtGuiWindow *win, const xtgui::Node *node) {
123
129
  auto found = win->node_index.find(node);
124
130
  if (found != win->node_index.end()) {
125
131
  slot = found->second;
132
+ } else if (!win->free_node_slots.empty()) {
133
+ /* Reuse a forgotten slot instead of growing the table forever. The
134
+ * generation bump below makes the handle that used to own it inert. */
135
+ slot = win->free_node_slots.back();
136
+ win->free_node_slots.pop_back();
137
+ win->node_order[(size_t)slot].node = node;
138
+ win->node_index[node] = slot;
126
139
  } else {
127
140
  slot = (int)win->node_order.size();
128
- win->node_order.push_back(const_cast<xtgui::Node *>(node));
141
+ XtGuiNodeSlot entry;
142
+ entry.node = node;
143
+ win->node_order.push_back(entry);
129
144
  win->node_index[node] = slot;
130
145
  }
131
146
  xt_value handle = xt_object_new_with_proto(domapi::node_proto());
132
147
  xt_object_set(handle, xt_string_from_cstr("__xt_gui_win"), win->object);
133
148
  xt_object_set(handle, xt_string_from_cstr("__xt_gui_gen"), xt_number(win->doc_generation));
134
149
  xt_object_set(handle, xt_string_from_cstr("__xt_gui_node"), xt_number((double)slot));
150
+ xt_object_set(handle, xt_string_from_cstr("__xt_gui_slot_gen"),
151
+ xt_number((double)win->node_order[(size_t)slot].generation));
135
152
  win->node_handles[node] = handle;
136
153
  return handle;
137
154
  }
@@ -143,18 +160,36 @@ void xt_gui_node_handle_forget(XtGuiWindow *win, const xtgui::Node *node) {
143
160
  * `resolve_node` reads, and leaving a freed pointer there is a
144
161
  * use-after-free the next time any handle is resolved. Existing handles keep
145
162
  * resolving to "stale" and no-op, which is the documented tombstone
146
- * behaviour. */
163
+ * behaviour — the generation bump makes the recycled slot unavailable to
164
+ * them. */
147
165
  auto index = win->node_index.find(node);
148
166
  if (index != win->node_index.end()) {
149
167
  int slot = index->second;
150
168
  if (slot >= 0 && slot < (int)win->node_order.size()) {
151
- win->node_order[(size_t)slot] = nullptr;
169
+ win->node_order[(size_t)slot].node = nullptr;
170
+ win->node_order[(size_t)slot].generation++;
171
+ win->free_node_slots.push_back(slot);
152
172
  }
153
173
  win->node_index.erase(index);
154
174
  }
155
175
  win->node_handles.erase(node);
156
176
  }
157
177
 
178
+ /** Forget the handles of `node` and every descendant. Runs before the subtree
179
+ * is freed, so the walk still reads live children. */
180
+ static void forget_subtree_handles(XtGuiWindow *win, const xtgui::Node *node) {
181
+ xt_gui_node_handle_forget(win, node);
182
+ for (const std::unique_ptr<xtgui::Node> &child : node->children) {
183
+ forget_subtree_handles(win, child.get());
184
+ }
185
+ }
186
+
187
+ void domapi::discard_subtree(XtGuiWindow *win, xtgui::Node *node) {
188
+ if (win == nullptr || node == nullptr || win->document == nullptr) return;
189
+ forget_subtree_handles(win, node);
190
+ win->document->discard(node);
191
+ }
192
+
158
193
  xt_value xt_gui_document_handle(XtGuiWindow *win) {
159
194
  if (win == nullptr) return XT_UNDEFINED;
160
195
  if (!XT_IS_OBJECT(win->document_object)) {
@@ -172,6 +207,7 @@ void xt_gui_handles_reset(XtGuiWindow *win) {
172
207
  if (win == nullptr) return;
173
208
  win->doc_generation += 1.0;
174
209
  win->node_order.clear();
210
+ win->free_node_slots.clear();
175
211
  win->node_index.clear();
176
212
  win->node_handles.clear();
177
213
  win->node_listeners.clear();
@@ -49,6 +49,12 @@ std::string class_name_of(const xtgui::Node *node);
49
49
  void set_class_name(XtGuiWindow *win, xtgui::Node *node, const std::string &value);
50
50
  void remove_listener(XtGuiWindow *win, xtgui::Node *node, const std::string &type, xt_value fn,
51
51
  bool capture);
52
+ /** Forget the handles of `node` and its descendants, then free the subtree from
53
+ * the document's detached pool. Use this only for nodes the script can no longer
54
+ * reach (the children `innerHTML = …` replaces, the node `replaceChild` drops):
55
+ * an existing handle then resolves to "stale" and no-ops, exactly like a handle
56
+ * to a node the document already removed and released. */
57
+ void discard_subtree(XtGuiWindow *win, xtgui::Node *node);
52
58
 
53
59
  /* -- method definitions (uniform `(self, env, argc, argv)` ABI) ----------- */
54
60
 
@@ -177,9 +177,13 @@ METHOD(node_replace_child) {
177
177
  xtgui::Node *newChild = resolve_node(xt_arg(argc, argv, 0), nullptr);
178
178
  xtgui::Node *oldChild = resolve_node(xt_arg(argc, argv, 1), nullptr);
179
179
  if (newChild == nullptr || oldChild == nullptr) return XT_UNDEFINED;
180
- /* `replaceChild` frees the node it replaces, so its slot may not survive. */
180
+ /* `replaceChild` does not give the replaced node back to the script: the
181
+ * engine drops it (and its subtree) outright, so its handle may not survive.
182
+ * A null result means nothing was replaced (the old node was not a child of
183
+ * `node`, or the move was refused), and then the old node is still live and
184
+ * must keep its handle. */
181
185
  xtgui::Node *replaced = win->document->replaceChild(node, newChild, oldChild);
182
- if (replaced != oldChild) xt_gui_node_handle_forget(win, oldChild);
186
+ if (replaced != nullptr && replaced != oldChild) discard_subtree(win, oldChild);
183
187
  return xt_gui_node_handle(win, replaced);
184
188
  }
185
189
 
@@ -25,13 +25,6 @@ static void collect_text(const xtgui::Node *node, std::string &out) {
25
25
  for (const std::unique_ptr<xtgui::Node> &child : node->children) collect_text(child.get(), out);
26
26
  }
27
27
 
28
- /** Forget the handles of `node` and every descendant: they are about to be
29
- * freed, and a slot left behind would dangle. */
30
- static void forget_subtree(XtGuiWindow *win, const xtgui::Node *node) {
31
- xt_gui_node_handle_forget(win, node);
32
- for (const std::unique_ptr<xtgui::Node> &child : node->children) forget_subtree(win, child.get());
33
- }
34
-
35
28
  #define METHOD(name) xt_value name(xt_value self, xt_value env, int32_t argc, xt_value *argv)
36
29
 
37
30
  METHOD(node_tag_name_get) {
@@ -112,7 +105,10 @@ METHOD(node_text_content_set) {
112
105
  std::vector<xtgui::Node *> existing;
113
106
  for (const std::unique_ptr<xtgui::Node> &child : node->children) existing.push_back(child.get());
114
107
  for (xtgui::Node *child : existing) win->document->removeChild(node, child);
115
- for (xtgui::Node *child : existing) forget_subtree(win, child);
108
+ /* The replaced children are unreachable now: `removeChild` only parks them in
109
+ * the detached pool, and this setter is what drops them. Forgetting their
110
+ * handles without releasing them would leak a node per write. */
111
+ for (xtgui::Node *child : existing) discard_subtree(win, child);
116
112
  if (!text.empty()) node->addText(text);
117
113
  mark_dirty(win);
118
114
  return XT_UNDEFINED;
@@ -140,10 +136,12 @@ METHOD(node_inner_html_set) {
140
136
  std::vector<xtgui::Node *> existing;
141
137
  for (const std::unique_ptr<xtgui::Node> &child : node->children) existing.push_back(child.get());
142
138
  for (xtgui::Node *child : existing) win->document->removeChild(node, child);
143
- /* The old children are dropped here (only `removeChild` keeps a detached node
144
- * alive), so forget them *and everything below them* before they are freed:
145
- * a leftover slot would be a dangling pointer for the next handle lookup. */
146
- for (xtgui::Node *child : existing) forget_subtree(win, child);
139
+ /* The old children are dropped here: `removeChild` only parks them in the
140
+ * detached pool (so a script that detached a node itself can re-append it),
141
+ * and replacing them wholesale is what releases them. Forgetting their
142
+ * handles without releasing the nodes would leak one subtree per assignment —
143
+ * a per-frame `innerHTML` write in a game loop then grows without bound. */
144
+ for (xtgui::Node *child : existing) discard_subtree(win, child);
147
145
  std::unique_ptr<xtgui::Node> fragment = xtgui::xt_html_parse(html);
148
146
  for (std::unique_ptr<xtgui::Node> &child : fragment->children) node->append(std::move(child));
149
147
  mark_dirty(win);
@@ -55,6 +55,15 @@ struct XtGuiAnimationFrame {
55
55
  xt_value fn;
56
56
  };
57
57
 
58
+ /** One row of the window's node table: the node a handle addresses, plus the
59
+ * generation that row was issued for. A slot is recycled after its node is
60
+ * forgotten, and the generation is what keeps an old handle from resolving
61
+ * through the recycled slot to an unrelated node. */
62
+ struct XtGuiNodeSlot {
63
+ const xtgui::Node *node = nullptr;
64
+ int generation = 0;
65
+ };
66
+
58
67
  /* One live window. `object` is the JavaScript-visible handle; the record is
59
68
  * addressed from it through the hidden `__xt_gui_index` property. */
60
69
  struct XtGuiWindow {
@@ -78,7 +87,9 @@ struct XtGuiWindow {
78
87
  /** Bumped on every `loadHTML`; stale handles no-op. */
79
88
  double doc_generation = 1.0;
80
89
  /** Node -> table index / cached handle, so identity is stable. */
81
- std::vector<xtgui::Node *> node_order;
90
+ std::vector<XtGuiNodeSlot> node_order;
91
+ /** Slots whose node was forgotten and which a later handle may reuse. */
92
+ std::vector<int> free_node_slots;
82
93
  std::unordered_map<const xtgui::Node *, int> node_index;
83
94
  std::unordered_map<const xtgui::Node *, xt_value> node_handles;
84
95
  /** Element-level listeners, keyed by node. */
@@ -461,6 +461,40 @@ static xt_value win_send_event(xt_value self, xt_value env, int32_t argc, xt_val
461
461
  return XT_TRUE;
462
462
  }
463
463
 
464
+ /** Diagnostic/test hook: engine counters as one object. `heapBytes` and
465
+ * `heapAllocations` are the live runtime heap, `treeNodes`/`detachedNodes` the
466
+ * DOM tree and the detached pool, and `handles`/`listeners` the element-handle
467
+ * tables. `detachedNodes` should stay flat in a steady state: a climbing value
468
+ * means the program keeps removing nodes faster than it releases them. */
469
+ static xt_value win_stats(xt_value self, xt_value env, int32_t argc, xt_value *argv) {
470
+ (void)env;
471
+ (void)argc;
472
+ (void)argv;
473
+ XtGuiWindow *win = xt_gui_window_from_this(self);
474
+ xt_value object = xt_object_new();
475
+ xt_object_set(object, xt_string_from_cstr("heapBytes"), xt_number((double)xt_heap_bytes()));
476
+ xt_object_set(object, xt_string_from_cstr("heapAllocations"),
477
+ xt_number((double)xt_heap_allocations()));
478
+ double tree_nodes = 0;
479
+ double detached_roots = 0;
480
+ double detached_nodes = 0;
481
+ if (win != nullptr && win->document != nullptr) {
482
+ tree_nodes = (double)win->document->treeNodeCount();
483
+ detached_roots = (double)win->document->detachedRootCount();
484
+ detached_nodes = (double)win->document->detachedNodeCount();
485
+ }
486
+ xt_object_set(object, xt_string_from_cstr("treeNodes"), xt_number(tree_nodes));
487
+ xt_object_set(object, xt_string_from_cstr("detachedRoots"), xt_number(detached_roots));
488
+ xt_object_set(object, xt_string_from_cstr("detachedNodes"), xt_number(detached_nodes));
489
+ xt_object_set(object, xt_string_from_cstr("handles"),
490
+ xt_number(win != nullptr ? (double)win->node_handles.size() : 0));
491
+ xt_object_set(object, xt_string_from_cstr("handleSlots"),
492
+ xt_number(win != nullptr ? (double)win->node_order.size() : 0));
493
+ xt_object_set(object, xt_string_from_cstr("listeners"),
494
+ xt_number(win != nullptr ? (double)win->node_listeners.size() : 0));
495
+ return object;
496
+ }
497
+
464
498
  /* -- prototype ------------------------------------------------------------ */
465
499
 
466
500
  static void define_method(xt_value proto, const char *name, void *fn) {
@@ -505,6 +539,7 @@ xt_value xt_gui_window_proto(void) {
505
539
  define_method(proto, "removeEventListener", (void *)win_remove_event_listener);
506
540
  define_method(proto, "hitTest", (void *)win_hit_test);
507
541
  define_method(proto, "sendEvent", (void *)win_send_event);
542
+ define_method(proto, "stats", (void *)win_stats);
508
543
  g_window_proto = proto;
509
544
  return proto;
510
545
  }
@@ -47,6 +47,19 @@ static int g_gc_threshold_pinned = 0;
47
47
  static size_t g_gc_threshold = (size_t)16 << 20; /* 16 MiB */
48
48
  #define XT_GC_MIN_THRESHOLD ((size_t)1 << 20)
49
49
 
50
+ /* `XT_GC_TRACE=1` prints one line per collection (live bytes, allocation count
51
+ * and the next trigger) to stderr, for diagnosing heap growth. */
52
+ static int g_gc_trace = -1;
53
+ static size_t g_gc_collections = 0;
54
+
55
+ static int xt_gc_trace_enabled(void) {
56
+ if (g_gc_trace < 0) {
57
+ const char *env = getenv("XT_GC_TRACE");
58
+ g_gc_trace = (env != NULL && env[0] != '\0' && env[0] != '0') ? 1 : 0;
59
+ }
60
+ return g_gc_trace;
61
+ }
62
+
50
63
  /* ------------------------------------------------------------------------- */
51
64
  /* Roots */
52
65
  /* ------------------------------------------------------------------------- */
@@ -441,6 +454,9 @@ void xt_gc_collect(void) {
441
454
  if (g_gc_running) return;
442
455
  if (!g_gc_initialised) xt_gc_init();
443
456
  g_gc_running = 1;
457
+ const int trace = xt_gc_trace_enabled();
458
+ const size_t heap_before = g_heap_bytes;
459
+ const size_t allocations_before = g_heap_allocations;
444
460
 
445
461
  xt_gc_table_build();
446
462
  xt_gc_mark_roots();
@@ -451,11 +467,22 @@ void xt_gc_collect(void) {
451
467
 
452
468
  xt_header **link = &g_heap_head;
453
469
  xt_header *header = g_heap_head;
470
+ size_t live_by_kind[16];
471
+ size_t bytes_by_kind[16];
472
+ for (int i = 0; i < 16; i++) {
473
+ live_by_kind[i] = 0;
474
+ bytes_by_kind[i] = 0;
475
+ }
454
476
  while (header) {
455
477
  xt_header *next = header->gc_next;
456
478
  if (header->flags & XT_GC_MARK) {
457
479
  header->flags &= (uint8_t)~XT_GC_MARK;
458
480
  link = &header->gc_next;
481
+ if (trace) {
482
+ size_t kind = header->kind < 16 ? header->kind : 0;
483
+ live_by_kind[kind]++;
484
+ bytes_by_kind[kind] += header->size;
485
+ }
459
486
  } else {
460
487
  *link = next;
461
488
  g_heap_bytes -= header->size;
@@ -469,7 +496,33 @@ void xt_gc_collect(void) {
469
496
  g_gc_running = 0;
470
497
 
471
498
  if (!g_gc_threshold_pinned) {
472
- if (g_gc_threshold < g_heap_bytes * 2) g_gc_threshold = g_heap_bytes * 2;
473
- if (g_gc_threshold < XT_GC_MIN_THRESHOLD) g_gc_threshold = XT_GC_MIN_THRESHOLD;
499
+ /* Follow the live set: twice the surviving heap is the headroom a
500
+ * mark-sweep wants between collections, and the floor keeps a tiny heap
501
+ * from collecting on nearly every allocation.
502
+ *
503
+ * The threshold must *shrink* again, not just ratchet upwards. A doubling
504
+ * threshold with a monotonic floor turns any transient growth of the live
505
+ * set into exponentially rarer collections: each sweep only reclaims the
506
+ * newly dead part of a larger heap, so the heap (and with it the resident
507
+ * set) grows without bound. A GUI program that keeps a few extra nodes
508
+ * alive for one frame is enough to start that spiral. */
509
+ size_t next = g_heap_bytes * 2;
510
+ if (next < XT_GC_MIN_THRESHOLD) next = XT_GC_MIN_THRESHOLD;
511
+ g_gc_threshold = next;
512
+ }
513
+
514
+ g_gc_collections++;
515
+ if (trace) {
516
+ fprintf(stderr,
517
+ "xt_gc: #%zu heap %zu -> %zu bytes, allocations %zu -> %zu, next trigger %zu\n",
518
+ g_gc_collections, heap_before, g_heap_bytes, allocations_before, g_heap_allocations,
519
+ g_gc_threshold);
520
+ fprintf(stderr, "xt_gc: live kinds");
521
+ for (int i = 1; i < 16; i++) {
522
+ if (live_by_kind[i] != 0) {
523
+ fprintf(stderr, " %d:%zu/%zuB", i, live_by_kind[i], bytes_by_kind[i]);
524
+ }
525
+ }
526
+ fprintf(stderr, "\n");
474
527
  }
475
528
  }