Skip to content

Experimental - #24

Merged
AdamMusa merged 25 commits into
mainfrom
experimental
Aug 7, 2026
Merged

Experimental#24
AdamMusa merged 25 commits into
mainfrom
experimental

Conversation

@AdamMusa

@AdamMusa AdamMusa commented Aug 7, 2026

Copy link
Copy Markdown
Owner

No description provided.

AdamMusa added 25 commits July 29, 2026 01:39
The shipped iOS VM was compiled twelve days before flet_app was renamed to
ruflet_app, so a self-contained build ran a framework that had never heard of
the control. Explorer asked for ruflet_app, the old core fell back to a
generic control, and serialization derived "RufletApp" from the type -- a name
no client registers, so the device showed "Unknown control: RufletApp" while
web and desktop, which serialize on the host, were fine.

Rebuild the device slice from current sources, and map the type in the control
registry as well. RufletAppControl::WIRE is only consulted when the control
resolves to its own class; going through the registry keeps the wire name
FletApp even when it resolves generically, which is what the embedded runtime
does.
A self-contained build copied the whole project directory into the app. That
shipped store artwork, tests, CI config and native project files to every
device, and Apple rejected an App Store upload outright when it found a
lockfile under release_assets it read as an unsigned code object: "Code object
is not signed at all".

Copy what the app needs at runtime instead: main.rb, lib/, and assets/. The
gems are compiled into the VM and ruflet.yaml and services.yaml have already
been applied to the native project by build time, so none of it is a runtime
input. assets/ stays because code loads it by path -- image(src:), lottie(),
fonts, audio.

Listing what belongs in beats enumerating what to leave out: the previous
approach only excluded directories someone had thought of, which is how
release artwork reached a shipped binary.

Also refresh ios/Runner/Info.plist from the template. It was not a managed
file, so a client generated before a plist key existed kept its original copy
forever -- the local-network usage description never reached projects, and iOS
silently blocked every connection to a development server.

For Ruflet Explorer this cut the embedded project from 461 files to 200 and
the iOS app from 90MB to 72MB.
The simulator slice was still the build from before flet_app was renamed, so
a self-contained app run on the Simulator hit the same "Unknown control:
RufletApp" the device did. Rebuild both simulator architectures and lipo them
back into the xcframework.
Both still carried the framework from before flet_app was renamed, so a
self-contained build on either platform would emit RufletApp and render
nothing. Windows is relinked from a fresh mruby archive; Android is rebuilt
for all four ABIs against a regenerated host_vm, which is where its CMake
takes the compiled framework bytecode from.
Last two platforms still carrying the pre-rename framework. Built natively
per architecture in a container, then relinked into the shared libraries the
Linux plugin loads.
Every prebuilt VM is now built from current framework sources; 0.0.9 rebuilt
only macOS. Record the embedded gem versions the artifacts actually carry.
0.0.10 relinked both from the mruby archive alone. The shipped libraries had
always been mruby plus desktop/ruflet_vm_host.cpp, so dropping it removed the
four ruflet_vm_* entry points and the embedded bootstrap bytecode that
ruby_runtime_plugin links against, and a Linux or Windows build failed at
link time.

Link the host layer back in. iOS, macOS and Android never regressed: their
plugins compile that layer themselves, which is why only the shared-library
platforms broke.
iOS and macOS drove mruby directly from their plugins, each carrying its own
copy of the bootstrap and the boot sequence that desktop/ruflet_vm_host.cpp
already implements. Two implementations of the same logic can drift, and they
had: relinking the Linux and Windows libraries from the mruby archive alone
silently dropped the host layer and broke those builds, while the Apple
plugins kept working because they never used it.

Compile the host layer into the Apple archives and reduce both plugins to the
same four-call bridge Linux and Windows use, so the built artifact is the only
thing a runtime release changes.

With mruby no longer compiled by the plugins, drop the vendored mruby sources,
Onigmo and host build outputs from ios/ and macos/ along with the header
search paths and build defines that fed them.

Also fix Ruflet::Server#web_client_root, which memoized with
defined?(@web_client_root). The embedded VM has no defined? keyword, so it
parsed as a method call and every HTTP request raised NoMethodError on device.
A separate resolved flag is needed because nil is a valid resolved value.
Opening the embedded mruby VM cost ~323ms, and ~307ms of that was the two
icon modules materializing their tables during mrb_open: for each of 10,147
icons they derived a constant name, interned it as a symbol, inserted it into
a hash and defined a constant. An app that names five icons paid for all of
them, on every cold start.

ruflet_ui.rb already deferred these modules with autoload, but the guard
excludes mruby -- the mrbgem concatenates the framework into one blob with no
filesystem require, so autoload cannot apply there and the embedded runtime
paid the full cost. Resolve the constants through const_missing instead, which
works on both engines, and build the constant-name index only for callers that
want the whole table (names, all, constants, ICONS).

Measured on macOS arm64 against the same toolchain and gem set:

  mrb_open()                323ms -> 16.5ms
  start -> server bound     348ms -> 23ms

Callers that probed with const_defined?(name, false) have to go through
resolve_constant now: with deferred constants, const_defined? is false for
every icon nobody has referenced yet, so the old probes silently stopped
finding icons that do exist.

packages/ruflet_core: 581 tests pass. Adds an embedded-VM test for the
deferral contract, since the CRuby test for it skips on mruby.
Experiment: let the platform own the runtime's lifecycle instead of waiting
for Dart to ask. The macOS plugin starts the VM from +load, before the engine
exists, reading the packaged project straight out of the app bundle -- so it
never needs the project copied to a writable directory first. Dart calls
serverUrl() and gets an answer that is usually already waiting.

Opt-in through RufletRuntimeAutostart in Info.plist: a server-driven app has
no packaged project and must not boot a second VM.

Measured on macOS arm64, demo payload, medians of 8 cold starts, time from
the plugin's dylib load to the server binding its port:

                              Dart-driven    platform autostart
  icons built at VM open        555ms            394ms
  icons built on demand         233ms            191ms

Autostart is worth ~160ms against today's runtime but only ~42ms once the
icon tables stop being built at VM open, because it can only hide as much
Ruby boot as there is Ruby boot. The prologue it overlaps is ~190ms.

Also adds the cold-start benchmarks these numbers come from, and a `timeline`
method reporting milliseconds since dylib load so Dart can timestamp itself
against a clock that starts before the engine.
Starting the VM from the platform layer only helps if Dart then leaves it
alone. Awaiting serverUrl() before runApp gives the whole win back: the first
frame would block on the VM, which is what booting it early was meant to avoid.

Documents the contract on serverUrl() and in the runtime README, and commits
the Flutter harness that checks it. Swapping a VM that binds in ~23ms for one
that takes ~350ms:

  first frame   188.5ms -> 189.3ms
  URL ready     190.8ms -> 378.7ms

A 16x slower VM moves the frame by 0.8ms, so the frame is not waiting. If that
ever stops holding, something has started awaiting serverUrl() too early.
Verified the two startup designs against the real RufletApp/demo project and
hit this immediately: with autostart enabled, a Dart start() call reached
ruflet_vm_start, which saw a running VM and returned success without adopting
any of the arguments. The runtime kept writing to the paths autostart chose,
so the app waited 30s for a port file nothing would ever write, then timed out
with "no port published" and no indication why.

The VM boots once per process, so autostart and start() are mutually
exclusive. Say that instead of reporting a success that did nothing.

Also replaces the earlier harnesses with one that measures a real app rather
than a bound socket: it speaks the Ruflet protocol and sends register_client,
which the server answers only after running the application block and building
its page. Medians of 8 cold starts, every run producing an identical
27,307-byte page patch:

  VM open / startup design        first frame   page rendered
  icons eager / Dart-driven         191.5ms        610.0ms
  icons eager / autostart           187.6ms        434.7ms
  icons lazy  / Dart-driven         190.3ms        289.3ms
  icons lazy  / autostart           200.4ms        242.0ms

The first frame holds at ~190ms while page-rendered moves 368ms, so Flutter is
not waiting on the VM in any configuration.
Everything so far was measured with a purpose-built harness. Built
RufletApp/demo the way ruflet build --self does -- the full 80MB client with
its Flet extensions -- and ran both startup designs against it. Medians of 5-6
runs after a warmup, timed from process spawn; every run returned an identical
27,307-byte page patch, so all four really rendered the app.

  VM open / startup design        server bound   page rendered
  icons eager / Dart-driven          626ms          762ms   <- ships today
  icons eager / autostart            473ms          550ms
  icons lazy  / Dart-driven          306ms          443ms
  icons lazy  / autostart             85ms          130ms

762ms -> 130ms, 5.9x.

This corrects the earlier finding. The minimal harness put autostart's marginal
value at ~47ms once icons were deferred; against the real client it is ~313ms.
Autostart hides the application's startup prologue, and a minimal app barely
has one -- the real client initializes a dozen extensions and extracts its
project from the asset bundle before it ever calls start(). Autostart skips all
of it, so the server is listening at 85ms, before Flutter's extensions have
finished initializing.

Adds verify_app.py, which drives a built .app rather than a harness, and the
client entrypoint diff these numbers came from.
macOS proved the shape; this brings iOS and Android to the same behaviour and
keeps ruby_runtime a package, since a Flutter plugin can already run native
code before the engine exists.

iOS and macOS now share apple/ruflet_runtime_autostart.h, reached the same way
both bridges already reach desktop/ruflet_vm_host.h. The duplicated startup
logic is gone and macOS cold start is unchanged (85ms to bound, 127ms to a
rendered page).

Android starts from an androidx.startup initializer, which the system creates
as a ContentProvider before Application.onCreate and long before the
FlutterEngine. Two things differ from Apple platforms:

  - Assets are entries inside the APK rather than files on disk, so the
    packaged project is unpacked before mruby can require from it. Doing that
    here takes it off the Dart critical path, and the unpacked tree is keyed to
    the install so only the first launch after installing pays for it.
  - Autostart routes through MrubyRuntimePlugin's existing JNI entry points.
    The prebuilt .so binds those exact class and method names, so declaring new
    natives elsewhere would compile and then fail with UnsatisfiedLinkError.

Verified on an API 35 emulator with the real demo client: the project unpacks,
the server binds, and the quantum simulation renders.

start() now reports autostart_owns_runtime on both platforms rather than
silently doing nothing, matching macOS.
Desktop has no hook as early as +load or an androidx.startup provider: the
earliest a plugin runs is its registration, during engine setup. That is still
before Dart's main(), so the VM boots while the engine finishes coming up
rather than after the application has initialized. The head start is smaller
than on mobile and desktop cold start was never the pressing case, but the API
is now the same on all five platforms.

Both share desktop/ruflet_desktop_autostart.h, alongside the ruflet_vm_host.h
they already shared. The start function is passed in rather than called
directly because Windows resolves the VM out of ruflet_vm.dll at runtime and
has no such symbol to link against.

Linux posts its reply through g_idle_add rather than responding from the worker
thread: the wait must not block the platform thread, but GLib objects belong to
the main context.

Not built here -- these need GTK and MSVC respectively, and this machine has
neither. The shared header is syntax-checked; the two bridges are not.
Earlier numbers stopped when the server could render a page. That is not the
number a user feels, and it flattered autostart: it counted the runtime being
ready, while the application still had to finish starting Flutter before it
could show anything.

Both designs are now instrumented identically and timestamped on the platform
timeline, which starts before the engine exists.

macOS, medians of 8 cold starts, time to the frame that mounts FletApp:

  icons eager / Dart-driven    579.4ms   <- ships today
  icons eager / autostart      402.5ms
  icons lazy  / Dart-driven    261.7ms
  icons lazy  / autostart      224.9ms

Android (API 35 emulator, icons eager in both, .so not yet rebuilt), medians of
6 cold starts each after pm clear:

  Dart-driven                  932.1ms
  autostart                    551.8ms

The gap between extensions_ready and url_ready is the runtime sitting on the
critical path: 360ms on macOS and 420ms on Android with Dart-driven startup,
4ms and 17ms with autostart. Autostart takes the runtime off the critical path
entirely and Flutter's own startup becomes the floor, which is why its
remaining value on macOS is small once icons are deferred but large on Android
where the prologue is longer and the project has to come out of the APK.
Anything to do with the VM belongs on the platform side. Flutter's job is the
extensions and the widget tree; it should only need the address to point
FletApp at.

The CLI now writes the autostart flag and the packaged project name where each
platform actually reads them -- Info.plist on Apple, meta-data in
AndroidManifest -- instead of passing RUFLET_EMBEDDED_PROJECT as a dart-define.
The VM starts before Dart does, so a value that only exists inside the Dart
isolate is one it can never see. Both writers are idempotent: PlistBuddy's Add
fails on an existing key, and a rebuild must not duplicate manifest entries.

Desktop needs no flag. It has no manifest to carry one and treats the presence
of a packaged project as the opt-in, which is the same distinction the flag
draws elsewhere: a self-contained build ships a project, a server-driven one
does not.

Verified on the emulator with no dart-define at all: the platform finds the
project from meta-data alone and the demo renders.

Also adds startup phase logging to the Android autostart (library / unpack /
boot), which is the only way to tell unpacking from VM boot on a real device.
It immediately earned its keep -- on a run where Flutter reached
extensions_ready in 212ms rather than the usual ~510ms, the eager-icon VM took
479ms and became the bottleneck. The 17ms margin measured earlier is a
coincidence of the two finishing together, not headroom.
Rebuilding the Android library from a fresh host_vm fails to link with an
undefined GENERATED_TMP_mrb_ruflet_record_gem_init: shared/mruby_gems_init.c
calls that gem's init and the Android CMakeLists compiles its sources, but the
build config never listed it. The shipped artifacts were evidently built from a
config that did, so this only bites someone regenerating host_vm -- which is
exactly what rebuilding a VM requires.

Found while rebuilding the Android VM to measure the deferred icon tables. On
an API 35 emulator that rebuild takes VM boot from 466ms to a median of 54ms,
so the runtime becomes roughly 7x faster than Flutter's own startup instead of
racing it. The rebuilt library is not committed here: only arm64-v8a was built,
and a mixed artifact set would be worse than the current consistent one.
Misdiagnosed. The undefined GENERATED_TMP_mrb_ruflet_record_gem_init came from
building against uncommitted work in a local checkout, not from the repository:
shared/mruby_gems_init.c and the Android CMakeLists there both reference the
gem, and ruflet-record, packages/ruflet_record and vm/vendor are all untracked.

Committed sources reference it nowhere, and the shipped artifacts do not
contain it -- no sqlite3_prepare or RufletRecord symbols in any of them. The
build config was right as it stood.
The icon fix landed in the framework sources but every shipped binary still
carried the eager tables, so no application had the improvement. Rebuilds
macOS, iOS and Android from current sources.

  VM boot, real demo app
    macOS      348ms -> 23ms
    Android    466ms -> 41ms   (API 35 emulator)

On Android the runtime now finishes roughly 7x faster than Flutter's own
startup instead of racing it: the VM is ready at ~56ms while Flutter reaches
its extensions at ~470ms.

Artifacts also shrink, because these are stripped and the previous ones were
not. Nothing consumes DWARF inside a static archive an application links, or
inside an .so Gradle strips again at packaging time:

    macos/libruflet_vm.a           40.5MB -> 15.4MB
    ios device slice               20.4MB ->  7.6MB
    ios simulator slice            40.4MB -> 11.1MB
    android arm64-v8a              19.6MB ->  5.0MB
    ... and the demo APK           115.4MB -> 113.5MB

Verified end to end on both platforms with the real demo client: identical
27,307-byte page patch, macOS reaching a rendered page in 126ms.

Linux and Windows are unchanged -- they need their own hosts to build. Their
artifacts still have the eager tables, so those two platforms keep the old
boot cost until someone rebuilds them with tools/build_vm_artifacts.sh.
Bumps the CLI's fallback constraint: a client generated without an existing
ruby_runtime entry would otherwise resolve ^0.0.9, and the client entrypoint
now calls serverUrl(), which does not exist there.

Excludes third_party/ from the published package for the same reason vm/ and
android/src/main/cpp/ already are -- consumers receive prebuilt VMs, not the
sources they were built from.

Verified the whole pipeline end to end: `ruflet build macos --self` on the demo
app resolves the local runtime, writes RufletRuntimeAutostart and
RufletEmbeddedProject into Info.plist, generates the non-blocking client, and
produces an app that binds in 84ms and renders in 125ms.
Made autostart depend on the CLI writing a flag, which coupled three
independently released things: publish them out of order and generated apps
break. Pushing the template proved it -- with the published CLI not yet writing
the flag, the runtime never started and every app showed an error screen.

Two changes remove the coupling entirely.

The packaged project is now the opt-in. A self-contained build ships one and a
server-driven build does not, which is the distinction the flag was encoding
anyway -- desktop already worked this way. Nothing has to be configured, so it
does not matter which version of the CLI generated the client.
RufletRuntimeAutostart / ruflet.runtime.autostart still turn it off.

And start() no longer fails when the platform owns the runtime. Those arguments
cannot take effect -- the VM boots once per process -- but the thing that caller
is waiting for already exists, so the port is written into the file it is
polling. A client generated before serverUrl() existed keeps working and gets
the parallel startup without knowing anything about it. Measured on macOS with
an unmodified start()-based client: 350ms to bound and 435ms to rendered
becomes 104ms and 154ms.

Verified on macOS, on an iOS 18 simulator and on an API 35 emulator, each with
a start()-based client and no autostart configuration.

Also teaches tools/build_vm_artifacts.sh to build Linux and Windows. Linux is
native to the container, Windows cross-compiles with mingw-w64 since a Windows
container cannot run on a macOS host. Both need a build named "host" for
mruby's gperf presym step, which the platform configs do not define -- it only
surfaces in a tree with no previous build, which is exactly what a container is.
Completes the set 0.0.13 started: all five platforms now build the icon tables
on demand rather than at VM open.

Both are built in a container. Linux is native to it, and the second
architecture comes from running the same target under --platform linux/amd64.
Windows cross-compiles with mingw-w64, since a Windows container cannot run on
a macOS host; the DLL imports only KERNEL32, WS2_32 and msvcrt, so it needs no
mingw runtime beside it.

  linux aarch64   14.7MB -> 4.4MB
  linux x64       14.7MB -> 4.5MB
  windows         36.5MB -> 4.5MB

Verified by loading each artifact and booting the real demo app. Linux boots in
40ms on aarch64. Windows was checked under Wine, which resolves all four entry
points, boots the VM, runs the app and binds a port -- it then fails in accept,
which is Wine's winsock rather than the runtime, so that part is still unproven
on real Windows.

The x64 figure of ~253ms is qemu emulating x86_64 on an arm64 host, not what
the artifact does on real hardware.
All five platforms now start the VM from the platform layer and build the icon
tables on demand.

Verified as a developer will actually receive it: the client entrypoint fetched
from the template repository (still the start()-based one), no autostart
configuration written, the currently published CLI. Against 0.0.13 that same
app takes 350ms to a bound server and 435ms to a rendered page; on 0.0.14 it
takes 91ms and 134ms, with no change on their side.
@AdamMusa
AdamMusa merged commit 1d77695 into main Aug 7, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant