Platform Adaptation Notes
Field-tested pitfalls and conclusions per platform; each entry records only the pitfall and the fix. Protocol-interop conclusions all come from real buses and real panels. Linux is organized by distribution → desktop environment, Windows / macOS by version; add new conclusions to the matching section and sync the Chinese version (docs/zh/adaptation.md) in the same batch.
Performance Baseline
Overhead of the full MoonBit stack (shim + MoonBit runtime) relative to native C++: examples/hello (release build) compared against a functionally identical pure-C++ libyue hello, both linked against the same vendored static library — no shim on the C++ side, so the delta is the entire wrapper cost.
| Metric | Native C++ | Full MoonBit stack |
|---|---|---|
| Startup (median of 20 warm starts, exec → window map) | 73ms | 70ms |
| Steady-state memory (Rss, 3s after the window appears) | 62.1MB | 62.9MB |
| Binary size | 6.52MB | 7.39MB |
Methodology: Ubuntu 24.04 XFCE (X11), same machine and session; the startup delta is below detection granularity — a tie. C++ side built with -std=c++20 -O2 -DNDEBUG; headers from the same-version fork tree (nativeui) plus the prebuilt companion tree (base/build, with base/allocator/partition_allocator/src added as an include root). Without -DNDEBUG the link fails on the missing RefCountedBase::CalledOnValidSequence symbol.
Cross-Platform (build chain / FFI)
Build and Linking
- moon must run at the repository root: vendor/build artifacts and WebView2Loader.dll are located via the working directory at runtime.
- Library packages must not carry a
linksection (moon would generate a main-less exe and the build fails); all link flags come fromscripts/prebuild.pyvia the--moonbit-unstable-prebuildhook, which emits link_configs auto-propagated to every main package depending on yue. Never hand-writecc-link-flagsin any package. - prebuild script constraints: stdout must carry only the final JSON (progress goes to stderr); all flags go in the single link_flags string with hand-controlled ordering (
-lyue_mbtmust precede-lstdc++, GNU ld is single-pass); the script's cwd is the consumer's project root, so library paths must be absolute. - moon does not relink when a static library changes (prebuild only rebuilds a missing library): after shim or static-library changes run
prepare.pyto rebuild plusmoon clean(or delete the produced exe) to force relinking;nm build/libyue_mbt.a | grep <symbol>with zero hits means a stale library. prebuild rebuilds automatically when shim sources are newer than the vendored library. - shim patches land as commits in the fork (lb091188/yue, main = upstream v0.15.6); fork CI emits three-platform source packages and prebuilt libraries on
v*-mbt*tags. A shim change must backfill the local platform's vendored library in the same batch and promptly align all three platforms via thevendor-*CI, otherwise the zero-compile mooncakes path is broken for users. - The static library and the shim must come from the same compiler family: on Linux a clang-built library + gcc-built shim segfaults reliably (same ABI, same libstdc++); prebuilt libraries are built with gcc. Ubuntu 22.04 toolchain artifacts need
-latomicat final link. - libyue version is pinned in prepare.py (
LIBYUE_VERSION+ six-asset sha256); update checksums when upgrading. - Vendored libraries ship with mooncakes (
lib/<platform>/); prebuild resolves "vendored → build/" in cascade. Three-platform artifacts are pinned byvendor-native.yml(tagvendor-*); maintainers align withvendor_native.py --fetch <tag>. - Recent moon deprecates
moon.mod.json/moon.pkg.json(moon fmtmigrates in one shot);moon doconly accepts the new format, and older moon does not understand it. - MSVC misreads BOM-less UTF-8 source as cp936; Chinese comments produce fake preprocessing errors (the reported line has no such directive): CMake adds
/utf-8for MSVC. - Adding a shim function: definitions use
extern "C", declarations go into the extern "C" block ofyue_mbt.hin the same batch, andnmmust show no_Z-prefixed symbol; GLib (g_*) is Linux-only — cross-platform functions must not call it. - Recent moon deprecates implicit trait-method promotion: call sites use the explicit static form
ViewLike::method(obj);impl Trait for Xdeclaration sites need explicitpub extend X with Trait::{...}(generate the method list frommoon check --no-renderoutput, don't hand-copy); black-box tests must qualify in-package symbols as@yue.xxx. These warnings are missed by incremental builds — a clean full build surfaces them all. - The mooncakes package-page README renderer only displays images with absolute external URLs (field-tested across the 0.5.3–0.5.5 releases: package-relative image paths render neither as plain Markdown nor as HTML — HTML blocks are dropped entirely — while an external CI badge displays fine); switch hero images to jsdelivr absolute URLs (
cdn.jsdelivr.net/gh/<user>/<repo>@master/..., balanced reachability inside and outside China;@masterreferences lag a few hours behind due to CDN caching). GitHub-side rendering via the camo proxy is unaffected. - The toolchain triple (moon / moonc / core) must ship as one matched set (field-tested during the 0.5.3 release):
moon upgradeaborted midway in a non-interactive terminal (IO error: not a terminal) after swapping the binaries but before touching~/.moon/lib/core, leaving moonc 0.10.14 paired with core 0.10.12 — the old core lacksStringView::exact_view(four "no method" errors in sysdata), and the mismatch only surfaced when a version bump in moon.mod forced a full re-check; earlier incrementalmoon check/moon testgreens were false. Nightly version numbers are delisted the same day (the CLI server 403s any named historical version), so pinning viaMOONBIT_INSTALL_VERSIONis not viable — the only repair is rerunninginstall/unix.shwith no version to install the latest full bundle (binaries and core replaced in one pass), matching CI. Verify by checking thatmoon version --alland~/.moon/lib/core/moon.modagree, then bumping the version to force a full re-check: 133 tests green.
On-demand Browser dependency (0.5.0)
- Mechanism: the MoonBit package boundary is the link-dependency boundary. All
Browserbindings moved into the standalone packageyue/browser(@browser.Browser→@browser.Browser, API unchanged;examples/showcase/moon.pkgis the import example), and prebuild's link_configs now has three entries — the Linux webkit2gtk pkg-config output goes only into theyue/browserentry, whileyue/yue/traybuskeep only the common libraries. The main package must never importyue/browser, otherwise the dependency closure hands webkit flags back to every downstream. - The shim was split accordingly: all 27
yue_mbt_browser_*functions moved intoshim/yue_mbt_browser.cpp, andCastTo/Store/BytesFromStringmoved toshim/include/yue_mbt_internal.h(function-local statics inside templates/inline functions are unique program-wide by the standard, so multiple TUs share one handle registry — a Browser handle must stay visible to the generic View functions). Acceptance:nm libyue_mbt.ashows browser symbols only in the browser member, andnm -Cfinds noU nu::Browserin the main member. - Major pitfall (fully reproduced; two approaches falsified): the libyue distribution jumbo-bakes
browser.cc/browser_gtk.ccinto the same object as PainterGtk/Font/Image, so any program linking that member must resolve webkit_* symbols. A static stub bailout does not work — moon adds--as-neededto link commands by default and concatenates per-package flags in dependency-topology order (yue→traybus→browser); in a browser program the main-package stub binds every webkit reference first (ELF static binding is irreversible), the real library then loses its DT_NEEDED to --as-needed ("not referenced up to this point"), and at runtime the browser page calls an empty stub and segfaults. "A weak definition is overridden by a strong dynamic one" is also false — a minimal sample binds the weak one at runtime (prints -1, not 42). A symbol-renaming surgery (objcopy --redefine-syms producing a browser-reference-free library + y4b* stubs, seescripts/make_webkit_stubs.py) serves non-browser programs exactly, but a single yue entry cannot branch on whether the main imports browser, leaving the same segfault hole for browser programs. Conclusion: member-level library isolation is the only sound fix; any static stub is a trap. - The fix (landed; full source-mode coverage):
prepare.py's source fallback extracts segments right after unpacking —browser.cc/browser_gtk.ccare pulled out of the jumbo into a standalone translation unitnativeui_browser.cc(located by// ../../nativeui/...section comment headers, idempotent); the 2-symbol webkit coupling inmenu_item_gtk(role items running Cut/Paste on the focused WebView) became runtime probing (g_type_from_name("WebKitWebView")for the type,dlsym(RTLD_DEFAULT,...)for the command; skipped when unresolvable — behavior unchanged). Result: all 58 webkit/soup/JS-family references inlibyue_mbt.aconverge into the browser member, and static-library pull-on-demand isolates it naturally. UseLIBYUE_FORCE_SOURCE=1(or a platform without prebuilt assets) to take this path. - Platform status: Linux source mode as above; Linux prebuilt mode and Windows unchanged (WebView2 has no link-time symbols; identical flags across the three entries is the status quo) — prebuilt mode gains the same on-demand behavior only after the fork's packaging script splits the jumbo (4a) and a
vendor-*re-release lands, after which the stub logic retires naturally at the next prepare version bump; macOS stays unsplit (no local Mach-O archive toolchain to verify the WebKit reference surface oflibyue_prebuilt(macos); llvm-nm reads universal-archive member symbol tables incompletely — a wrong split breaks the whole mac line with no real machine as backstop), so the Darwin branch keeps pre-refactor flags on all three entries. - Verification (Ubuntu 24.04 XFCE X11, source mode): sysmonitor (no browser import) has no webkit/javascriptcore in
ldd, 0 webkit entries in the dynamic symbol table viaobjdump -T(symbol-level zero, not just missing DT_NEEDED), and survives startup; showcase linkslibwebkit2gtk-4.1/libjavascriptcoregtk-4.1and survives a Browser on the first screen (the WEBKIT_DISABLE_DMABUF_RENDERER guard still applies);moon checkzero warnings,moon test131/131. In-page interaction (page loading / JS round-trip / bindings) still needs on-device confirmation.
MoonBit cfg(platform=)
- moonc implements
#cfg(platform="windows"/"linux"/"macos"), evaluated from the-targettriple; but current released moon passes moonc an OS-lessnativetarget, so every condition is false. The condition lights up oncemoon build -vshows the full triple in the moonc command line. - Current substitute: runtime
platform()checks — declarative trees assemble nodes conditionally and simply skip creation when unmet.
Declarative Layer and Self-Drawn Components
Never call
set_background_colorinside mouse callbacks: runtime CSS rewrites swallow the immediately following press ("works on the second click"). Interactive states (hover / pressed) are expressed in on_draw; set_background_color is allowed only at mount time and in theme-subscription callbacks.A flex container's attach order is also its layout order and z-order: interleave widgets (handles / dividers) must attach in exact layout position and stay permanently visible — users find them by looking, not by hovering.
markdown_view span-offset accounting and mdast observed semantics (MD2, 2026-10): the old hand-written parsers (md_parse_inline / md_parse_blocks) are gone;
yue/markdown.mbtnow consumes mizchi/markdown (mdast). ①Attributed-text range endpoints must be accumulated per rendered-text segment in UTF-16 code units (md_utf16_len) — never counted in code points, never derived from mdast Spans. MoonBit'sfor ch in striterates code points whilestr.length()counts UTF-16 code units (verified: "a👍b" iterates 3 times, length 4); AttributedText ranges are UTF-16-semantic, so styling after an emoji (non-BMP, 1 code point = 2 code units) shifts left when accumulated in code points. Span-based locating is not viable at all: rendered text ≠ source text (markers stripped, soft breaks turned into spaces, link URLs dropped) — ranges can only be measured over the rendered text. Inline nesting is flattened iteratively with an explicit style-carrying stack (md_flatten_inline), merging adjacent same-style segments to cut range count; rendering and tests share one accounting functionmd_span_ranges(wbtest asserts the bold range after an emoji is [4,6), not the code-point-misaligned [3,5)). ②Three mizchi/markdown 0.8.3 parsing semantics:FencedCode.codecarries a trailing\n(line splitting must drop the trailing empty line);parse_code_block_infoonly understandslang:file {meta}and does not split space-separated info strings ("rust title=x" lands whole in lang), so the language must be taken as the first word; blank lines between paragraphs produceBlankLinesblocks, so AST traversal cannot assume "block index = document order" (a test tripped on children[2] actually being a blank block). Rendering decisions: HtmlBlock / footnotes not rendered, tables degraded to row lines (cells joined by two spaces, bold header), task lists consumeListItem.checked(☑/☐), Blockquote children render recursively inside the accent bar, ordered lists honorstart. Verification:moon checkzero warnings +moon test595 all green (19 new wbtests covering conversion / flattening / ranges / info / line splitting, plus the five-item CommonMark-deviation before/after acceptance: setext headings / ~~~ fences / info strings / indented code blocks / nested lists).Subscriptions must be unsubscribable, or rebuild-style components accumulate dead subscriptions without bound (CORE2): Store/Signal subscription closures are held by the signal kernel and theme subscriptions by theme_box.subs, with no removal entry — every component that rebuilds rows or remounts a subtree leaks a whole generation per rebuild: transfer leaks one set per move; table_t leaks "row container + one themed_container per cell + bind_fg" per rows Store set (over a thousand per refresh on a 1000-row table); each snackbar push leaks two; each bind_node remount leaks the old subtree's bind_label Store subscription. Dead subscriptions cost little memory but every set / theme_apply then runs against destroyed views (set_color / schedule_paint on removed widgets), and the closures keep the captured view subtrees alive forever. Fix on both sides: (1) subscriptions carry ids —
Signal::subscribereturns a handle andSignal::remove(id)removes it; on Store,subscribe_id(f)returns a handle andremove(handle)removes it (Store::subscribekeeps returning Unit: MoonBit forbids implicitly discarding non-Unit values, so changing its return type would touch a hundred-plus call sites repo-wide). (2) Theme subscriptions get the same treatment:on_theme_changereturns a handle,off_theme_change(handle)removes it. (3) Deep or bulk subscriptions use the subscription bagsub_bag_begin() / sub_bag_end() / sub_bag_discard(closure list): open a bag before a rebuild, and every subscription registered during it (Store + theme, including themed_container / bind_fg / bind_label at any depth of the subtree) pushes its unsubscribe closure into the bag; the old generation is discarded wholesale before rebuilding — transfer's render_all, table_t's rebuild, bind_node's rebuild and the snackbar's dismiss timer all use it. The bag is a stack (nesting-safe), the same "collection-time global context" pattern as the existing sig_collector in signals.mbt. Verified: moon check with zero warnings + moon test 657 all green (8 new cases: Store unsubscribe and idempotence, Signal handle removal, three bag generations and nesting, theme off and bag, empty-stack end, theme bag on component rebuild, carousel_stop).Timers started at mount need a reclamation channel; the two timer APIs have different contracts (CORE2): a timer started when a component mounts has no "view was removed" notification, so without reclamation it keeps ticking against a destroyed subtree. The two libyue timer contracts differ:
set_timeoutreturns an id thatclear_timeout(id)cancels immediately (self-rescheduling carousel chains use this);set_timeris a periodic callback with no cancellable id — the only way to stop it is returning false from the callback (the shim's SetTimer does not hand back an id). Hence carousel_t gains arotation? : CarouselHandlehandle pluscarousel_stop(handle)(stops auto-advance and cancels the pending timeout; manual arrows/dots are unaffected), while video_view_t's frame-advance timer follows a documented contract —vid_stop(handle)sets running=false so the next tick returns false and stops, and must be called before unmounting (at most one extra tick). Both "must stop before unmounting" contracts are written into the usage docs (components-ui / system-capabilities, zh and en in sync).
Self-drawn canvas and chart rendering
- 8-digit hex colors have the alpha first (
#AARRGGBB— a libyueParseHexColorsource-level convention, the opposite of CSS#RRGGBBAA;yue/color.mbt'sparse_hexagrees). Appending a tail neither errors nor changes opacity — the two hex digits land in the blue channel, showing up as color drift: instance one, the floating scrollbar fade changed the alpha string from "d9" to "73", which actually moved blue 0xd9→0x73 with red/green untouched, so the fading thumb turned yellow; instance two, chart area fillcolor + "26"prepends nothing — accent#009688+ "26" →#00968826= alpha 0x00 (fully transparent) + RGB(96,88,38), so the area fill had never rendered at all. Fixed by routing everything throughwith_alpha(hex, aa)(prepends the alpha); any set_fill_color/set_stroke_color needing opacity must use it — no hand-appended suffixes. - An offscreen
Canvas::new + get_paintersupports all geometry drawing (fill / stroke / arc / clip) withoutinitialize(), so drawing benchmarks run on headless CI too; but text paths (draw_text/AttributedText::get_bounds_for) segfault outright — the GTK text stack needs initialize first. Benchmarks therefore come in two tiers: "pure pipeline + geometry-call mirror" (no display needed) and a real full-frame run (text included) that only executes when DISPLAY is present; both tiers print fromcharts_wbtest.mbt. stroke()under the painter's default stroke color is a no-op (draws nothing): a benchmark that forgetsset_stroke_colorshows stroke cost as ~0 and passes bogusly. Always set an explicit color before timing strokes.- Under software rasterization (GTK/X11, no GPU) a path stroke costs ~1.8µs per segment, ~13µs for near-vertical segments (slope > 30);
fill_rect~1µs each (axis-aligned fast path),draw_text~0.11ms per call,line_toitself ~12ns (path building only). A 1000-point × 4-series line chart drawn entirely with path strokes costs 45–60ms per frame — far over the 5ms acceptance line. - Fix (shared by the whole chart family, see
yue/charts.mbt): in dense mode (more points than pixel columns) decimate to columns (min/max preserving extremes) and switch to rect paths — area mode fills one rect per column up to the anchor (the fill's top edge is the line), line mode draws a min..max vertical bar per column; only sparse mode (points <= columns) uses a true polyline plus polygon area fill. Per-frame cost is decoupled from window size and grows only with plot width. - libyue's Arc cannot express a counterclockwise arc: on GTK
PainterGtk::Arcis a plaincairo_arc, and cairo normalizesea < sainto a "plus 2π clockwise long arc"; the Win public API is hardwired clockwise too (the underlying ArcPixel has ananticlockwiseparameter that is never exposed). The shim's "ccw as negative span" conversion therefore never takes effect on either backend — using ccw for a ring's inner arc wraps the hole into the long arc and fills a solid pie (verified by real-machine screenshot: donut and gauge centers were not hollow, with the total/percentage painted on the solid face). Fix: approximate the inner return arc with a polyline (32 segments per full circle, chord error < 0.4px, identical on all platforms); for pure strokes (icon arcs) simply swap the start/end angles, which is geometrically equivalent. ChangingPainterGtk::Arctocairo_arc_negativein the fork would be the root fix, but that needs a vendor-* release, so it is deferred. - Chart acceptance benchmarks (release, Ubuntu 24.04 XFCE X11, offscreen canvas + initialize, real full frame incl. text): line 1000 points × 4 series (adversarial sawtooth) 3.08ms / smooth data 1.81ms; bar 200 categories 0.38ms; donut 50 sectors 0.70ms; gauge 0.15ms; scatter 10000 points 3.03ms. Pure-function pipeline (range / ticks / decimation / mapping) for the line chart: 0.028ms. Long-run push: 4800 pushes (10 min at 2Hz × 4 series) total 2.89ms with window length pinned at 1000 — live data is bounded at 4 × 1000 × 8B = 32KB; the increment is GC-reclaimed window garbage, which does not accumulate across continuous pushes.
- Line area anchor: the zero line when 0 is inside the range, the plot bottom for all-positive data, the plot top for all-negative (straddling columns emit two rects, above and below).
Painter::DrawText(canvasdraw_text) defaults towrap=true: long text in a fixed-height row or narrow box gets wrapped by the platform layout engine and overflows the row bounds. Three symptoms verified on the real machine: process-table command lines (often 100+ chars) wrapped and bled into the next row; large chart y-axis values ("21414.7") stacked vertically as multi-line garbage; end-value labels of multiple line series overlapped when their last values were close. Fix: the shim gainedyue_mbt_painter_draw_text_exexposing TextAttributes' wrap/ellipsis (pure ABI translation), MoonBit'sdraw_textgained optional parameters, and table cells now draw withwrap=false + ellipsis=true(truncation is done by the platform layout, so no per-cell measurement);fmt_axisswitches to k/M/G units from |v| >= 1e4 (one decimal, trailing zeros trimmed) so labels stay within 5 characters and never trigger wrapping; end labels are now collected, spread apart by y (14px minimum gap, clamped back into the canvas), then drawn.- Chart / virtual-table responsiveness (
fillswitch): no fixed width — the container stretches along the cross axis of the column parent and follows window resizes. In the table's fill mode, column geometry is re-laid-out on every paint from the actual width: fixed columns keep their (possibly drag-resized) widths while elastic columns share the remainder (dragging swaps width between the two adjacent columns conservatively, so it never conflicts with the elastic re-layout). Self-drawn row containers that anchor tow - offsetalready followed their parent's width. - Table header decorations (sort arrows, column-boundary lines, hover highlights) must be painted in the header container's
on_draw, not in a per-cell container: on GTK, libyue Painter path fills (begin_path+fill) do not render at all inside a single header cell (about one column wide × 32px, with a Label child widget) — the code runs, nothing errors, but no pixels appear; the identical code renders fine on the whole-row header container or the table's big canvas container.fill_rectand pathstrokerender at every container size, so the symptom is easily misread as "wrong coordinates". Reproduce by paintingfill_rect/ path fill / path stroke side by side in one window. Fix: both table_t and table_v_t keep all header decoration in a singleon_drawon the header container, leaving cells with interaction only. Root cause pending fork-level investigation (suspected to relate to the GTK draw region / clipping of small containers). - Mouse events at header column boundaries: the pixels just right of a boundary line belong to the next cell, so pressing exactly on a visible boundary lands on the next cell's left edge (triggering a sort instead of a drag). Fix: the drag hot zone recognizes both edges (right 4px → boundary (j, j+1); left 4px → boundary (j-1, j)), with no handle on the last column's right edge.
Layout Geometry (Yoga flexbox)
Built-in layout assertions pass 16/16 (±1px); composition rules: content area = container − 2×padding; gap does not stack with margin; percentages resolve against the parent content width.
Composite widgets (Tab / Scroll / Group) are measure-less leaf nodes in the yoga tree: their outer size must be given explicitly (e.g.
flex:1) or they collapse (Tab freezes its minimum size at construction, driving the page area to zero).tabs_t once hard-coded the outer width to 360px and gave content pages no flex: any Scroll / Table placed inside collapsed to zero (measured: the sysmonitor overview page rendered completely blank except the tab strip). Fix: outer drops the fixed width in favor of
flex:1(stretch gets the width in a column parent; fills when the parent has a definite height) and content pages getflex:1too. When the parent has no definite height flex does not grow, so embedded usage (the showcase section) is unchanged.After changing styles at runtime (
set_style), callingupdate_layouton the root container does not refresh the subtree on GTK at all (yoga state has changed, bounds stay put), and calling it on a single node only re-lays-out that node and its siblings under the same parent — other subtrees (e.g. table body rows) are unaffected. A probe with four controlled variants (immediately after mount / 500ms later, update root / leaf, width / flexbasis) pinned the only reliable form: call it on every container whose style changed. table_t column dragging used to move the handle (bounds follow col_w) while column widths never changed (the style took no effect) because it updated the root; the splitter happened to have a single wrapper container to restyle, so the same pit existed but never surfaced. Fix: apply_col_widths calls update_layout on both header cells and both cells of every row individually.GUI automation: prefer keyboard (Tab focus + Space activate);
xdotool key --windowsends XSendEvent events that GTK drops — use XTEST (no --window); coordinate clicks are unreliable due to WM decoration offsets.The Linux/macOS tabs_t page-switch collapse is cured (native ≥ v0.15.6-mbt.19): the lesion sits in yoga itself interacting with display:none, a three-layer mechanism — ① the
flexshorthand (flex:1) resolves in this yoga vintage to grow=1 with a derived basis of 0 (resolveFlexBasisPtr); ② hiding a page goes through display:none, whereYGZeroOutLayoutRecursivelywipes the layout wholesale with= {}, knockingcomputedFlexBasisback to undefined; ③ after re-showing, the flex basis computation (Yoga.cppYGNodeComputeFlexBasisForChild) rewrites the derived basis-0 as long as the resolved value is defined and the cache is undefined, and every later round then takes the "already computed" branch — a content measure never runs again, so under an auto-height owner chain (a scroll's content column) with no free space to distribute, the page container stays at zero height for good (probe measured the re-shown page at 1px, outer 65→36 collapsed to the head height). Pages that were never hidden stay healthy only by the coincidence that a content measure once entered the basis cache — not a stable guarantee. The fix (fork patch lb091188/yue 93078300: set azeroedByDisplayNoneflag in the zero-out pass, and for a flagged node the basis computation takes the content-measure branch for that one round; explicit flexBasis styles keep their behavior untouched) heals only the shorthand path; the collapse of an explicit flexbasis 0 is inherent semantics (every round rewrites 0 from the resolved value, the content-measure branch never runs), which is why tabs_t is platform-split — Windows keeps the explicit basis 0 (treating its visibility mid-state compression), Linux/macOS return to the plain flex shorthand. Regression tool:moon run examples/probe-collapse(four timed switch samples; the shorthand pages must hold a steady height). Pitfalls: the CI clang command line carries-Wshadow -Werror(the local-Wall -Wextracombo does not include shadow — patch code fully green locally, all red on CI; check shadow first), and the fork patch lives inpatches/(the prebuilt workflow applies it withgit -C third_party/yoga applyafter Bootstrap) because yoga is a submodule and stays out of fork commits.The boundary of the mbt.19 patch: showcase re-collapse and the shorthand ban on outer (closed out by next-day on-metal re-verification): after mbt.19 shipped, on-metal re-verification of showcase still collapsed — the patch only heals the node that was itself zeroed by display:none and re-shown; it cannot heal a flex-shorthand ancestor that was never hidden but gets dirty-invalidated by a sibling's visibility flip: tabs_t's own outer also carries the flex:1 shorthand, and once a page switch dirties it, the content-measure round under its auto-height owner (the scroll content column) resolves the derived basis-0 against the available size into 0 (not undefined), which the owner then takes as a zero-height content contribution — outer collapses to its margin height and the whole subtree goes to zero (gauge-measured 1px); everything looks fine initially only because the first natural-measure round sees an owner size of nan, where basis-0 resolves back to undefined and the content measure runs. A minimal pure-yoga repro (one availableHeight=undefined round, then a fixed-300 round plus switches) reproduces both shapes faithfully, and printf tracing in Yoga.cpp captured the three-step collapse chain:
outer mainOwnerSize 300→10→0(10 being the margin height). The fix stays out of native: tabs_t's outer switches to flexgrow + flexshrink written separately — grow/shrink do not participate in the content measure and the basis takes auto semantics (CSS flex:1 1 auto): content height under an auto owner, fill/compress under a definite owner, the sole difference from the shorthand being basis auto. Rule: any container that must live both ways — fill under a definite-height owner, content-size under an auto owner — must not use the flex shorthand; write grow/shrink separately. The shorthand is only fit where the owner height is always definite, or where the node itself is the visibility-switch target (protected by the mbt.19 patch). Regression tool extended withmoon run examples/probe-collapse2(full showcase-structure repro: sidebar + sibling pages toggling + scroll chain + dual-shape tabs; the gauge group must hold a steady 18). One probe-side note: never use button_t as a decorative sidebar — during window teardown the GTK focus-out callback can hit destructed state and segfault (gdb stackOnFocusOut → Store<Responder>::get), unrelated to the collapse; the probe uses labels instead, and the button_t focus-chain lifetime is tracked separately.
MoonBit ↔ C ABI
- Trampolines must match the C function pointer prototype bit-for-bit, including arity: C calls
callback(closure, args...)and the trampoline's first parameter receives the closure. Misalignment hides itself — row counts work, some callbacks fire; a callback is verified only once it has actually fired. extern "c"returning nullable types: older toolchains segfault outright (report success/failure via aRef[Int]out-parameter); on moon 0.1.20260904 + moonc v0.10.12,-> Bytes?works — a C-side NULL maps to None correctly, verified in both debug and release against real missing-file and directory (EISDIR) paths (sysmonitor's read_text_file). Other nullable types (handles etc.) are untested; the out-parameter pattern remains the fallback.- FFI pointer parameters need
#borrow(compiler-enforced); widget parameters take the handle typeView, never the MoonBit wrapper struct (otherwise handles are invalid at runtime and silently dropped). - Closures across the ABI: capture-free top-level function literals compile to real C function pointers; capturing closures use the "function pointer + closure pointer" two-parameter form.
- Callback closures are kept alive process-wide by a registry and not reclaimed per window (negligible leak for single-window tools — settled).
- Toolbar / Vibrant have no Linux static-library symbols (link would fail) and are not exposed; the Browser empty-cookie-list crash is fixed since fork mbt.7.
- Changing a shim signature requires the same batch in
.cpp/yue_mbt.h/ ffi.mbt; both a missed signature sync and an entirely missed declaration manifest as a mangle split (_Z-prefixed symbol vs plain-C reference) — locate with nm, and run a ".cpp definitions × header declarations" cross-scan before packaging. - A standalone translation unit (e.g.
yue_accent_mac.mm) that does not includeyue_mbt.hmust mark its function definitionsextern "C"explicitly: without it they export C++-mangled (__Z25yue_mbt_system_accent_macv) whileyue_mbt.cppreferences the plain-C name from the header — link-time undefined. Only macOS exposes this (Linux/Windows never compile the .mm), and neither does the vendor/CI prepare stage (a static archive never checks undefined) — only a real macOS link (moon test/build) hits it; with no local mac, CI is the only defense line. Actually fixed ahead of the 0.5.0 release (symbol re-verified via strings on the re-vendored archive as_yue_mbt_system_accent_mac). - The XFCE panel prioritizes IconPixmap over IconName: set_icon_name and set_pixmap are mutually exclusive — setting one must clear the other.
- Custom-protocol reject encoding and C-side payload pre-validation (CORE1, high severity): the None reject path of
Browser::register_protocolused to encode[1,0,0,0]— the C side read the first 4 little-endian bytes asok=1(success) and kept reading mime_len/content_len past the end of the 4-byte buffer (native heap over-read), then builtstd::string(p+8, mime_len)from a garbage length: undefined behavior. The reject never actually took effect either (libyue's WebKitGTK side rejects a request only when the handler returns nullptr, viafinish_error— see OnProtocolRequest in the yue fork'snativeui/gtk/browser_gtk.cc). Fix: the MoonBit reject payload is now[0,0,0,0](ok=0, so C returns nullptr without reading further bytes), with the encoding extracted into the in-package pure functionencode_protocol_payloadfor white-box tests; the C side pre-validates against the MoonBit Bytes length before decoding segment by segment — ok==0 or a payload shorter than 12 bytes is rejected, as are negative or out-of-payload mime_len/content_len (length comparisons go through int64 to avoid8+mime_len+4overflow), and a nullptr payload is also defensively rejected. The Bytes length is the 4 bytes preceding the data pointer (the moonbit_object header's meta, which is the length for VAL_ARRAY; layout follows moonbit.h'sMoonbit_array_length— moonbit.h itself is not included because its extern "C" memcpy declaration conflicts with glibc), and that assumption was empirically verified with a minimal C probe linking libmoonbitrun.o + the libruntime archive directly (make_bytes(23/4/0) all read back the correct length). Lesson: for a self-encoded ABI payload with length fields, the C side must be able to obtain the total length itself and pre-validate first — never trust fields after the peer "claims success"; cross-ABI contract tests must mirror the C decode logic (length pre-check, then segment reads) rather than only asserting the encoder's shape. Verification:moon checkwith zero warnings +moon test636 all green (new yue/browser/browser_wbtest.mbt with 8 cases: None payload byte-exact as[0,0,0,0]; Some byte-exact across ok/mime_len/mime/content_len/content; UTF-8 multi-byte lengths counted in bytes; the 12-byte minimal payload for empty mime/content; the legacy[1,0,0,0]shape and an 8-byte ok=1 short payload both rejected; out-of-payload mime_len/content_len rejected; round-trip with long content containing quotes and newlines); the shim change went through prebuild's incremental rebuild, and after deleting the showcase exe under_build,moon build examples/showcaserelinks with zero warnings (nm confirms the exe references yue_mbt_browser_register_protocol); showcase gains a demo-deny reject-path button and code snippet — the real-machine load-failure behavior is for the user to confirm under Linux WebKitGTK (Windows WebView2 silently ignores custom protocols anyway).
Audio decoding capability boundary (miniaudio)
- AudioEngine's decodable formats are exactly miniaudio's built-in decoder set — WAV / MP3 / FLAC only (probe-verified 2026-10: generated mp3/flac/m4a/ogg samples and loaded each via a throwaway wbtest; mp3/flac/wav load fine, m4a and ogg both fail with
LoadFailed: Invalid file). Root cause: miniaudio's Vorbis decoding needs an external stb_vorbis (this binding doesn't integrate one), and m4a/aac/opus/wma/aiff have no built-in decoder at all. The trap takes the form of "docs and filters claiming a superset": a file-dialog filter listing m4a/aac/ogg plus copy saying "WAV/MP3/FLAC/OGG etc." invites users straight into Invalid file (the ffmpeg-generated demo wav itself loads fine). Fix: filter onlywav,mp3,flac(plus an "All files" entry), state the set boundary in the page copy and the yue/audio.mbt header comment, and append a "convert with ffmpeg first" hint to the load-failure message; no silent auto-transcoding (a demo board should show AudioEngine's real capability, and synchronous transcoding of large files would block the UI).
VideoPlayer replay state machine (VpEnded / stop audio seek)
- Symptom: after a video plays to the end (EOF, non-looping), clicking play again does nothing — position pinned at duration, picture pinned on the last frame, audio queue already drained; after stop → play the picture does restart but sound is silent the whole way.
- Root cause 1 (play after ended does nothing):
vp_tick's EOF non-looping branch set state back toVpIdle, indistinguishable from the initial idle —play()on VpIdle always "resumes from the current position", and in the ended state pos=duration, so the target time is always past the end while the demuxer sits at EOF: the first tick hits EOF again and stops, so the click is a no-op. - Root cause 2 (silent after stop → play):
stop()only seeked the video demuxer; the audio demuxer is a separate container instance opened on the same file (independent read cursors), so it stayed at EOF — on replayvp_fill_audio'sdecode_pcmreturned None immediately, the queue stayed empty, silent throughout. Any "back to start" operation must seek both demuxers; missing one gives you a silent picture. - Fix: ① an explicit
VpEndedstate (mirroring AudioPlayer'sApEnded), entered on non-looping EOF;play()on VpEnded first runsvp_restart(both demuxers seek(0) + pos/base_pts/frame_no reset + audio queue refill) before starting; ②stop()also seeks the audio demuxer to 0; ③seek()migrates VpEnded → VpPaused, so scrubbing after the end and pressing play resumes from the scrub point instead of being mistaken for a replay (replay semantics belong only to "press play while parked at the end"). - Design trade-off: an explicit state rather than a
pos>=durationheuristic — mid-playback, when the target time is past the duration but EOF has not been reached yet, pos is clamped to duration (heuristic false positive: "pause at the end, then play" would wrongly replay from 0); and with no container duration header (duration=0) the heuristic fails entirely. The explicit state is correct in both cases. - Verification: white-box control — stashing video_player.mbt back to the pre-fix revision and running
moon test -p yue-media, the two new cases fail precisely (play-after-ended's is_playing assertion, stop→play's audio decode_pcm assertion); with the fix restored, 11/11 pass. Whole repo:moon check --deny-warnzero warnings +moon test641 all green (5 new cases vs the previous 636: loop EOF→vp_restart, ended→play, ended→seek→play, stop→play, volume clamping). Real-machine interaction feel (play-after-ended, replay after stop) is for the user to re-verify.
Package split: single yue package -> core + six sub-packages (0.5.11)
- Environment: moon 0.1.20260920 / moonc v0.10.14, module NoahLiu/moonbit-libyue; prebuilt native libs in place.
- Symptom: before the split the single yue package held ~60k lines — FFI, native widgets, themed components, 20+ charts, markdown, the declarative layer and 30 system-capability files in one package. MoonBit compiles per package with no tree shaking inside a package, so webview-based or core-only consumers compiled the whole toolkit into their binary.
- Root cause (mechanism): MoonBit cross-package references must be qualified as
@alias.name— evidenced twice: the official moonbitlang/async src/fs sub-package references root symbols as@async.xxx, and this repo's yue/media.mbt references traybus as@traybus.xxx. The split therefore means: one moon.pkg per sub-package, every in-package core reference qualified with@yue.(word-boundary codemod that must skip.fieldaccesses,::methodsuffixes and string literals), and dependency declarations sinking to package level (mizchi/markdown -> yue/markdown; subproc and moonsqlitefile -> yue/system). No prebuild change was needed: link_configs concatenate flags across the dependency closure, so a sub-package importing only core automatically gets-lyue_mbt; webkit stays confined to the yue/browser entry. - Fix (batch order is cycle avoidance): (1) markdown first — it calls components' code_view, so moving it later would cycle core->components->core; (2) components — pre-step moves the
hand()cursor helper from components.mbt into core as pub (charts_interactive references it cross-package and charts moves later), and ~17 private theme getters plus bind_fg/bind_entry_theme/entry_ctrl_height referenced by 20+ files get pub-ified on demand; (3) icons; (4) charts (charts_wbtest.mbt carries its own extern "C", so its moon.pkg needs a native-only targets entry); (5) declarative — two pre-steps: the Node protocol (struct Node + attach) is extracted from declarative.mbt into core yue/node.mbt (otherwise four packages' unqualified Node references all break), and signals.mbt's trailingbind()(which calls bind_label) moves into the declarative package with it; (6) system — open_url (system.mbt) and tray.mbt stay in core (markdown's link clicks depend on open_url; procrun goes with the system layer); (7) version close-out 0.5.11 across moon.mod, yue/version.mbt and the yue-media pin (prebuild validates them at build time). - Verification and the lesson: each batch ran moon check zero-warning + full moon test + moon build of examples/showcase and sysmonitor, plus a read-only acceptance review of the git diff. Lesson: the gate missed systemprobe — after the batches were committed, the pushed tree failed to build systemprobe with 15 link errors (its import adaptation stayed uncommitted in the working tree); hello/hello-themed were unaffected so the two-example gate didn't notice, and only a stash test on the committed state found it. Gate package-split work against every example, and verify each commit's scope with git status (another work stream's uncommitted files are easily swept in by whole-file commits).
Linux
Distributions
Ubuntu 24.04 ✅ mainline
- System dependencies:
build-essential cmake pkg-config libgtk-3-dev libpango1.0-dev libfontconfig1-dev libx11-dev libwebkit2gtk-4.1-dev. - AppIndicator runtime is gone, so libyue's built-in tray is unusable → replaced by
yue/traybus/(pure-MoonBit SNI direct to the panel). - The webkit2gtk package name varies by distribution (4.0 / 4.1); prepare.py probes with pkg-config and accepts either.
Other distributions ❓ untested
- First porting step: check each dependency's pkg-config name, then run prepare.py.
Desktop environments (tray / menu behavior)
XFCE ✅
Autostart .desktop findings: the Exec path comes from readlink on /proc/self/exe (during development it points into the _build output, so the entry breaks as soon as the artifact moves — only a fixed install path is reliable; AppImage likewise points at its mount point); the full file passes desktop-file-validate with zero warnings; the enabled check honors both system-side disable switches (Hidden=true and X-GNOME-Autostart-enabled=false), and disable is idempotent (a missing entry counts as success).
Context menus are rendered by the panel mirroring DBusMenu; the app is not asked to draw via SNI ContextMenu.
xfce4-panel 4.18 sends only the batch
EventGroup/AboutToShowGroup, not the singular versions: implementing only the singular forms gets silently rejected by UnknownMethod — the menu opens but clicks do nothing.Desktop notifications must go through
Notification::Show();NotificationCenter::AddNotificationnever issues the DBus Notify call on Linux and fails silently.Global shortcuts are exclusive XGrabKey registrations; a taken key combination makes Register return -1 (no crash, silent failure) — callers must check and offer another key.
With desktop focus, xfwm4 captures the keyboard and global shortcuts do not fire (they work when an application window has focus).
GNOME ✅ (X11 and Wayland sessions)
- The AppIndicator extension reads the Menu property at registration time to build its proxy: an empty menu returning
/permanently kills the menu client (clicking the icon does nothing). traybus always returns the real/MenuBar, exporting it even when empty, filled via LayoutUpdated. - With
ItemIsMenu=false, both left and right clicks emit Activate. - Global shortcuts segfault on Wayland sessions (upstream casts the root window through a GDK X11 macro): a
GDK_IS_X11_DISPLAYguard is in place; non-X11 sessions get Register = -1. - Mouse button semantics are yue-unified 1=left 2=right 3=middle (GDK's 2/3 are swapped); components judge by yue semantics.
- Environment variables for launching GUI apps over SSH: X11 session
XAUTHORITY=/run/user/1000/gdm/Xauthority; Wayland sessionXAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.*andWAYLAND_DISPLAY=wayland-0; both needDBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus.
KDE ✅ (Plasma 5.27)
- The panel consumes SNI directly; new icons go straight into the visible tray area; tray / menu / quit all pass.
- Protocol behavior is the opposite of XFCE: only singular
Event/AboutToShow, no batch versions; traybus implements both.
Deepin ✅ (23 / 25, DDE)
- dde-dock implements StatusNotifierWatcher; SNI direct connection works; new icons land in the collapsed tray area by default and can be dragged out.
- libyue's Popover (transparent window + pointer grab) does not render on DDE and slows the pointer: the shim checks
XDG_CURRENT_DESKTOP(23=DDE, 25=Deepin) and falls back to a borderless plain window. - Binaries built on the host (Ubuntu 24.04) run directly: the glibc symbol ceiling is 2.38 and deepin ships the webkit2gtk-4.1 runtime; for cross-distribution shipping, check the glibc symbol requirement first (
objdump -T | grep GLIBC_). TextEdit::Delete()deletes the selection, it does not clear; useset_text("")to clear.- DDE clipboard-manager interactions produce occasional CHECK / CRITICAL log noise; functionality is unaffected.
KDE / MATE / Cinnamon / Budgie / LXQt ❓ untested
- All support SNI at the protocol level and traybus implements the protocol; awaiting per-environment verification.
DBus wire protocol (traybus)
The DBus array length prefix excludes alignment padding before the first element; counting it makes dbus-daemon disconnect as a protocol violation.
The variant signature in the header SIGNATURE field is "g" (u8 length-encoded); encoding it as "s" passes unit tests but real buses reject it.
The SNI Menu property must always return the real menu object path — never
/, even when empty.Unit-test self-consistency ≠ interop: protocol issues are located with dbus-monitor on the real bus; GNOME panel-side exceptions show up in journalctl.
Single-instance RequestName must carry flags=4 (DO_NOT_QUEUE): the default 0 queues the request, so the second instance's already-running check hangs until the first instance exits — semantics fully broken. Real-bus findings (Ubuntu 24.04 XFCE): reply 3 = held by another connection (instance exists → wake it and exit); after SIGKILL of the first instance the bus reclaims the name automatically and a new connection gets reply 1 (becomes the primary); reply 4 = this connection already owns it (idempotent). The full chain (RequestName flags=4 → EXISTS → Wake('as') → RETURN) was verified by dbus-monitor capture on the real bus: Wake to RETURN in 39µs; after SIGKILL of the first instance a third instance claims normally.
The second-instance wake dispatch must hook Conn::handle's kind==1 branch in bus.mbt before sni.mbt's handle_call: all inbound calls funnel into handle_call, and without the interception Wake lands in the UnknownMethod fallback so the first instance never sees it. Wake naming convention: interface org.moonbitlibyue.Instance, object path /org/moonbitlibyue/Instance, member Wake ('as' = the second instance's command line, passed through moonbitlang/core/env args(), including argv[0]; callers decide what to keep).
FileManager1 (reveal-in-file-manager) findings on XFCE: the NameHasOwner probe works; ShowItems wire format is "ass" (array of file URIs + an empty startup id); file URI percent-hex uses uppercase (RFC 3986 allows either case, uppercase is the convention), unreserved characters (A-Za-z0-9-._~) and '/' stay raw, everything else is %XX-encoded per UTF-8 byte — locked by unit tests. When the service is offline or the call fails, the fallback is xdg-open on the parent directory — selection is lost, a semantic downgrade that the usage doc must state.
External-open spawns go through g_spawn_async (G_SPAWN_SEARCH_PATH + stdout/stderr to DEV_NULL); glib reaps the child automatically, no zombies; no shell involved, argv passed directly. xdg-open's behavior for nonexistent paths or unopenable URLs varies by desktop, so the library's Ok only means "handed to the system" — the platform-side outcome does not travel back.
Screen-saver inhibition (Inhibit/UnInhibit) picks the service by real NameHasOwner presence, not by desktop-name guessing: the candidate list is [org.freedesktop.ScreenSaver, org.xfce.ScreenSaver]; measured on Ubuntu 24.04 XFCE only org.xfce.ScreenSaver is online (held by xfce4-screensaver), org.freedesktop.ScreenSaver is unheld. The two are interface-isomorphic: object path and interface name derive from the service name by dot→slash, Inhibit("ss" = app name + reason) -> u cookie, UnInhibit("u" = the original cookie) must match bit for bit (real-bus capture: Inhibit returned cookie 1516211641, UnInhibit 2 seconds later carried the same value, empty successful reply). GNOME / KDE service holdings pending real-machine notes.
System bus (measured Ubuntu 24.04): socket is /run/dbus/system_bus_socket — connecting by this default with DBUS_SYSTEM_BUS_ADDRESS unset works; when the variable is explicitly set, take the first unix:path= among the comma-separated entries, and if none matches (e.g. a tcp: address) return an explicit Err carrying the original text — silently falling back to the default socket would reconnect to a bus that really has UPower, turning the degradation test into a false pass. BecomeMonitor is rejected on the system bus (both dbus-monitor and busctl monitor), so capture is unavailable; verification goes through the probe's own round trips plus busctl call to cross-check reply shapes.
Multi-bus connections coexisting (B5 infrastructure): the shim's fd watch went from a process-wide single slot (each watch overwriting the previous) to an fd→callback dispatch table plus unwatch; the MoonBit side routes fd→Conn through a registry. Dual connections (session SNI + system UPower) verified to coexist without clobbering. When a glib source callback returns 0, glib destroys the source itself — the shim table entry must be erased in step (otherwise a later unwatch calls g_source_remove on a dead id and warns).
Wire-level 'd' (DOUBLE) and 't' (UINT64) must land as a set: in a real UPower GetAll reply UpdateTime is 't' and Percentage/Energy are 'd'; missing 't' sent the variant's unknown-signature old defensive branch ("return empty string but leave the read position alone"), misaligning every subsequent element and aborting inside the read_string slice (unit tests stayed green — self-crafted shapes never hit it). The defense is now two layers: unknown variant signatures push pos to the message end (parsing degrades to garbage values instead of misalignment), and read_string/read_sig gained bounds checks. Adding a type is an eight-site whole-chain change: DVal/Sig/sig_align/parse_one_sig/sig_of/sig_char/encode/decode_in.
Double↔IEEE 754 bit conversion lives in the shim (moonbitlang/core has no Double::to_bits/from_bits — confirmed): yue_mbt_sys_f64_to_bits/from_bits are pure bit reinterpretations (static_assert sizeof(double)==8), and the wire layer's 'd' piggybacks on the Int64 8-byte little-endian read/write. Note: once traybus's whitebox test target references such externs, its link needs -lyue_mbt — but link_configs propagate to "targets depending on the package", and traybus does not depend on yue (it's the reverse), so prebuild.py must carry a separate identical entry for NoahLiu/moonbit-libyue/yue/traybus (the single static-lib member references the whole gtk set, so trimmed flags won't link).
UPower reading path (measured Ubuntu 24.04, desktop machine): DisplayDevice (/org/freedesktop/UPower/devices/DisplayDevice, interface org.freedesktop.UPower.Device) aggregates the main battery — IsPresent=false → Ok(None); mind the interface-name split: devices are …UPower.Device, the toplevel is …UPower, and PropertiesChanged's arg0 filter naturally keeps device-level signals out of toplevel subscriptions. OnBattery lives on the toplevel object; power-source events subscribe to toplevel PropertiesChanged and parse the changed dict directly in the callback (signal dispatch sits on the drain stack — call_sync inside a callback would re-enter the receive path; forbidden).
Disconnect self-healing (B5, not yet exercised by a real disconnection): mark_dead is idempotent (keyed on the fd registry), reconnects via the shim's post_delayed_task (extern'd directly, bypassing the traybus→yue reverse dependency) after 500ms, at most 3 attempts; on success it replays AddMatch rules, migrates the subscription table and tray items, and re-registers them (during the disconnect the watcher already dropped the old registrations when our unique name vanished). In a pure-CLI context (main loop not running) the reconnect callback never fires and gives up naturally.
Multi-item DBusMenu object paths must be derived from the item path (CORE2, whitebox + real-bus regression): the SNI Menu property used to return
/MenuBarunconditionally, and the menus registry (menu object path → Item) collided on that key — creating a second tray item didc.menus.set("/MenuBar", item2), silently replacing the first item's menu route, so the panel only ever saw the last registered item's menu (the first item's layout queries landed on the second item or hit UnknownObject); unregistering could not clean the stale key either. Fix:menu_path_of(item_path)derives the path (/StatusNotifierItem→/MenuBar,/StatusNotifierItem_N→/MenuBar_N, the first item keeps the ksni convention), unregister and the create-failure path clear both the items and menus tables symmetrically, andConn::next_item_pathnow scans for the first free path (the old length()+1 numbering reused an occupied path after a middle item was unregistered, colliding with its menu route too). Real-bus regression (sni_wbtest, auto-skips without a panel watcher): two items get distinct paths/menu paths,gdbus_call_session(own unique name, each menu path, GetLayout)returns each item's own labels (never the other's), both tables are empty after unregistering, and a fresh item restarts from/StatusNotifierItem; with the pre-fix code stashed in (menu_path_of always/MenuBar) the related cases fail precisely. The panel-side behavior of two icons each opening their own menu still needs on-machine visual review (AGENTS rules 5/6).Disconnect self-healing must not unconditionally open a new connection (CORE2, whitebox + real-bus regression):
try_reconnectused to call connect_session/connect_system and overwrite g_conn/g_sys_conn as soon as it found a saved connection — if an on-demand path (sni_available / Item::create / system_conn) had already rebuilt a live connection after the disconnect, that connection plus its fd and glib fd watch leaked (the g_conns registry routes by fd, and after the overwrite nothing could unwatch/close it), with subscriptions and tray items split across two connections so signals only reached the orphaned one. Fix: the migration logic is consolidated intoConn::adopt_saved(bus), called by connect_session/connect_system whenever any new connection is established (on-demand rebuild and self-healing share the path), andtry_reconnectfirst checks whether g_conn/g_sys_conn is already live — if so it only tops up the migration (idempotent) and opens nothing. Two older issues fixed along the way: migrated paths already present on the target connection are not re-registered (avoiding a double icon from a repeated RegisterStatusNotifierItem on the same connection), andItem::unregisteralso clears the item from the saved (disconnected) connection, so it no longer resurrects after a reconnect. Tray-item re-registration is posted back to the main loop viasys_post_delayed(0, ...): register_item_path's internal call_sync pumps the bus, and running it synchronously would let predicates such as sni_available / Tray::is_supported block for up to 1.5s when called from a GUI callback, and would re-entrantly dispatch inbound calls arriving during the pump (the same discipline as never calling call_sync from a netmon/UPower callback). Real-bus regression: mark_dead simulates the disconnect → on-demand rebuild → after try_reconnect g_conn is still the same connection (unique name unchanged) and g_conns gains no entry; with the pre-fix behavior (unconditional new connection) it fails precisely.logind event subscriptions (B6): PrepareForSleep(b) lives on the Manager interface; Lock/Unlock live on each session object's Session interface (empty-body signals, one AddMatch per member, path unbounded — all sessions match, so with multiple logged-in users someone else's lock fires too; ownership is the caller's to check via sender). Degradation measured (private bus without logind): suspend_resume_supported()==false and session_lock_watch returns Err(SystemError::Unsupported), never silent. Rapid suspend/resume cycles may deliver two Resuming events in a row (no library-side debouncing); network/DBus may not be ready right after wake — retry logic in callbacks should defer.
Lock events need a dual signal source (real-machine bugfix): XFCE's xfce4-screensaver does not call logind when locking (no Lock/Unlock signals; calling Session.Lock via busctl directly fires them fine, proving the subscription chain is healthy — the locker just never notifies). It emits ActiveChanged(b) on its own org.xfce.ScreenSaver name instead. Fix: session_lock_watch subscribes to both sources — logind Lock/Unlock (system bus, GNOME/KDE path) plus the screensaver service's ActiveChanged (session bus, candidates [org.xfce.ScreenSaver, org.freedesktop.ScreenSaver] picked by real presence) — with a state machine deduping multi-source reports (same-state repeats don't dispatch; only transitions do). The ActiveChanged subscription key carries the service-name prefix, so it cannot clash with the logind subscriptions.
XFCE "blank-after-1-minute setting has no effect" and the keep-awake verification path (real-machine diagnosis): the power manager GUI's "blank after 1 minute" relies on DPMS or the screensaver's blanking, and on the measured host all three dimming chains were off (xfce4-screensaver /saver/enabled=false, X DPMS Disabled, xfce4-power-manager carrying no dpms/blank keys at all) — the GUI shows a value but nothing executes it, so the keep-awake switch is unobservable. Before verifying keep-awake, enable one executor (the screensaver blank is recommended: same component as the locker, so inhibition and lock events pair up); org.xfce.PowerManagement's inhibit interface is offline here (NameHasOwner=false), so DPMS blanking under XFCE cannot be inhibited via DBus — avoid that path.
Windows power/session event window (B6, code landed, real-machine verification pending): one message-only window receives both WM_POWERBROADCAST (PBT_APMSUSPEND; both PBT_APMRESUME and PBT_APMRESUMEAUTOMATIC count as resumed) and WM_WTSSESSION_CHANGE (WTS_SESSION_LOCK/UNLOCK, NOTIFY_FOR_THIS_SESSION); the WNDPROC PostTasks back to the main loop (B1 message-window pattern reused). The C side is single-window single-callback shared by sleep and lock — the MoonBit side registers one global dispatcher keyed on the event code, so later registrations must not clobber earlier ones. A WTSRegisterSessionNotification failure only disables lock events and is not treated as overall failure. wtsapi32.lib is the plan's only link-list change; prebuild.py's WINDOWS_LINK_LIBS and shim/CMakeLists.txt must land in the same commit (the two lists carry no consistency-comment backing).
NetworkManager online status (B7): State and Connectivity are both toplevel properties; a Properties.Get reply body is a single "v" (containing a u32) — a different shape from GetAll's a{sv}. Both change signals are subscribed: the legacy StateChanged(u) carries the new value directly, while PropertiesChanged(a{sv})'s changed dict may carry either property; subscription callbacks sit on the drain stack, so they only update the cache and notify with merged values — never call_sync to re-query (the first cache build happens inside the registration action, legal off the drain stack, worst-case 1.5s block). Normalization: State 70/50 with Connectivity 4/3 is Online; captive portal (Connectivity=2) counts as Offline ("online" = internet-reachable). Measured Ubuntu 24.04: State=70/Connectivity=4 → Online, matching busctl bit for bit; private-bus degradation gives Err(NetworkError::Unsupported). Windows NLM GetConnectivity bitmask: IPV4_INTERNET (0x40) or IPV6_INTERNET (0x400) present means Online, polled every 5s (listener-based later); netlistmgr.h needs no extra link libraries.
NM StateChanged signal parameter is Int32 (i), not u32 (real-machine pitfall, corrected): the D-Bus spec makes the State property u and the StateChanged signal parameter i — different types for the same concept; the wire layer decodes i as VI32, and the old implementation matched only VU32, so the signal was silently dropped — signals arrived, callbacks never fired, and after the panel went offline (State 70→20/10) the UI kept showing the initial Online (unit tests alone can't catch this: the hand-crafted signal body happened to use u). Fix: statechanged_of matches both VI32/VU32; bool and other types are still dropped. State inside Properties.Get and PropertiesChanged remains u. The dispatch chain was verified alive on a real machine via a harmless property write (NM 1.46 WwanEnabled toggle, zero network impact on machines without WWAN hardware, PropertiesChanged arriving in real time); the UI flip after a signal was left to real-machine offline/online verification.
Network callback registration must sit outside the platform branches (located via two-level live observation): on_network_status_change dispatches uniformly through net_dispatch iterating g_net_cbs, but the registration g_net_cbs.push(cb) used to live only in the Windows branch — on Linux every signal arrived and the cache updated value by value (State 70→10→20→40→60→70 matching nmcli monitor line for line) while callbacks never ran, leaving the UI stuck on the initial value; a two-level probe (traybus nm_on_change layer vs the online dispatch layer) pinpointed it. Fix: push moved outside the platform branches so both platforms register uniformly. Other event registrations (on_suspend_resume/on_power_source_change) embed their callback closures directly and don't go through a global array, so they were never affected; net_dispatch dedupes against the network_status() baseline backfilled at registration, so the first same-state signal after registration not dispatching is expected.
Windows NLM GetConnectivity called on the UI thread can freeze for seconds and drag the native layout assert into a crash (measured on a Win10 19045 host; reproduced deterministically with a poor network environment): showcase aborts steadily 3.7–4.7s after launch (0xC0000409 = fast-fail), with yoga's toplevel assert "availableHeight is indefinite so heightMeasureMode must be YGMeasureModeUndefined" on stderr right before; the crash rate drifts 0%–100% with network health (when the network is healthy the query is millisecond-fast, matching the "smoke survived" notes at commit time — easy to misread as a code regression). Locating path: page bisection on the same exe (removing only the System Integration page → 0 crashes) → disabling only the initial network query + callback registration → 10/10 stable → prewarming and delayed queries both still crashed (any in-process query freezes, timing irrelevant). Root cause: the GetConnectivity slow path can take seconds per call; the synchronous initial-value query at registration froze the UI thread for seconds, and the moment it returned yoga processed the backed-up invalidations through ScrollView's content-measurement GetPreferred* path (which calls YGNodeCalculateLayout with a NaN height — fatal for containers with a defined height style). Fix: shim adds yue_mbt_netwin_start/netwin_cached — a background MTA thread owns COM and the polling loop (interval 5s default) and writes a process-level cache; the UI thread only reads the cache (network_status returns QueryFailed until the cache is ready; on_network_status_change no longer backfills the initial value synchronously; g_net_last becomes None = unknown, first value always dispatches). The cross-thread COM apartment problem dissolves because the interface pointer is created and used on the same thread. Verification: moon check zero warnings, 128/128 tests, showcase smoke 12/12 alive (same environment was 7/10 crashes before the fix).
Measured sysmonitor figures (Ubuntu 24.04 XFCE X11, same methodology as the performance baseline at the top of this file: startup median, steady-state RSS, release binary): startup (exec → window map) over 5 runs 77/78/81/82/88ms, median 81ms (the hello baseline of 70ms is an idle system; this run carried 1042 processes); steady state with the process page foregrounded at 1Hz uses 2-3% CPU (sampling + derived data + 1000-row table rebuild + repaint total ≈ 25ms/second), RSS 84.9MB flattening at 85.8MB after 100s; binary 7.72MB (hello counterpart 7.03MB). The 1000-row process page meets acceptance: full sampling of 1053 processes takes 14.94ms/pass (release, ≈14µs per process — two /proc reads each), a 1.5% duty cycle at 1Hz.
The 1000-row table uses table_v_t virtual scrolling (only visible rows draw): refresh goes "full data sampling → filter/sort derived data → rows Store set → table load + schedule_paint", never rebuilding the view tree; selection is remapped by pid so it doesn't drift as the sort order changes every second. There is no C++ counterpart binary, so the combined "wrapper + data layer" overhead is attributed directly from the figures above; the UI drawing portion is on par with the hello baseline under the same methodology.
Panic in the IconPixmap property callback on the empty-pixmap state (2026-10, user's real-machine stack + before/after control): after clicking "switch to theme icon (Linux)" in showcase's tray demo,
set_icon_nameclears the bitmap by design (XFCE's panel prioritizes IconPixmap over IconName; without clearing, the old bitmap keeps drawing after a rename), leaving w=0/h=0/empty buffer. The panel receives NewIcon and re-queries IconPixmap;Item::propertycallsdownscale_pixmap(0, 0, …)to build the extra 16×16 entry — the old clamp guardif y*h/nh > h-1 { h-1 }is true when h=0 (0 > -1), clamping sy to -1, andpx[src+c]panics with a negative index inside the DBus callback (exit 134). The user's real-machine stack:traybus.downscale_pixmap(icon.mbt:63) ← Item::property(sni.mbt:539) ← Conn::handle_call(sni.mbt:660) ← drain ← on_bus_ready. Fix:downscale_pixmapearly-returns (0, 0, empty) for degenerate sizes (any of w/h/nw/nh ≤ 0, or a buffer shorter than wh4) and the two never-triggered clamps were removed (for valid inputs y*h/nh is always < h); the mainpixmap_valueentry already tolerated the empty state, panels fall back to IconName, and the SNI semantics are unchanged. Before/after control (same probe, same path: create tray → set_icon_name → panel/manual IconPixmap re-query): before the fix, exit 134 with a stack identical to the user's report frame for frame; after the fix, the property returns[(0,0,[]), (0,0,[])]and the process stays alive (also re-verified with a manual gdbus Properties.Get). Regression: icon_test gains 3 cases (0×0 empty pixmap, degenerate target size, short buffer — all return empty),moon test659 all green. Lesson: pure functions on the DBus property-callback stack must also defend against degenerate inputs — a panic there kills the message pump; "clamp to h-1" guards treat -1 as a legal value when the size is 0, so early return beats clamping; the cleared state (0×0) is a legitimate business state that every consumer must tolerate.
System monitor data layer (/proc, /sys — sysmonitor example)
- /proc and /sys pseudo-files always report stat size 0 (fseek/ftell cannot learn the length): whole-file reads must loop with incremental
freadand a doubling buffer (16MB cap); opening a directory succeeds butfreadfails with EISDIR — detect viaferror. Implemented in the app's own native stubexamples/sysmonitor/stub/sysmon.c; the MoonBit side goes throughread_text_file. - An app-owned native stub may live in a subdirectory:
"native-stub": ["stub/sysmon.c"]resolves relative to the moon.pkg directory; all symbols are within libc's default link set — zero shim / fork / vendored / link-flag changes. The test target links the stub automatically, so wbtests can read real /proc files. - /proc/stat column order is
user nice system idle iowait irq softirq steal guest guest_nice: the 9th column (guest) is already folded into user/nice by the kernel — adding it double-counts. Usage = (Δtotal − Δidle − Δiowait) / Δtotal; iowait is not CPU-busy. When the sampling interval is shorter than one tick (USER_HZ, usually 10ms), Δtotal ≤ 0 and the result is 0; a zero first sample makes the first screen show the since-boot average. - /proc/cpuinfo model field differs by platform: x86 uses
model name, ARM boards only haveProcessor/Hardware— three-level fallback; core count = number ofprocessorlines (logical CPUs incl. hyperthreading, matches nproc). - Windows /proc/cpuinfo core-count correction (>64 cores, final review):
GetSystemInfo's dwNumberOfProcessors only covers the calling thread's group (≤64), so multi-group machines must sum ActiveProcessorCount over GetLogicalProcessorInformationEx(RelationGroup). The original guard and offsets were all wrong: the relationship constant was 3 (RelationProcessorPackage; RelationGroup is 4), ActiveGroupCount read at it+8 (really it+10: 8-byte record header + 2-byte MaximumGroupCount), the GroupInfo base/stride assumed 32+64g (really it+32 base with a 48-byte stride per group), and the guard used 32+64g (really 32+48g) — for a single group the API returns Size=8+24+48=80 < 96, so total stayed 0 and the correction never fired, leaving >64-core machines at the current group's count. Authoritative layout (Microsoft Learn winnt.h): GROUP_RELATIONSHIP {WORD MaximumGroupCount; WORD ActiveGroupCount; BYTE Reserved[20]; PROCESSOR_GROUP_INFO GroupInfo[]} (24-byte header, union starts at record+8), PROCESSOR_GROUP_INFO {BYTE MaximumProcessorCount; BYTE ActiveProcessorCount; BYTE Reserved[38]; KAFFINITY ActiveProcessorMask} (48 bytes), and SYSTEM_LOGICAL_PROCESSOR_INFORMATION_EX's Size is the whole record length (including the Relationship+Size header), used as the stride to the next record. Verified: a harness fills a 2-group (64+32) buffer from the real structs and asserts the sum is 96; the single-group and truncated-Size degenerate cases each assert the fallback of 64. Real multi-group numbers still need Windows on-device confirmation. - /proc/meminfo units are always kB;
MemAvailableonly exists on kernels ≥ 3.14 — fall back toMemFree; used = total − available (includes reclaimable cache). - The
moon runwrapper process does not forward signals to its child: smoke-testing exit behavior requires killing the built exe child process — killing only the wrapper PID leaves an orphan window. - Measured full-process sampling (release, Ubuntu 24.04): 580 processes × 2 file reads (stat + cmdline) = 9.57ms per pass, ~1% CPU at 1Hz refresh; RSS comes from stat's page count × page size (equal to status's VmRSS), saving a third read per process.
- /proc/[pid]/stat comm can contain spaces and nested parentheses (process name "(foo (bar))") — split at the LAST ')' in the line; comm is truncated to 15 chars, so the full command line is read separately from cmdline (NUL-separated; empty falls back to [comm] for kernel threads).
- getpriority's nice = -1 is a legal value and is ambiguous with the error return: report success/failure via a
Ref[Int]out-parameter (kill / setpriority still use the errno return). - Windows has no /proc and no nice semantics: the stub keeps the same ABI at compile time and returns an "unsupported" sentinel (-1000) at runtime, which the MoonBit layer turns into a Chinese notice — the whole process page degrades cleanly; three-platform CI builds are unaffected (macOS takes the POSIX branch naturally).
- diskstats lists both whole disks and partitions (nvme0n1 alongside nvme0n1p1/p2/p3); an LVM mount's device name (/dev/mapper/ubuntu--vg-ubuntu--lv) does not match its diskstats name (dm-N) — resolve via
/sys/block/dm-*/dm/nameto find dm-N, then take the first slaves entry (measured: dm-0 → nvme0n1p3) to attribute IO rates to the mount row. - hwmon temperature indices skip numbers (coretemp exposes only some cores' tempN_input); labels may be absent (acpitz has none — fall back to the chip name); millidegrees can be negative (battery sensors); NVIDIA dGPUs commonly expose no hwmon temperature (measured: 0x2488 has none), so GPU temperature shows "—" under the hwmon convention.
- statvfs capacity uses f_bavail (available, minus reserved blocks) rather than f_bfree, matching df's Use% convention; the struct is flattened across the ABI into three int64 out-params (total/free/avail).
- /proc/mounts pseudo-filesystems (~20 kinds: proc/sysfs/cgroup2/devtmpfs/efivarfs …) have no meaningful statvfs capacity — the capacity table skips them via an fstype blacklist, keeping only real /dev/ device lines; multiple mounts of one device (btrfs subvolumes / LVM snapshots) are deduplicated by device, first occurrence wins.
- Directory enumeration (/sys/class/hwmon, /sys/class/net, /sys/bus/pci/devices, /sys/block/*/slaves) goes through a generic opendir/readdir stub (newline-separated entry names); together with read_text_file these are the data layer's only two IO primitives.
- The /dev/fuse control mount (appears once the file manager brings up gvfsd-fuse; mount point /tmp/fuse) returns a valid statvfs with f_blocks=0: filtering by the fstype blacklist alone is not enough — also skip total<=0, or the disk page shows a "0 MB / 0 MB" noise row (verified: the S5 whitebox assertion total>0 failed because of it).
- sysmonitor UI copy discipline (from the full UI-polish pass): interface text states only "what it is / how to use it" — never data conventions or implementation paths (e.g. /proc paths, "two-sample difference", "milli-degree conversion", or a "nvidia-smi planned later" note); rates, capacities and axis values always switch units dynamically (B→K→M→G) so numbers stay short; the large 24px statistic cards especially must never wrap and overflow.
- sysmonitor overview card pattern, modeled on Mission Center: icon + title, a spec subtitle (hardware specs such as CPU model / total capacity / mount point live in the card subtitle — not in a window-level subtitle row), a current-value line (usage % · temperature, used / total · swap), and an in-card sparkline (series window + end dot; fixed 0-100 range or peak-adaptive, dual series overlaid for network rx/tx). Cards share width via flex and equal height per row via stretch, following window resizes; each card is self-contained and readable without the rest of the window.
Display protocols
- X11 ✅ mainline; Wayland unsupported — verify GUI behavior in an X11 session (tray / shortcuts are session-guarded).
- User idle seconds come from the X Screen Saver Extension (XSS) XScreenSaverQueryInfo, dlopen'd at runtime ("libXss.so.1"/"libXss.so") instead of build-time linking: keeps libxss-dev out of the distribution chain (prebuilt static libs ship via mooncakes; an extra dynamic dependency may be absent on consumer machines). XScreenSaverInfo is minimal — the shim hand-mirrors the struct (idle-milliseconds field at offset 24); a failed load takes the same path as "no X" and returns Unsupported.
- In an XWayland session input events never reach the X server, so the XSS idle reads absurdly high (hours right after user activity): an explicit WAYLAND_DISPLAY probe returns Unsupported — better no number than a wrong one; env -u DISPLAY (no X session) is likewise Unsupported; neither crashes. Measured cross-check (Ubuntu 24.04 XFCE X11): idle_seconds vs xprintidle same-instant double read differs by 17ms (criterion ±2s); after an xdotool-simulated mouse move, 0.317s vs xprintidle 320ms.
- Screen locking does not count as input: the XSS idle keeps growing while the screen is locked (lockers report no input). "User idle" and "screen locked" are orthogonal dimensions — do not approximate lock detection with an idle threshold (lock events come from logind, landing in a later batch).
- Idle-reading demo interaction: a click is itself an input event and resets the XSS idle counter — a "click the button to read the current idle" demo contradicts itself (it always reads 0.x seconds; command-line probes and xdotool-based external reads never exposed this). The correct shape is a switch that starts a periodic refresh (one read per second), with the timer stopping itself on the next tick once the switch turns off (libyue timers have no cancel id — the callback checks the switch state and suicides).
GTK specifics
A Table inside a Notebook tab segfaults during size measurement (negative allocation): keep it in a plain container or a standalone window.
Content widgets (Group / Scroll) extend View, not Container: attach content via
SetContentView;AddChildis rejected by the type check.Upstream NUContainer defects (patch
patch_linux_container_events): (1) the event window raises on map, stealing hits from native child widgets (tabs unclickable, wheel dead) →gdk_window_show_unraised; (2) container preferred sizes hardcode 0 and Scroll's size_request is 0×0 → width follows the viewport, height resolves to the content's natural yoga height. Do not report yoga's dynamic natural size to GTK: allocation pollutes yoga state and requisition negotiation oscillates without converging.The visibility guard at the top of
UpdateChildBoundsmisses GTK's first size-allocate (it happens before map): layout must run unconditionally.Slider::SetValuesets the ignore flag even for identical values, swallowing the user's first callback: set it only when the value actually changes.ProgressBar::SetValuemeans 0..100 on both Linux and Windows; the yue layer is unified on 0..1, so the conversion branch must cover both platforms.View::GetBoundsInScreenstacks coordinates wrongly under Scroll / nested containers (patchpatch_linux_view_bounds_in_screen): GTK screen coordinates must be "client-area origin (gdk_window_get_origin) + offset within the client area (gtk_widget_translate_coordinates)";gtk_window_get_positionincludes the title-bar decoration — mixing it with translate is off by exactly one decoration size.Table checkbox-column indicators scale with row height (XFCE theme): set
indicator-size=16explicitly on checkbox columns and cap the renderer height at 20.Drag-out data must use the
Data(std::vector<base::FilePath>)constructor (the string constructor silently degrades to Text); relativize paths first (g_filename_to_urirejects relative paths).The drag preview hotspot is hardcoded to (0,0) upstream; the patch centers the image on the cursor (
patch_linux_drag_icon_hotspot).Whether a drop is accepted is decided by drag-motion (
handle_drag_update);handle_drag_enteris only an entry notice. Register the Image data type too — dragging in from image viewers / browsers yields image content, not file paths.libyue's
Entry::SetTextswallowson_text_change: the GTK side guards with anis-editingobject-data flag that filters thechangedsignal during programmatic sets (loop prevention), so the visible text changes but consumers get no callback —input_t's clear ✕ therefore "cleared the text but never refreshed the filtered list". Fix: the clear handler explicitly invokeson_input("")once. Any path that programmatically changes Entry / TextEdit text and then relies on the callback must invoke it manually.Drag-out initiation: calling
gtk_drag_beginsynchronously desyncs GTK's drag state machine (nested gtk_main never exits, drag works once), so it must be deferred until the event queue drains, initiated with the press event, and the drag_context backfilled; drag-failed needs a defensive cleanup (fork mbt.12).Creating a Browser page (WebKitGTK) aborts the process immediately:
Could not create GBM EGL display: EGL_NOT_INITIALIZED. Aborting...— WebKitGTK 2.5x's DRMDeviceManager initializes the main DRM device when a WebView is created and RELEASE_ASSERTs to death when the GBM EGL display can't be obtained. On the NVIDIA proprietary driver withoutlibnvidia-egl-gbm, GLVND only has the X11 backend (10_nvidia.jsonhas no GBM), so neither card1 nor renderD128 yields a display (measured:eglGetPlatformDisplay(GBM)returns NULL; note that in-process probe results don't match WebKit's actual path — an in-process probe on renderD128 initialized successfully while WebKit still aborted, so don't gate on probe results). showcase mounts a Browser page on its first screen, so it always crashed. Library-side fix: the shim's app_init (Linux) setsWEBKIT_DISABLE_DMABUF_RENDERER=1unconditionally (overwrite=0, an explicit user setting wins), pushing WebKit onto the legacy rendering path — no more crash, full Browser functionality (only web content loses one layer of GPU acceleration; the cairo-based UI is untouched). Verified: baremoon run examples/showcaseopens its window, stays alive, zero aborts. Root fix at the system level: installlibnvidia-egl-gbm(NVIDIA's GBM EGL backend), after whichWEBKIT_DISABLE_DMABUF_RENDERER=0restores hardware rendering. Engineering note: after changing the shim, moon does not necessarily relink (the exe isn't in its dependency graph) — runmoon cleanor delete the exe to force a relink and confirm withnm exe | grep <new symbol>.The overlay-scrollbar default needs a process-environment guard: the
GTK_OVERLAY_SCROLLINGenvironment variable outranks gsettings inside GtkSettings (distro scripts / users exporting=0to force classic scrollbars is a common practice), and GTK re-reads the process environment every time a ScrolledWindow is created — writing thegtk-overlay-scrollingGtkSettings property during theme init alone cannot beat the env-var path. app_init (Linux) callssetenv("GTK_OVERLAY_SCROLLING","1",1)before gtk_init (overwrite=1: the library default andset_overlay_scrollbar(true)win over the global preference; a caller turning a single scroll area off explicitly still goes through the per-widget API), immune to machines running such scripts; the later GtkSettings write stays as a fallback for paths that bypass the env var.Cross-axis scroll-range bug (the root cause of unscrollable pages, verified bit-for-bit with probes): the fork's Linux ScrollImpl::GetMaximumScrollPosition uses the viewport width as the vertical page_size — measured
max_y = content natural height − viewport width(window 900×600: pure drawn-rows content 1064 → maxy 164, charts+rows 1088 → 188, pure labels 1160 → 260 — all exact hits), with the horizontal axis wrong symmetrically. Consequences: programmatic scroll range collapses to a fraction of the real one, and the floating thumb never appears (the library hides it at maxy ≤ 0.5). Initially-visible and shown-after-hidden scrolls are equally wrong (briefly misdiagnosed as a hidden-mount measurement issue, disproved by an always-visible control group reading the same value; queue_resize/update_layout remeasure attempts had no effect — GTK's measurement was correct all along, nativeui's reading was what's wrong). Fix: on Linux the shim'syue_mbt_scroll_get_max_position_x/ynow readsupper − page_sizestraight fromgtk_scrolled_window_get_h/vadjustment(other platforms still pass through to nativeui); after the fix 1160−600=560 exactly, and sysmonitor's right pane scrolls by wheel (5871 differing pixels before/after screenshots).Scroll range is a set_content-time snapshot and also bites non-lazy scenes: on GTK the ScrolledWindow's adjustment upper comes from a one-time snapshot of the content's natural height at set_content time (the fork writes it as a size request; details and the lazy-mount probe matrix in the Chinese doc's corresponding entry). Besides lazy mounting, any content that grows after mount hits the same wall —
set_textgrowing a report label by dozens of lines, or a video player mounted into a placeholder container after the page is up — leaving the scroll range stuck at the skeleton height (bottom clipped, wheel dead). Fix on the mounting side: callScroll::refresh_content_size()after each dynamic growth; systemprobe's multi-page refactor injects that refresh closure into every page body and wires it into report set_text, player mounting, and command-preview text (verified 2026-10).A report label does not grow after set_text (clipping — the second half, complementary to the scroll-range snapshot): on systemprobe's system-capabilities page, clicking "read system capabilities" showed only the first 1-2 lines of a dozens-of-lines report (user screenshot: the volume section kept only its sink line; "volume: 45% muted: no" and everything after was invisible). Root cause: a GTK label's (nu_label) yoga height is measured once at mount (from the placeholder text);
Label::SetTextonly changes the pango text and does not mark yoga dirty — the new text is clipped to the old height.refresh_content_size()only writes the container's existing preferred height to the GTK viewport, so it cannot save the label's own height (it fixes the scroll range — the first half, see the entry above). Probe control (same page-shell structure: scroll → vbox → long-placeholder label): set_text + refresh alone rendered only line 1 of a 16-line report; addingset_visible(false)→set_visible(true)before refresh rendered all 16 lines. The toggle works because the shim'syue_mbt_view_set_visibleperforms a root-levelLayout()recalculation (same code on all three platforms, no platform branches), forcing yoga to re-measure the label; both calls happen within one turn, so there is no visible flicker. Fix: systemprobe's shared layer gainsreport_set(label, text, refresh)(set_text + visibility toggle + refresh), and every report write site on the system-capabilities / command-preview / video pages routes through it. Verification: real-machine click on "read system capabilities" rendered the full report (volume 45% / brightness / browser history / downloads / VS Code / binary lookup — six sections), 62979 differing pixels before/after the click;moon checkzero warnings +moon test657 all green. Lesson: a GTK label's "growing" and its "scroll range" are two separate things — the former needs a visibility toggle (or an equivalent relayout) to force a yoga re-measure, the latter is what refresh_content_size is for; any demo page with a dynamically growing report should go through report_set, not refresh alone.All three paths to a GTK label's "natural height" are untrustworthy (deepening of the "visibility toggle fixes it" entry above; only exposed when dynamic content exceeds the viewport, 2026-10, user feedback + probe + DBus double evidence): the earlier fix (visibility toggle to force a yoga re-measure) only cured half the problem — it looks fine while the content fits the viewport (a 16-line report < viewport); once the content exceeds the viewport it still won't scroll and the label gets clipped (40 lines allocated 19), which the user reported as "after the report updates and refresh is called there is no scrollbar". Three layers of root cause: ① a GTK label's natural height is measured by yoga's
MeasureLabel→AttributedText::GetBoundsFor, whosepango_layout_set_height(text_, size.height() * PANGO_SCALE)does not guard NaN — when yoga passes an undefined height (GetPreferredSize's NaN measurement), NaN→int is garbage and pango measures the whole layout as one line (temporary shim print measured preferred_h=92, i.e. button + one placeholder line); ② the viewport's definite height (382) flows down holder→vbox, and MeasureLabel's AT_MOST mode truncates the text to 341 (40 lines allocated 19) — the content can never exceed the viewport; ③refresh_content_sizewrites ①'s wrong value (92) to the viewport, the adjustment upper never moves, andget_max_scroll_position_ystays 0. Mechanism control: in the same probe,set_content_size(600,1000)yieldsmax=618(=1000−382) on the next tick, proving the adjustment chain itself works and the value refresh writes is the culprit. Fix (pure MoonBit side, no shim/fork changes): yue gainsmeasure_text_height(text, role?, width?)(an AttributedText probe with the role's font measured at large width/height, avoiding both the NaN and AT_MOST traps);report_setbecomes set_text → measure natural height →set_style(label, "height", h+2)explicit height → visibility toggle (root relayout) → refresh. With an explicit height yoga never calls the measure function, so both traps are avoided at once. Verification: probe (same page-shell structure, 40-line report): before the fix label_h=341/max=0; after, label_h=699 (full text height), body_h=773, max=391 (=773−382), 5 wheel clicks give pos=263, and a screenshot confirms all 40 lines render and scroll correctly. Boundary: the explicit height is not recomputed on window resize (width changes → wrapping changes → stale height) — acceptable for a demo page; a later on_size_changed hook could re-set it. Lesson: all three paths to a GTK label's "natural height" are untrustworthy — a yoga re-measure gives one line for an undefined height, truncates for the viewport's definite height, and measures the placeholder text at mount; dynamically growing text must always go "measure with an AttributedText in the same font + set an explicit height" — never leave the natural height to any automatic path.A hover group's (hover_group) detection cannot rely on container enter/leave: GTK pointer events don't bubble — delivered to the deepest hit GdkWindow and finished there — and child containers' (NUContainer) / native widgets' (Entry etc.) event windows monopolize pointer events, so ancestor containers never see enter (sysmonitor cards do receive enter only because their children are window-less labels; once a child is a container or native widget the stream breaks). A true "event pass-through" would need either upstream fork changes (every view broadcasting events up the ancestor chain) or platform-wide hooks (XI2/WH_MOUSE) — both invasive and heavy to maintain, so not taken. The workable mechanism is pointer-position polling: all groups share one global 100ms timer (a set_timeout chain that stops entirely when no groups exist), one pointer query per tick plus one screen-rectangle compare per group, all microsecond-level; cursor_screen_x/y and get_bounds_in_screen share screen-root coordinates, consistent under multi-monitor tiling including negatives (verified bit-for-bit with a probe). enter serves only as a hit accelerator; leaving is entirely the poll's job. Cursor pass-through is generalized the same way:
cursor_groupshares the same global poll tick and, while the pointer is inside the group bounds, recursively SetCursor on the group root plus every descendant through the shim'syue_mbt_view_set_cursor_deep(setting only the container does not cover children with their own windows); leaving restores Default — the original per-child cursor, not the pre-group one (the library offers no read-back, so don't wrap a group whose children must keep their own cursors). A visible background must be self-drawn in on_draw (set_background_color and the backgroundColor style are both unreliable for draw-less containers — theme CSS and child windows suppress them; paint the hover/idle backgrounds directly in the group container's draw, transparent child regions show through naturally). Measured: hovering a child widget (input) turns the whole card to fill_hover (239,241,243) exactly, and leaving restores it.The self-drawn floating scrollbar (overlay_scroll) was demoted to an explicitly opted-in component after the cross-axis bug, then picked back up for Windows after real-machine acceptance: it once was the declarative
scroll()default form (to unify the look across platforms against Windows' classic bars), but the thumb depends on the maxy/on_size_changed chain and turned unstable once the cross-axis bug stacked on top, soscroll()fell back to the platform's native scrollbars; after the cross-axis fix the Windows form was picked back up on its own after real-machine acceptance — on Windows,scroll()with overlay=true and no explicit policy wraps a host container and routes through attach_overlay_thumb, while non-Windows and explicit-policy paths keep the native bar (overlay=true requests the overlay style — guaranteed by the environment variable on Linux; Windows has no overlay equivalent); the overlay_scroll component and Windows-side page-level scrolling share this path.Reading the system accent color (GTK3 has no accent API;
yue_mbt_system_accentfalls through three steps): 1) the GNOME 47+ GSettings keyorg.gnome.desktop.interface/accent-color— when the schema exists but the key does not (e.g. Ubuntu 24.04's gsettings-desktop-schemas),g_settings_get_stringaborts instead of returning empty, so probe withg_settings_schema_has_keyfirst (abort reproduced with a probe on this machine); 2)@define-color theme_selected_bg_colorin the current theme's CSS: GTK themes express their primary color as the selection background, searched in~/.themes→$XDG_DATA_HOME/themes→$XDG_DATA_DIRS/themes→/usr/share/themesundergtk-3.0/{gtk,gtk-contained,gtk-dark}.css, with the dark preference deciding which file is parsed first (Orchis themes define the light accent in gtk.css and the dark variant in gtk-dark.css); 3) empty when neither hits. Verified with a probe (Ubuntu 24.04 + XFCE + Orchis-Teal-Light-Compact): returns#009688, matching the theme CSS bit for bit; XFCE has no accent setting, so step 2 is what captures the theme's primary color.Semi-transparent backgrounds must go through on_draw + Painter fills, never
set_background_color: libyue'sColor::ToString()(upstream and fork alike) emitsrgba(r, g, b, a)with a as a 0-255 integer, while GTK CSS rgba alpha accepts only 0..1 — out-of-range values clamp to 1.0 (measured on this machine withgdk_rgba_parse:rgba(0,0,0,89)→alpha=1.000,rgba(0,0,0,128)→1.000,rgba(0,0,0,0.35)→0.350). So any 8-digit hex carrying alpha renders fully opaque through the background-color path on Linux; the dialog_t mask#59000000(intended 35% black) showed as pure black until it was moved to an on_draw fill (set_fill_color+fill_rect, the same Cairo path as the video control bar, consistent with the hover_group entry's "visible backgrounds must be self-drawn"). The earlier byte-order fix's probe claim "#80000000 = 50% black" came from the Painter path and does not hold for backgrounds. Verified: probe window (white content + resident dialog) screenshot, three sample points in the mask area allsrgb(166,166,166)=#a6a6a6, bit-for-bit the theoretical value of#59000000over#ffffff(255×(1−0x59/255)=166);moon checkzero warnings +moon test657 all green. Whole-repo scan: the mask was the only 8-digit-alpha hex on the background-color path;with_alphaoutputs all flow through Painter fills. Lesson: label the render path in every color probe conclusion — Painter fills and the background-color CSS path behave differently for the same value.
Windows 10 / 11 ✅
First full local-chain verification environment: Windows 10 19045 + VS BuildTools 2022 (v17.14) + SDK 10.0.26100 — prepare.py build → moon check / test / build → hello launch smoke all pass (previously Windows had CI-only verification; some branches had never been compiled against a real Windows SDK).
Toolchain
- Requires VS Build Tools (VCTools workload + the ATL component for
base/win/atl_throw.h); run moon / cmake from an x64 Native Tools Command Prompt or after vcvars64. Installer quiet / passive mode requires elevation, else Exit 5007. - Release asset names are
libyue_{v}_win.zip/_mac.zip(not windows / darwin). - On case-sensitive volumes compilation fails with C1083 on
webview2.h: the SDK ships onlyWebView2.h(capital W); prepare.py adds a lowercase alias after extraction. On the same volumeshutil.copyfile's samefile check is unreliable — delete the destination before copying. - Platform-specific code must guard its includes and implementations in the same batch: a bare
gtk/gtk.h, or a function guarded at the call site but not at the definition, explodes on the other platform's compiler with C1083 / C2065. - The power/session message code (B6) hit three SDK facts on its first real-machine compile: the
PBT_APMRESUMEmacro does not exist (resume only ever sends the unconditionalPBT_APMRESUMEAUTOMATIC, plusPBT_APMRESUMESUSPENDafterwards only for user-input resumes — the latter is a subset of the former, and matching both fires the callback twice per wake); SDK 10.0.26100 has folded thePBT_*constants into winuser.h and there is no standalonepbt.hat all — explicitly including it fails with C1083;WTSRegisterSessionNotification/NOTIFY_FOR_THIS_SESSIONare declared inwtsapi32.hwhile theWTS_SESSION_LOCKmessage codes live in winuser.h — a missing include therefore reports only the functions as undeclared while the message codes compile fine, which misleads you into thinking the headers are okay. - shim platform differences: guard
dlfcn.hwith__linux__; MSVC needs_USE_MATH_DEFINESforM_PI;base::FilePathisstd::wstringunder UNICODE builds — go throughFromUTF8Unsafe / AsUTF8Unsafe; Windows has no Popover and noSetOverlayScrollbar/Clipboard::Selection/Tray::SetTitleetc., all degraded to no-ops in the shim;operator new/deleteredirect tomalloc/free(moon builds its runtime with MOONBIT_ALLOCATOR=SYSTEM); a widget's HWND comes fromdynamic_cast<nu::SubwinView*>(GetNative())->hwnd()—GetNative()itself is not an HWND. - Native child HWNDs do not follow the container after scrolling (they float over scrolled content): force
View::Layout(), and the scroll wrapper registers on_scroll with the callback deferred via a 0ms timer until layout completes; an input's inner shadow isWS_EX_CLIENTEDGE, so borderless must clear STATICEDGE / CLIENTEDGE / WS_BORDER; DatePicker shows only the year unless given an explicit width; glyph-based small icons are inconsistent across platforms — draw them with Painter vectors inside components.
Link flags (moon → cl / link)
cc-link-flagsis spliced verbatim into the cl command line; GNU-style-L/-ltriggers D9002. The correct form is link inputs (build/yue_mbt.lib setupapi.lib …): cl forwards .lib position arguments to link, and system libraries resolve via the LIB environment variable.- Path separators must be forward slashes: backslashes are eaten by moon's argument parsing and link fails with LNK1104.
- The official CMakeLists system-library list is incomplete; copying it verbatim yields 144+ LNK2019. The prepare.py list is complete.
- The CRT must match moon's static /MT: CMake multi-config generators ignore
CMAKE_BUILD_TYPE, socmake --buildmust pass--config Release(prepare.py automates this), else LNK4098 plus unresolved__imp__*. - The console window on exes is fixed by the
win_gui.clink pragma built into the yue package: the pragma lives in the .obj's drectve section and an archived member is only read if it is referenced —initialize()references the stub symbolyue_mbt_win_gui_markerto guarantee extraction, so consumers need zero configuration; the PE-header rewrite in release-bin.yml (Subsystem 3→2) remains as a fallback. User link_flags are spliced before/link, so cl discards/SUBSYSTEM-class link options outright (D9002) — appending flags cannot work. Under the GUI subsystem stdout is visible only through pipes / redirection. - After swapping
yue_mbt.lib,moon buildreports "no work to do": delete the produced exe under_buildto force relinking. - prepare.py mode-switch pitfall (prebuilt↔source):
cmake -Donly overrides the CMakeCache when explicitly passed — a stale ON from a previous prebuilt configure survives into source mode, so none of the globbed sources get compiled,yue_mbt.libends up with just the single shim obj, and the final link fails with 360 library-wide unresolved symbols. Fix: always pass the flag explicitly as ON/OFF for both modes. Quick check:lib /list build\yue_mbt.liband count the objs — a full Windows source build has 27. - prebuild's link_configs Windows branch once omitted the separate entry for
yue/traybus: traybus does not depend on yue (it is the reverse), so propagation "to targets that depend on the package" never reaches it, andmoon testlinking the traybus test exe failed with 12 LNK2019yue_mbt_sys_*symbols; the Linux/macOS branches already carried the separate entry, and Windows was brought in line.
Manifest
- moon's link-flag splicing behavior (measured on Windows): for each main package it splices the flags of every package carrying link_configs in the dependency closure once, and for blackbox test targets it splices the tested package's own flags one extra time (tested package's entry ×2). Listing a
.libtwice is harmless; listingmanifest.restwice puts two same-name MANIFEST resources into the exe → CVT1100 link failure. The earlier conclusion that "newer moon embeds its own MANIFEST that conflicts with our res" was wrong: mt verified that moon-linked exes contain no manifest resource at all — the "other copy" of the conflict was always our own duplicated res (it started firing for any target that splices both flag sets once the Windows link config for traybus was added). - Channel exploration conclusions: link options like
/MANIFEST:EMBED/MANIFESTINPUT:placed into link_flags are discarded by cl as compile options (D9002 — options before/linkdo not reach the linker);#pragma comment(linker,"/manifestdependency")requires the linker to run with /MANIFEST, which moon's link does not (no external .manifest file appears next to moon-linked exes either); prebuild's stdin carries only an environment snapshot and module_root, with no target/package info, so per-target differentiated configs are impossible. - Final scheme: manifest.res stays out of the default link_flags — dev / test / moon run need zero configuration. The cost of running without a manifest is more than visual: on a real Win10 19045 machine, clicking a button in a message box killed the process —
TaskDialogIndirectis only exported by ordinal 345 of comctl32 v6; without a manifest the v5.82 comctl32 is loaded, whose export table also has an ordinal 345 pointing to an unrelated function, so libyue's by-ordinal lookup resolves a non-null garbage pointer and calling it is plain UB (a python ctypes probe confirmed the resolved non-null address; a MoonBit probe replicating the exact same usage ran clean three times in a row — UB is nondeterministic, a single non-repro does not disprove it). Fork fix (mbt.13): MessageBox checks comctl32's DllGetVersion before resolving the ordinal and only calls it on major version ≥ 6; below v6 it falls back to the classicMessageBoxW(icons mapped from the TD__ICON values to MB_ICON, MB_OKCANCEL with OK returning the first custom button's response when two or more buttons are registered, MB_OK otherwise with cancel semantics), so a dialog always shows regardless of the manifest; OnClose is uniformly posted back to the UI thread (the old empty-resolution path calling it straight from the background thread was also a cross-thread hazard). The first degradation build closed silently on a null resolution, which real-machine feedback reported as "clicking the message box does nothing", hence the MessageBoxW fallback; a second round of feedback reported "icons and buttons but no text" — TaskDialog keeps the main text inpszMainInstructionand only the informative text inpszContent, whileSetTextwrites only the former, so the fallback must concatenate both fields into MessageBoxW's single text line. Distribution-style builds setYUE_MBT_KEEP_MANIFEST=1: the res rides the yue entry, and moon build's main packages splice each entry exactly once, embedding exactly one manifest (Common-Controls v6 + supportedOS, verified by reading it back with mt) — with v6 a real TaskDialog shows; do NOT set this switch for a fullmoon test(the blackbox tested-package duplication guarantees CVT1100). release-bin.yml is configured accordingly. - After an environment variable changes link_flags, moon occasionally keeps relinking with the stale config: if setting/unsetting the variable seems to change nothing, fall back to
moon clean(or delete the produced exes under_build).
Runtime differences
- 2026-09-25: the whole tree rolled back to the 09-23 evening state f3ba8bf (the user's call): the entire fix chain made from 09-24 onward for "form-page native children following the scroll / page-switch streaks / tooltips never showing" (first-generation 0ms resync → fork recursion into UpdateChildBounds → dropping WS_CLIPCHILDREN → allocation translation → pixel blit → band repaint ordering → page-switch relayout coalescing, 30+ commits, including fork ba479418/e373e60a/f4528cb8/cf308737, the browser package split, and three tooltip generations) proved a net burden across rounds of real-machine review — every round introduced new diseases (jank / blue ghosts / streak recurrence / ghosting / floating inputs / tearing) without curing the old ones, so the tree was rolled back wholesale per the user's decision; native pinned back to v0.15.6-mbt.12. Core lesson: sync-layer fixes for mixing native HWNDs into a self-drawn world failed on the real machine repeatedly — each such fix must pass a single-point real-machine verification before the next one is layered on; bundling several fixes per round is forbidden. Full root-cause narratives of the rolled-back approaches live in the git history of 09-24~09-25 (both repos). Accepted known state after the rollback: native tooltips never show on Windows (the native path needs a comctl32 v6 manifest), native inputs follow scrolling via the update_layout fallback, and page-switch/scroll streaks remain (inherent to WS_CLIPCHILDREN). After the rollback the user hand-picked items to restore (same day): the browser package split (with the lib-form-differentiated linking fix), vector self-drawn result symbols, the cursor_group component, the network-status label() fix plus the Linux initial-dispatch fix, the Windows live-resize bitmap placeholder (pure shim subclass), and the single-line Entry wheel forwarding (fork 7f57e87f, delivered via the v0.15.6-mbt.18 prebuilt; the unselected fork ba479418 was removed in 3f8e8935 before cutting that release). Not restored: the self-drawn tooltip bubble, the prepare backport machinery, and every scroll/page-switch family fix. The Windows overlay-scrollbar form was restored later (its own batch after real-machine acceptance): only the form itself was ported (host wrapper + self-drawn thumb + flex/basis 0 inside the host branch); the 09-23 baseline's update_layout follow fallback stays untouched and the non-Windows branch is unchanged. The original commit's bundled "remove the fallback + unconditional basis 0" (the collapse root that aab1a74 had to cure) did not come along.
- MoonBit concrete-type misuse in FFI handle parameters:
MessageBox::run_for_windowonce passed theWindowstruct straight into an extern expecting aViewhandle (the C side received a MoonBit heap address instead of the registry id), soCastTo<nu::Window>failed silently — the message box ended up parentless and the synchronous path deadlocked. Detection: the MoonBit-generated C prototype showsstruct ...Window*for the parameter instead ofvoid*. Rule: any extern declaration taking a handle must useView(the external type) and let MoonBit call sites unwrap the concrete struct via.view(); the asyncshow_for_windowand the syncrun_for_windowmust follow the same convention. Also note: moon's incremental check is unreliable — after changing the shim/library,moon buildmay report "up to date" and not relink, so delete the produced exe under_buildto force it (otherwise you probe stale-library behavior). - Visibility-toggled pages disappear after switching (set_visible-driven tabs like tabs_t, verified on a real machine): libyue win's
View::Layout()propagates only to anIsContainer()parent (if (GetParent() && GetParent()->IsContainer())), and Scroll is not a Container — after a subtree mounted under a Scroll's content toggles visibility (set_visible → yoga display switch), the propagation chain breaks at the Scroll, the yoga root never recalculates, and the page being shown gets a 0-height allocation; when the page content has no explicit height it is squeezed to the sum of its paddings (measured: page height 50→16, the whole content block vanishes). GTK hides this behind full size-allocate relayout; it is Windows-only. Fix: the shim'syue_mbt_view_layout(update_layout) now walks the parent chain to the root container and callsLayout()there, and tabs_t'ssel.subscribeadds oneupdate_layout(outer)after the visibility flips. A probe (page-content on_draw self-report + bounds dump) confirms every switch paints correctly with a stable page height. Note: only trees routed through a Scroll's content break the chain — a direct flex child of the root always recalculates. - Visibility-toggle vanishing, residual three-layer case (the showcase structure: page-container set_visible switching + scroll + section + tabs_t, still reproduces on a real machine): on tab switch, the dirty self-heal branch of
Container::Layout(the one carrying a TODO comment in view.cc) allocates outer containers from the yoga mid-state of the display switch — sibling flex:1 page containers get a compressed height from the scroll content's mid-state measurement (measured: 210→56), and it cannot self-correct afterwards: the self-heal propagation breaks at Scroll (not a Container), and the root-level recalc's SetBounds chain breaks at intermediate containers whose size did not change (identical size → ViewImpl::SizeAllocate early-returns → children's UpdateChildBounds never fires). Consequence: the WM_PAINT dirty rect is clipped by the compressed page container into a 24-high sliver that does not intersect the page area (74,278,512,50), soDrawChildskips the whole block onchild_dirty.IsEmpty()— on_draw never fires while bounds look fine. Tried and measured ineffective: root recalc ×N, reversed visibility order (true before false), root recalc inlined into set_visible (shim side), leaf-level visibility (only page content toggles), page_c flexbasis 0 (CSS flex:1 1 0 semantics). Conclusion: the root cause lives in libyue win's yoga integration itself (dirty self-heal using stale layout + the Scroll chain break), which external shim/yue-layer patches cannot penetrate; a fork-side fix is needed (candidate directions: force YGNodeCalculateLayout before allocating in UpdateChildBounds, or keep Scroll content measurement out of the mid-state). The 20babb5 fix fully covers the case where tabs_t sits directly in a scroll's content (no page-container layer); the showcase case awaits the fork-side approach. - Scrollbar form: libyue's Windows scrollbar is a self-drawn classic style (the Scrollbar class: track + arrow buttons + permanent layout space), and
Scroll::SetOverlayScrollbaris a no-op on Windows (the API is excluded by#if !defined(OS_WIN)in the header) — GTK's floating form has no counterpart here. The unifying answer is the yue-levelattach_overlay_thumb: set policy to Never to hide the platform bar, and a self-drawn thumb positioned via yoga absolute (static right/top/bottom) spanning the right edge's full height, with the visible segment painted in on_draw from Ref state — scroll updates only schedule_paint with zero yoga relayout; appear/fade uses two clear_timeout-cancellable timer steps (d9→73→hidden), dragging relies on the implicit mouse capture acquired on press (moves keep arriving outside the thumb — see the WM_CAPTURECHANGED note under the splitter entry), and wheel events on the slim thumb are forwarded to the Scroll manually via on_wheel. Integration point: the declarativescroll()default form (Windows, overlay=true with no explicit policy) wraps a host container and routes through it (the style parameters move to the host with the Scroll filling it — the thumb's absolute positioning is anchored to the host; attaching the thumb to the page container directly would be skewed by padding/margins); the overlay_scroll component and Windows-side page-level scrolling share this path. The Windows on_scroll→0ms-timer forced relayout of native HWNDs is kept. - System accent color:
DwmGetColorizationColorreturns the "window colorization color" — a blend of the accent color with the system base color, visibly off from the Settings accent color under default configuration (reference machine Win10 19045 returned yellow-green 0xFFB7AC00 while the Settings accent color is teal 0xFF00B7C3); read the registryHKCU\Software\Microsoft\Windows\DWM\AccentColorfirst (0xAABBGGRR, written since Win10 1803+), falling back to the colorization color only when missing / 0 / 0xFFFFFFFF — after the fix the probe returned 0xFF00B7C3, bit-for-bit equal to the Settings page. The fallback value's alpha bits encode "intensity", not opacity; take RGB only. - GetSystemPowerStatus semantic losses (battery query): ACLineStatus 255 (unknown) counts as not-online; BatteryFlag 128 (no battery) / 255 (unknown) both count as no battery; BatteryLifeTime drifts with AC/battery mode and is often -1, so remaining time is never reported (the Linux UPower side folds State 1/4/5 all into "on power", aligning the two platforms). Also: at full charge on AC the BatteryFlag is High without the Charging bit (measured: percent=100 charging=false), so "full but still on power" shows as non-charging on Windows; there is no change event for the charge level itself (only plug/unplug), so slow drain/charge needs polling to stay fresh (the showcase system page uses a 30s set_timer plus instant refresh on plug events, both through the same query path).
AttributedTextranged font / color: upstream Windows supports only whole-text (ranged calls CHECK-crash; GDI+ has no rich text). Fork mbt.9 builds a segmented layout engine (run storage / flowing line breaking / measure-draw from one source) and the MoonBit-side degradation guards are removed — three platforms now agree. Gotcha:Gdiplus::Font::GetHeight's overload takes(const Graphics*); passing a reference does not compile.Color::Get(Border)hits NOTREACHED and returns a garbage color: the shim maps Border toGetSysColor(COLOR_WINDOWFRAME).- Blurry self-drawn text: libyue's GDI+ brush hardcodes grayscale antialiasing; an idempotent prepare.py patch switches it to
TextRenderingHintClearTypeGridFit. - System notifications: WinRT toast looks up the notifier by AUMID and silently fails without an AppUserModelID; the shim sets the AUMID automatically before the first notification and writes the registry DisplayName.
- Browser prefers WebView2 (falls back to IE when the loader / runtime is missing); WebView2 follows the system proxy — on machines with a broken proxy set
LIBYUE_WEBVIEW2_ARGS=--no-proxy-server(a prepare.py patch injects AdditionalBrowserArguments from the variable); the demo:// custom protocol does nothing under WebView2 (the IE path works). - win32 Group / Scroll do not grow with content: give them explicit heights; ScrollImpl's scroll range only honors SetContentSize, so a prepare.py patch queries the content yoga tree for natural size when none was set explicitly.
- Key codes and modifiers: the Windows KeyboardCode uses Win32 VK values, normalized to the constant table at the events.mbt entry; Windows' native modifier bits are Shift=2 / Ctrl=4 / Alt=8, and the shim's
NormalizeModifiersgained an OS_WIN branch mapping to the unified 1/2/4/8. - Ghost tray icons: abnormal exits skip the CRT static-destruction chain and leave the icon behind; the shim installs four process-level hooks (atexit / SetConsoleCtrlHandler / SetUnhandledExceptionFilter / SIGABRT) that re-issue NIM_DELETE by "owner window + icon ID range"; a taskkill /F hard kill cannot be rooted out in-process. A blank tray icon means check the asset first (it was once a 1×1 placeholder).
- The Popover substitute (borderless / non-activating / topmost window): the popup needs
WS_EX_NOACTIVATE(else clicking the popup steals focus); under nested scrollingGetBoundsInScreenyields garbage offsets, so anchor coordinates come from the native child HWND'sGetWindowRect; close is destructive on Windows — a reusable popup switches toSetVisible(false); outside-click dismissal installs aWH_MOUSE_LLhook that PostTasks a close when the press lands outside the popup rectangle; the popup background ignores themes, soPopover::set_background_colorwas added end to end. libyue's SetVisible / IsVisible are unreliable on a non-activating topmost window — use Win32SetWindowPos+SW_SHOWNOACTIVATEdirectly. - autocomplete keyboard navigation: the Windows branch attaches on_key_down on the Entry (↑↓ highlight / Enter select / Esc dismiss); single-line EDIT has no vertical-centering style, so
entry_vcenternarrows the control by font line height and splits the leftover into margins; RichEdit text stays black regardless of theme —Entry::set_colors(EM_SETBKCOLOR + CHARFORMAT2) hooks it into the theme chain. - Dark mode for native controls: real Win32 common controls (RICHEDIT50W / SysListView32 / SysDateTimePick32 etc.) all ignore the system dark theme; RichEdit can be darkened via the message channel; Table cannot (its custom draw repaints a white base over external messages); the official answer is the self-drawn component library, with native dark mode as a known boundary.
- Hit testing runs opposite to painting: upstream
FindChildFromPointiterates children in insertion order, so a late-attached full-screen mask paints on top but its events fall through to the earlier container (dialog uncloseable, clicks pass through) — fork mbt.6 reversed the traversal to match paint order. Any "visually-topmost view receives no events" — check the hit-test direction first. - The wheel is consumed outright by the outermost Scroll and never dispatched, so nested scroll areas and self-drawn canvases never scroll: a prepare.py patch (
patch_win_wheel_dispatch) dispatches to the child under the cursor via FindChildFromPoint, and shimyue_mbt_view_on_wheelgained a Windows branch converting WM_MOUSEWHEEL deltas. - Text measuring and drawing disagree:
GetBoundsForuses GenericDefault (with overhang) whileDrawStringuses GenericTypographic, so manualx=(width−measured)/2centering always sits left; positioning must usealign=Center/Endand leave it to the platform — measuring is only for computing container widths. - Two splitter traps: an explicit
SetCaptureon Windows triggersWM_CAPTURECHANGED, which tears down the implicit capture (skip if capture is already held); theflexbasis:"50%"percentage string parses only on GTK — use pixel values everywhere. - The Entry infinite-recursion case: two mutually recursive functions in the non-Linux branch overflowed the stack, and MSVC C4717 had already warned — treat "logically doomed" warnings as errors. For "no window" GUIs use
Get-Process <name> | Select MainWindowHandle; MoonBit println is fully buffered through pipes and lost if the process dies — instrument via stderr. - mount_window activates the window automatically after the handle callback; consumers no longer call activate manually.
- Platform info / locale / scaling / clipboard / timers / global shortcuts / global mouse polling / canvas (GDI+) all verified working.
- An offscreen Canvas cannot be created before initialize on Windows: a single
Canvas::newcrashes with 0xc0000005 (minimal repro involves no drawing at all). Root cause: Canvas / DoubleBuffer / Painter construction depends onnu::State— GDI+ (GdiplusHolder), the default font, and NativeTheme are all established with State, which initialize() creates; a bareGdiplusStartupfallback inside canvas_new still crashed (State has more null dereferences), and the correct fix is running the sameyue_mbt_app_init()as initialize wheng_stateis null. Contrast with Linux: the cairo image surface has no State dependency, so offscreen geometry runs without initialize (only the text path needs the GTK stack — see "Self-drawn canvas and chart rendering"); Windows could not even start geometry, and after the shim fallback the "offscreen Canvas usable anywhere" semantics now agree across all three platforms. charts_wbtest's offscreen geometry benchmark passes on Windows accordingly (17/17). - A platform-independent function mistakenly stubbed in a platform branch: wire's
'd'codec goes throughyue_mbt_sys_f64_to_bits/from_bits(a pure bit reinterpretation implemented with memcpy), but the implementation sat inside the OS_LINUX branch with a non-Linux stub returning 0 — on Windows three pure in-memory wire tests failed with everything decoding to 0 (wire codec tests need no bus and run on non-Linux too). Fixed by moving them outside the platform branches. The rule: go through the stub list asking "is this really platform-specific" — pure computation / pure in-memory logic never gets stubbed (same family as the macOS note on the#elsefallback swallowing macOS). - The single-instance message window is the first self-owned WNDPROC/window-class code in this repo (grep found no prior usage): the class name derives from the app_id (
moonbit_libyue_instance_<app_id with dots replaced>), and the window must be created on the main thread that runs the libyue loop. WM_COPYDATA is dispatched synchronously by SendMessage into the WNDPROC (it does not traverse the message queue); the receiver posts back to the main loop via MessageLoop::PostTask before invoking the MoonBit callback, so no application code runs inside the peer process's SendMessage stack. The mutex uses the Local\ session namespace (no elevation needed); a second same-name CreateMutexW within one process hits ERROR_ALREADY_EXISTS, guarded idempotent via a static handle. [Pending real-machine verification] actual dispatch of the message window under the libyue loop, SetForegroundWindow success rate under foreground-lock, and the title-based fallback when mixed with older builds. - The UI thread's COM apartment must be STA or file dialogs hang/crash: the common file dialog
IFileDialog::Showrequires STA — with COM never initialized on the UI thread,CoCreateInstance(CLSID_FileOpenDialog)fails outright (null dereference inside the lib), and when an application call path initializes the thread as MTA first (typically an old in-UI-threadCoInitializeEx(MTA)network query),Showhangs forever — observed as "app freezes the moment you open/save a file". Fix: shimyue_mbt_app_initcallsState::InitializeCOM()right after creating State (ScopedCOMInitializer STA + OleInitialize, idempotent); afterwardsCoInitializeEx(MTA)on the UI thread only yieldsRPC_E_CHANGED_MODEand local COM objects keep working. Complementary to "move NLM queries to a background MTA thread": the background thread's COM init is thread-local and does not replace the UI thread's own STA. Verify: file dialogs open and return paths on a real machine. - No global repaint on theme switch on Windows leaves stale pixels ("ghosts"): the GTK side of
theme_applyrebuilds CSS and repaints every window synchronously as a fallback; the Windows side used to only notify theme-subscribed custom views, so unsubscribed regions (and the area behind hidden/moved native child windows) kept old pixels until a mouse-over invalidated them locally. Fix: shimyue_mbt_repaint_allgains a Windows branch —EnumWindowsfiltered to this process's visible ownerless top-level windows,RedrawWindow(RDW_INVALIDATE|RDW_ERASE|RDW_ALLCHILDREN)to invalidate everything including native children, invalidation only (WM_PAINT goes back to the message loop);theme_apply's windows branch calls it. Verify: toggling dark/light repeatedly leaves no residue on headers/pages. - Zero-height custom-drawn views silently render nothing (table_t text column invisible):
ViewImpl::Invalidatereturns early on empty sizes, and a childless on_draw container with no height style measures to 0 in yoga and never paints — table_t's CellText cell is exactly that (text drawn via on_draw, no children), so the whole text column vanished while header/zebra/checkboxes stayed fine; GTK's clipping behaves differently which is why it never showed there (the mirror-image of the earlier "GTK small-cell path fills don't render" trap). Fix:cell_viewsetsminHeight=row_heighton the CellText container. Lesson: an on_draw container needs a non-zero size source (explicit height or children); "no children + auto height" equals "not painted" on Windows; an offscreen canvas pixel probe rules out the paint pipeline first (colored AttributedText is bit-exact fine on a Windows canvas). - GDI+ blend modes: only Normal/Copy take effect:
PainterWin::SetBlendModemaps only Copy toCompositingModeSourceCopy, everything else falls toSourceOver— GDI+Graphicshas exactly these two compositing modes, so Multiply/Screen/Difference/Xor are silent no-ops. Offscreen pixel probe: the Multiply overlap (128,128,255) is bit-identical to SourceOver (mathematically expected #8028FF). This is a platform capability gap; the lib stays untouched (a full blend set needs D2D); the showcase canvas demo reports "only Normal takes effect on this platform" on Windows, and the platform wording ofImage::write_to_filein docs is corrected accordingly (Windows GDI+ encoder handles png/jpeg; the mac release package never compiled it and always fails). - The native Tab emits
on_selected_page_changewhen the first page is added (internal initial selection, not a user switch): registering the callback before adding pages makes it fire spuriously during mount (declarativetab()used to let switch-counter demos start at nonzero); registering after the add loop avoids it.
macOS (CI build chain verified, GUI pending real-machine)
- The libyue v0.15.6 release ships ARC / no-ARC dual libraries: Darwin link flags = main library +
-lyue_mbt_noarc(no-ARC symbols are referenced by the main library, so it goes after) + frameworks AppKit / Carbon / IOKit / Security / WebKit / OpenDirectory +-lobjc -lc++ -lpthread -lbsm -Wl,-dead_strip; the prebuild Darwin branch is pre-configured accordingly. - CI (macos runner) covers build + tests; headless runners have no WindowServer, so no GUI smoke.
#elsefallbacks in shim platform branches swallow macOS: borderless needs#elif defined(OS_WIN); CurrentDirForDrag splits into three branches (mac usesgetcwd);Window::SetSkipTaskbar/SetIcon/App::SetIDhave no macOS declarations — guard the calls as no-ops.- AppleClang 17 (after the macos-15 image update) parses the return of
getRed:green:blue:alpha:as void, making![...]a compile error; the value is guaranteed after the nil check and sRGB conversion, so discarding the return compiles under both BOOL/void parses (69136b7 — once lost to a full-tree rollback and picked back up; when a rollback baseline contains a sick file, later fixes vanish with it, so cross-check that file's history before re-pushing vendor). - Three root causes of the CI red streak before the 0.5.0 release (since 9-22; Linux/macOS red, Windows green): ① the AppleClang compile error above dead-locking prepare; ② the missing extern "C" (see ABI section) dead-locking the link; ③ sysmonitor's S4 hardware-sampling test asserting "coretemp always has a Package sensor" — virtual-machine runners lack the hardware, so environmental gaps (no sensors / no DISPLAY) must skip, not assert. Also: the CI native-layer cache key must include the shim source hash (with only prepare.py in the key, a shim change keeps the old key and the restored build/ cache holds a stale shim archive); and with no Actions log access, slicing the failing output into
::errorannotations (the check-runs annotations API is anonymously readable) is the only forensics channel.
Maintenance
- Add new conclusions to the matching section, recording only the pitfall and the fix; protocol-interop conclusions must come from the real bus and real panels — unit-test self-consistency does not count.
- Keep both language versions (this file and docs/zh/adaptation.md) in sync in the same batch.