Experimental - #24
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.