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 +5 -1
- package/dist/tests/e2e/gui-retention.test.d.ts +14 -0
- package/dist/tests/e2e/gui-retention.test.js +100 -0
- package/dist/tests/e2e/gui-retention.test.js.map +1 -0
- package/doc/gui.md +108 -5
- package/doc/unimplemented.md +1 -1
- package/doc/zh-CN/gui.md +87 -2
- package/doc/zh-CN/post/pelican-bike.png +0 -0
- package/doc/zh-CN/post/xbintsc-build-pelican.md +31 -0
- package/doc/zh-CN/unimplemented.md +1 -1
- package/package.json +1 -1
- package/runtime/ext_gui/document.cpp +27 -0
- package/runtime/ext_gui/document.h +12 -0
- package/runtime/ext_gui/dom_api.cpp +40 -4
- package/runtime/ext_gui/dom_api_internal.h +6 -0
- package/runtime/ext_gui/dom_api_node.cpp +6 -2
- package/runtime/ext_gui/dom_api_node_read.cpp +10 -12
- package/runtime/ext_gui/gui_engine.h +12 -1
- package/runtime/ext_gui/window.cpp +35 -0
- package/runtime/xt_alloc.c +55 -2
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: **
|
|
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.
|
|
729
|
-
|
|
730
|
-
|
|
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
|
package/doc/unimplemented.md
CHANGED
|
@@ -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()`;
|
|
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
|
-
状态:**
|
|
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
|
|
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
|
+

|
|
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
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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`
|
|
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)
|
|
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
|
-
|
|
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
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
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<
|
|
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
|
}
|
package/runtime/xt_alloc.c
CHANGED
|
@@ -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
|
-
|
|
473
|
-
|
|
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
|
}
|