* fix a pile of splash script-vm bugs: newline statements, short-circuit args, tail calls
went through the script VM and parser and fixed a batch of correctness bugs
that were biting the launcher's mini-apps:
- newline-delimited statements: a `(` or `[` at the start of the next line no
longer greedily glues onto the previous value as a call/index. leading infix
operators and `.` still continue the expression (the shader DSL needs that),
and the divert is suppressed inside ()/[] groupings.
- short-circuit `&&`/`||` used as a call argument no longer loses its value to
nil when the jump skips a multi-op right-hand side.
- a call as the very last statement of a script actually executes now, in both
end-of-parse unwind loops (also patched a zero-offset ShortCircuitEnd).
- custom widgets that deref to a base with a #[source] field now forward
script_source, so script_apply_eval works on them instead of silently no-op'ing.
plus regression tests for the newline and short-circuit cases.
* harden splash isolates: scoped timers, net gating, effective-visibility snapshots
isolate-safety work so mini-apps can't reach outside their sandbox:
- isolate-safe script-timer dispatch hook + gc for stale timers
- gate net.socket_stream on the net runtime being present
- widget-tree snapshot reports effective visibility (a widget counts as hidden
if any ancestor is hidden)
- macos_activate_app (plus a headless no-op) so the launcher can focus itself
* widen the host->splash surface: splash setters, view/glassbutton script calls
everything the host needs to poke into a running mini-app's script:
- Splash: call_script_fn, set_script_global, set_allow_net, and a cached body id
so host->script calls don't rescan for the body every time
- View.set_visible and GlassButton set_text/text are callable from script now
- makepad_test learned right-click (secondary button) so the headless tests can
exercise long-press / context menus
* fix small-size glass lens + sdf box degeneration, warn on missing glyphs
visual correctness fixes we kept tripping over:
- cap the gauss lens band at 35% of the surface's smaller side so tiny discs
degrade gracefully instead of smearing
- clamp the Sdf2d.box (and box_x/box_y/box_all) radius so an oversized radius
saturates at a circle instead of collapsing into a rotated diamond
- log once per codepoint when no loaded font can render it (was silently
drawing .notdef boxes)
* guard stale rect areas in clipped_rect/abs_to_rel/set_rect against out-of-bounds panics
* add switch_finger_capture to hand a live finger capture between widgets mid-drag
* add promote_finger_capture_over: hand a child-grabbed finger up to a co-capturing container
* splash: add validate_splash_body, a dry-run eval for externally-sourced scripts
evaluates a body in a throwaway isolate with the exact prefix/limits the
Splash widget uses and returns the captured script errors instead of logging
them. lets hosts that install source from outside (downloads, AI generation,
user input) reject bad scripts with real errors to show or feed back, where
the widget's own eval silently keeps the old view.
* strip mod.res from splash isolates; document validate_splash_body caveats
the res module's handles reach both the filesystem (abs_path loads) and the
network (web_url / http resources) without going through the gated net
runtime, so a 'no-net' isolate could still fetch and exfiltrate. found by an
adversarial review of AI-generated app installs, but it applies to any
untrusted splash source.
also note on validate_splash_body that the instruction limit bounds compute,
not heap growth, and that top-level timers live until isolate reclamation --
same exposure as actually installing the source, so validation adds nothing
new.
* splash: jailed per-app file storage (mod.fs inside isolates)
mini-apps get an OS-style private data directory, like an android app's
internal storage or an iOS container: the app sees a filesystem rooted at
"/", and that root IS its host-assigned sandbox directory
(Splash::set_sandbox_dir / SplashRef forwarder). registered as mod.fs in
isolates -- deliberately shadowing the stripped real fs module, so inside
an app "the filesystem" simply is the jail:
fs.read fs.write fs.append fs.exists fs.remove fs.mkdir fs.list
containment lives entirely in the host layer:
- lexical path resolution against the root; `..` above the root, NUL, deep
or overlong paths are errors before any I/O
- the per-VM root is rust state keyed by the isolate's heap -- script code
can neither read nor retarget it
- symlink defense in depth: nothing here can create links, and every
existing component under the root is verified non-symlink before use
- quotas: 1MB/file, 16MB/jail, 256 entries
- no root assigned (previews) -> every call errors cleanly
validate_splash_body gives dry runs a throwaway jail (temp dir, removed
after) so top-level fs.read boot loads validate instead of erroring. roots
are dropped with their isolates in the gc.
unit tests cover the containment: traversal/absolute/backslash escapes,
depth/name caps, and the symlink block.
* splash: put the jailed fs module in scope as a bare name
app scripts say fs.read("/x"), but the eval prefix only used the widgets
prelude, so bare fs resolved to a not-found error value and every storage
call failed silently. bind it in the prefix (let fs = mod.fs) for both the
plain and net variants; a script reassigning fs only shadows its own name,
the jail stays host-side.
* splash storage: quota + boundary hardening from adversarial review
three confirmed jail findings:
- mkdir bypassed every quota (target + create_dir_all, no jail_usage check)
-> unbounded inode/dir-metadata exhaustion on the shared host volume.
now charges new dirs against MAX_ENTRIES via missing_entries(); write's
entry check does the same so a deep write can't overshoot the cap either.
- write/append/mkdir lacked remove's root guard: fs.write("/", data)
resolved real == root and reached create_dir_all(root.parent()) -- one
dir above the jail (the shared app_data/). now rejected like remove does.
- validate_splash_body's scratch jail used a predictable temp name created
with create_dir_all (would follow a planted symlink out of temp). now an
exclusive create_dir on a per-process+vm name (EEXIST-safe against a
planted entry), reclaimed via gc before the dir is removed so a top-level
timer can't resurrect it.
unit tests added for missing_entries; the containment tests still pass.
* splash: empty set_text tears down the isolate instead of no-oping
set_text("") was a silent no-op (eval_body early-returns on an empty body),
so a reused Splash that goes back to empty -- the widget-gallery live preview
on Back -- left its old isolate running its timers (and holding a storage-jail
binding) behind a blank view. now an empty body reclaims the isolate: the
isolate-minted view is replaced with a fresh empty one built in the main vm
BEFORE the isolate heap is freed, then the isolate is gc'd (stopping its
timers, dropping its jail root); vm_id resets to MAIN so a later non-empty
set_text allocs a fresh isolate as before. the existing host_launcher
teardown call sites (widget picker back()/reset()) become correct unchanged.
* overlay: composite glass in draw order, not creation order
every gauss/glass surface opens its own draw list and registers it in the
window's single Overlay via store_sub_list, which hands out the first free
slot and keeps it for the life of the process. renderers walk that table in
index order, so the paint order of all glass in an app was the order the
surfaces were first *created* — permanently, with freed slots reused by
whatever registered next. draw order never came into it, so a widget rebuilt
after a layout change, or a panel opened later, could land on top of anything
drawn after it. the only workarounds available to apps were "don't draw the
thing that's winning", which looks like a bug.
the hook for fixing it was already there and unused: CxDrawList's
draw_item_reorder, honoured by every backend (metal, d3d11, opengl, vulkan,
web_gl, headless raster). so stamp each overlay sub-list with the position it
was begun in this frame (Cx2d::overlay_seq, reset in Overlay::begin) and have
Overlay::end stable-sort the table by that stamp.
this also gets parent-then-child right without special cases, which matters
because glass.GlassButton / glass.GlassSegmented call begin_overlay_reuse
unconditionally instead of checking is_drawing_overlay(), so they hold their
own slots rather than riding their parent's.
* glass.GlassSegmented: size segments to their labels, add set_selected
three things, all of them things that looked broken to a user:
- segments were width/count, so "Max" got the same room as "Default": the long
word crowded, the short one floated. each segment is now measured (DrawText
layout size_in_lpxs) and gets its text plus padding, with leftover width
shared equally so every label keeps the same margin. if the labels don't fit,
the padding shrinks (never the text) to a floor. the pill's x/width are
computed in rust and passed as uniforms since they can't come from a segment
count any more, and hit-testing is a boundary lookup rather than a division.
- `selected` was public but the pill is drawn from a private sel_pos that only
followed it via the click animation, so restoring a saved value from code
left the control showing one segment while reporting another — and a click on
the segment it really held was then ignored as "already selected". that reads
as the control eating your clicks. set_selected keeps both in step.
- the travel easing was 0.30, which arrived before the eye could follow it.
0.16.
* text_input: re-layout when max_lines changes
the laidout text was cached on width alone, so flipping draw_text.max_lines
at runtime (collapsing a composer to one line) kept the old multi-row layout
and the field never shrank. make max_lines part of the cache key.
* text_input: add set_max_lines instead of making callers script it
applying script to a TextInput re-applies its #[live] fields, and text is
one of them, so toggling max_lines through script_apply_eval! silently
wiped whatever the user had typed. give it a typed setter.
* text_input: don't drop the layout in set_max_lines
clearing laidout_text there leaves the field with no layout for the rest
of the event batch, so every cursor op in that window bails out with
"can't move cursor because layout was invalidated by an earlier event".
since set_max_lines gets called from focus/blur handling, that window is
exactly when you're clicking into the field — so the click placed no
caret at all. max_lines is already part of the layout cache key, so the
next draw re-lays out on its own.
* text_input: add scroll_to_top
for a field that folds to a fixed height when it loses focus: the scroll
offset survives the blur, so a draft last edited near its end folds
showing whichever line the caret had scrolled to rather than its first.
leaves laidout_text alone — scrolling doesn't change the layout, and
dropping it would break every cursor op for the rest of the event batch,
same trap as set_max_lines.
* text_input: add set_height
for a composer that folds to one line when it loses focus. pinning the
height is the safe way to fold — unlike clamping max_lines it leaves the
laid-out text alone, and the laid-out text is what maps a click to a
caret position. fold by re-layout and the press that re-focuses the
field resolves against the folded layout while the expanded one is on
screen, so the caret and any drag-selection land on the wrong text.
* text_input: add take_key_focus, which actually shows the caret
the caret draws as (1.0 - blink) * focus, and both come from animators
that only move when the widget is dealt a Hit::KeyFocus. setting key
focus on a field that ALREADY holds it dispatches no hit — so a field
that was focused, then hidden (hiding doesn't clear Cx's key focus) and
shown again comes back typable but with no caret and no selection
highlight, animators still parked where the last focus-lost left them.
plays focus.on unconditionally rather than only when focus changed:
repairing the case where it did NOT change is the entire point.
* splash: name scripts in errors, and stop using line as an identity slot
a runtime error from a Splash app logged `:1804943384:12 - widget has
no uid`: empty file, and a "line" that is really a pointer address.
the format is {file}:{line}:{col}, and both fields were casualties of
the same hack — ScriptMod.line carried self_id so the body could be
found again (m.line == self_id && m.file.is_empty()), while ip_to_loc
adds that same field to the script's real line when reporting. so every
location came out as real_line + a pointer, and nothing said WHICH app.
identity moves to module_path, which nothing else reads for these
bodies, freeing line to be a line. file gets a real name via a new
set_debug_name the host calls with the mini-app's id.
the validator's ScriptMod gets the same treatment — its errors are
shown to the user AND fed back to the agent as repair input, so a
location offset by a vm id was actively misleading there.
* splash: document the constant offset in reported script lines
the host prefix is two lines, so a reported line is two ahead of the
app's own file. it can't be zero — a zero-line prefix would share line 1
with the app's first line, and that line is always the // name: header,
which would comment the prefix out.
* script: stop silently losing widgets emitted from branches and loops
Splash mini-apps kept rendering nothing from on_render closures with zero
errors logged. Bisected live and in pure-VM probes, this was a pile of
separate bugs in the same corner:
- an if/else whose branch emits a widget lost the taken true branch: the
statement's POP_TO_ME got fused onto the else tail, which the IF_ELSE
jump skips. Generalized last_short_circuit_target into last_jump_target
and record every branch join (if/elif/else, match, try/err), so the
commit lands standalone AT the join and every path runs it. Same disease
as the short-circuit-argument bug, new jump sites.
- elif never patched its arm's IF_ELSE jump (relative 0), so a taken arm
spun the interpreter until the instruction limit killed the whole entry.
elif now desugars into else { if ... } via IfElseExpr, which also gives
its arms the join treatment.
- for x in <non-iterable> silently skipped the body where the equivalent
while errored; now raises "for loop source is not iterable" (nil and
empty sources stay silent). for k v in <number> passed key/index swapped
and lost k after the first iteration (the advance only rebound v).
- a line-leading { after a value-ending line glued onto the previous
expression as a proto instantiation; it now starts a new statement,
same divert rule the ( and [ newline fix added.
- int literals were second-class numbers: U40/I32/F32/F16/U32 didn't
collapse to the number bucket in to_redux, so 6 .is_number() missed
method dispatch entirely and an int arg against a float default failed
with "expected number, got number". They're all just numbers now.
Regression tests in tests/on_render_emission.rs cover each shape, incl.
the exact calendar/weather patterns that were blank in the launcher.
* splash: keep an on_render closure's final widget, and say when a render fails
Two host-side halves of the emission-loss story:
- the parser turns a closure's last statement into its return value, so a
render closure that ENDS with a widget literal built it and then threw it
away (this is why wrapping a whole render in one extra View{} produced
nothing). script_result now pushes a returned widget object into me as
the last child; non-widget returns still get skipped downstream.
- a render closure that errors mid-run used to have its output discarded
with no diagnostics at all, which is what made this whole bug family
cost days to find. Now it logs the error before dropping the result.
* script: auto-close still-open fn and let states at end of source
Both parse drivers dropped EndFnExpr/EndFnBlock/EmitLetDyn through their
auto-close catch-all when the source ended with them still open. A module
whose FINAL statement was let c = <lambda> got a body whose jump-over
stayed 0 — FN_BODY_DYN re-ran, found its me already popped, logged
"me stack is empty" and fell straight INTO the body, running it inline
at definition time and ending the module eval early — and the let itself
never emitted, so the binding silently didn't exist (same for
let c = <call>). Now those states close the way the live handlers do:
return + jump patch for the body, LET_DYN/LET_TYPED for the binding, and
the let's own EndStmt no longer marks a statement value (LET consumed
it; the final RETURN would pop an empty stack).
Fun consequence: the old test idiom of reading a result via
let out = r
out
only ever worked BECAUSE the trailing let was dropped — RETURN popped
the naked value off the stack. With the let actually binding, scripts
must end on a call (echo(r)); the emission tests are updated to do that.
Regression tests in tests/auto_close_eof.rs, including the exact
deferred-boot-timer closure shape the launcher apps use (which was
already fine — it just LOOKED guilty, see the splash commit).
* splash: probe optional script hooks without spamming the error log
call_script_fn checks whether the fn exists and bails quietly — but it
probed with a trapping scope_value, which had already queued a NotFound
by the time the miss was handled. Every host broadcast of an optional
hook (on_app_resize, on_widget_resize) against a script that doesn't
define it logged
variable 00001e93e419c77c not found in scope
— maximally misleading: the hex is just id!(on_app_resize) (Rust-side
ids aren't in the reverse-lookup table so they print raw), and the
line:col is the stale ip from the end of that script's eval, which
pointed at whatever closure happened to be compiled last. In the
launcher that was the boot-timer line of every generated app, sending
the investigation down a deferred-closure rabbit hole the pure-VM tests
then cleared. Probe with NoTrap.
* script derive: don't name the eval values vec 'v'
script_apply_eval!'s generated values block bound 'let mut v' and then
spliced #(expr) interpolations in verbatim — so a caller interpolating a
variable that happened to be called v got the macro's own half-built Vec
(borrow errors if you were lucky, the wrong value if not). Obscure name
instead.
* headless: don't compile the Apple video path
Upstream's zero-copy video work put CoreVideo/Metal code in
gpu_texture.rs (plus two consumers) behind cfg(target_os = "macos")
alone. Under cfg(headless) the apple backend isn't built at all, so
every one of those symbols — ObjcId, msg_send!, CVPixelBufferRef,
CVMetalTextureCache* — is missing and makepad-platform fails to
compile with 94 errors. That takes the headless harness down with it,
which is what host_launcher's UI tests run on.
Gate the Apple blocks on not(headless) too. Nothing is lost: headless
has no Metal device to import a CVPixelBuffer into, so the whole path
is inapplicable there.
Not caused by the rebase — pristine dev has it: its headless
CxOsTexture is an empty struct while gpu_texture.rs reads .os.texture.
1264 lines
42 KiB
Rust
1264 lines
42 KiB
Rust
use {
|
|
crate::makepad_draw::*,
|
|
crate::makepad_draw::makepad_platform::script::std::ScriptStd,
|
|
crate::makepad_draw::makepad_platform::script::timer::CxScriptTimer,
|
|
crate::makepad_script::{script_err_not_found, ScriptFnRef, ScriptThreadId},
|
|
crate::widget::{WidgetRef, WidgetUid},
|
|
crate::widget_tree::CxWidgetExt,
|
|
std::any::Any,
|
|
std::cell::RefCell,
|
|
std::collections::{HashMap, VecDeque},
|
|
std::sync::atomic::{AtomicU64, Ordering},
|
|
};
|
|
|
|
static SCRIPT_ASYNC_COUNTER: AtomicU64 = AtomicU64::new(1);
|
|
pub(crate) const WIDGET_SCRIPT_INSTRUCTION_LIMIT: usize = 200_000;
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
|
pub struct ScriptAsyncId(u64);
|
|
|
|
impl ScriptAsyncId {
|
|
pub(crate) fn new() -> Self {
|
|
Self(SCRIPT_ASYNC_COUNTER.fetch_add(1, Ordering::Relaxed))
|
|
}
|
|
|
|
pub fn as_u64(self) -> u64 {
|
|
self.0
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
|
|
pub struct SplashVmId(pub u64);
|
|
|
|
pub const MAIN_SPLASH_VM_ID: SplashVmId = SplashVmId(0);
|
|
|
|
thread_local! {
|
|
/// Splash isolate VMs whose owning `Splash` widget has been dropped, awaiting
|
|
/// reclamation. `Drop` can't reach `Cx`, so it only records the id here; the
|
|
/// real teardown happens in [`gc_dead_splash_isolates`] on the next isolate
|
|
/// allocation. We defer rather than free in `Drop` because the isolate owns a
|
|
/// live `ScriptHeap`/`ScriptStd` that must be dropped with a `Cx` in hand and
|
|
/// while nothing is executing in it.
|
|
static DEAD_SPLASH_ISOLATES: RefCell<Vec<SplashVmId>> = const { RefCell::new(Vec::new()) };
|
|
}
|
|
|
|
/// Queue a Splash isolate for reclamation on the next isolate alloc. Called from
|
|
/// `Splash::drop`, which has no `Cx`. Ignores the main VM (id 0), never an isolate.
|
|
pub(crate) fn mark_splash_isolate_dead(vm_id: SplashVmId) {
|
|
if vm_id == MAIN_SPLASH_VM_ID {
|
|
return;
|
|
}
|
|
DEAD_SPLASH_ISOLATES.with(|g| g.borrow_mut().push(vm_id));
|
|
}
|
|
|
|
/// Reclaim any isolates queued by [`mark_splash_isolate_dead`]. Called on each new
|
|
/// isolate allocation so the live count tracks live Splashes instead of growing
|
|
/// unboundedly. Dropping an isolate frees its `ScriptHeap` (and every widget object
|
|
/// minted in it) plus its `ScriptStd`, so we must also purge every queue/map that
|
|
/// could later swap into the now-missing VM (which would panic in
|
|
/// `with_script_vm_id`) or dereference its freed heap. Cheap no-op when nothing is
|
|
/// queued.
|
|
pub fn gc_dead_splash_isolates(cx: &mut Cx) {
|
|
let dead: Vec<SplashVmId> = DEAD_SPLASH_ISOLATES.with(|g| {
|
|
let mut g = g.borrow_mut();
|
|
std::mem::take(&mut *g)
|
|
});
|
|
if dead.is_empty() {
|
|
return;
|
|
}
|
|
let dead_heaps: Vec<usize> = {
|
|
let state = cx.global::<CxWidgetAsync>();
|
|
state
|
|
.heap_to_vm
|
|
.iter()
|
|
.filter(|(_, v)| dead.contains(v))
|
|
.map(|(k, _)| *k)
|
|
.collect()
|
|
};
|
|
// Stop and drop script timers whose callbacks live in a dying heap. Their fn refs
|
|
// hold the heap's roots Rc alive, and firing one later would deref a freed heap.
|
|
let stale_timers: Vec<_> = cx
|
|
.script_data
|
|
.timers
|
|
.timers
|
|
.iter()
|
|
.filter(|t| dead_heaps.contains(&t.callback.heap_key()))
|
|
.map(|t| (t.id, t.timer))
|
|
.collect();
|
|
for (id, timer) in stale_timers {
|
|
cx.stop_timer(timer);
|
|
cx.script_data.timers.timers.retain(|t| t.id != id);
|
|
}
|
|
// Sandbox roots die with their isolates.
|
|
crate::splash_storage::gc_roots(&dead_heaps);
|
|
let state = cx.global::<CxWidgetAsync>();
|
|
for vm_id in dead {
|
|
state.isolated_vms.vms.remove(&vm_id);
|
|
state.heap_to_vm.retain(|_, v| *v != vm_id);
|
|
state.ui_handle_types.remove(&vm_id);
|
|
state.vm_root_uids.remove(&vm_id);
|
|
state.done.retain(|d| d.vm_id != vm_id);
|
|
state.widget_to_script_calls.retain(|r| r.vm_id != vm_id);
|
|
state.script_to_widget_calls.retain(|r| r.vm_id != vm_id);
|
|
state
|
|
.pending_script_to_widget_returns
|
|
.retain(|(v, _), _| *v != vm_id);
|
|
state.thread_map.retain(|(v, _), _| *v != vm_id);
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct ScriptAsyncCall {
|
|
id: ScriptAsyncId,
|
|
method: LiveId,
|
|
me: ScriptValue,
|
|
thread_id: Option<ScriptThreadId>,
|
|
}
|
|
|
|
#[derive(Clone, Default)]
|
|
pub struct ScriptAsyncCalls {
|
|
calls: Vec<ScriptAsyncCall>,
|
|
}
|
|
|
|
impl ScriptAsyncCalls {
|
|
pub fn take(&mut self, id: ScriptAsyncId) -> Option<ScriptAsyncCall> {
|
|
if let Some(pos) = self.calls.iter().position(|v| v.id == id) {
|
|
Some(self.calls.swap_remove(pos))
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
impl ScriptAsyncCall {
|
|
pub fn id(&self) -> ScriptAsyncId {
|
|
self.id
|
|
}
|
|
|
|
pub fn method(&self) -> LiveId {
|
|
self.method
|
|
}
|
|
|
|
pub fn me(&self) -> ScriptValue {
|
|
self.me
|
|
}
|
|
|
|
pub fn thread_id(&self) -> Option<ScriptThreadId> {
|
|
self.thread_id
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub enum ScriptAsyncResult {
|
|
Return(ScriptValue),
|
|
Pending,
|
|
MethodNotFound,
|
|
}
|
|
|
|
struct WidgetAsyncDone {
|
|
vm_id: SplashVmId,
|
|
target_uid: WidgetUid,
|
|
id: ScriptAsyncId,
|
|
result: ScriptValue,
|
|
}
|
|
|
|
struct ScriptToWidgetCallRequest {
|
|
vm_id: SplashVmId,
|
|
target_uid: WidgetUid,
|
|
method: LiveId,
|
|
caller_thread: ScriptThreadId,
|
|
args: ScriptObjectRef,
|
|
}
|
|
|
|
struct ScriptToWidgetReturn {
|
|
vm_id: SplashVmId,
|
|
target_uid: WidgetUid,
|
|
method: LiveId,
|
|
result: ScriptValue,
|
|
}
|
|
|
|
struct WidgetToScriptCallRequest {
|
|
vm_id: SplashVmId,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
}
|
|
|
|
struct IsolatedSplashVm {
|
|
network_enabled: bool,
|
|
std: ScriptStd,
|
|
vm: Option<Box<ScriptVmBase>>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct IsolatedScriptVms {
|
|
next_id: u64,
|
|
vms: HashMap<SplashVmId, IsolatedSplashVm>,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CxWidgetAsync {
|
|
done: VecDeque<WidgetAsyncDone>,
|
|
widget_to_script_calls: VecDeque<WidgetToScriptCallRequest>,
|
|
script_to_widget_calls: VecDeque<ScriptToWidgetCallRequest>,
|
|
pending_script_to_widget_returns: HashMap<(SplashVmId, usize), ScriptToWidgetReturn>,
|
|
thread_map: HashMap<(SplashVmId, usize), (WidgetUid, ScriptAsyncId)>,
|
|
ui_handle_types: HashMap<SplashVmId, ScriptHandleType>,
|
|
global_ui_root_uid: WidgetUid,
|
|
/// Maps a heap identity (see [`ScriptObjectRef::heap_key`]) to the isolate VM
|
|
/// that owns it. Only isolate heaps are inserted; a ref whose heap isn't here
|
|
/// (the main app heap, or an empty ref) resolves to `MAIN_SPLASH_VM_ID`. This
|
|
/// replaces per-widget uid registration: a widget's owning VM is derived
|
|
/// directly from its own `source` ref, so there are no coverage gaps for
|
|
/// lazily-created widgets and no wrong-heap fallbacks.
|
|
heap_to_vm: HashMap<usize, SplashVmId>,
|
|
/// Each isolate's own view-root uid (set by [`inject_splash_ui_handle`]). Isolate `ui`
|
|
/// handles are confined to this subtree so a mini-app can't reach host/sibling widgets.
|
|
vm_root_uids: HashMap<SplashVmId, WidgetUid>,
|
|
isolated_vms: IsolatedScriptVms,
|
|
current_vm_id: SplashVmId,
|
|
/// Round-robin cursor for the per-pump isolate GC pass (last vm id serviced).
|
|
gc_rr_last: u64,
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct CxWidgetAsyncHooksInstalled(pub bool);
|
|
|
|
struct CxWidgetHandleGc {
|
|
handle: ScriptHandle,
|
|
uid: WidgetUid,
|
|
}
|
|
|
|
impl ScriptHandleGc for CxWidgetHandleGc {
|
|
fn gc(&mut self) {}
|
|
|
|
fn set_handle(&mut self, handle: ScriptHandle) {
|
|
self.handle = handle;
|
|
}
|
|
}
|
|
|
|
/// Swap isolate `vm_id` onto `Cx` — its `ScriptStd` into `cx.script_data.std` and its
|
|
/// `ScriptVmBase` into `cx.script_vm` — for the duration of `f`, then put the previous
|
|
/// pair back.
|
|
///
|
|
/// **Every path that executes an isolate's script must go through this.** A `ScriptVm`
|
|
/// hands `&mut Cx` to native code via `with_cx`/`with_cx_mut`, which park the executing
|
|
/// `bx` into `cx.script_vm` and take it back out. Run an isolate while the app VM still
|
|
/// occupies that slot and the park silently drops the app VM's entire heap, nulls the
|
|
/// slot, and leaves every subsequent `Cx`-mediated script access resolving isolate
|
|
/// object pointers against the wrong heap. Handing the isolate's `std`/`vm` to a
|
|
/// function as side-channel `&mut` args is exactly that mistake.
|
|
///
|
|
/// Nested installs are fine: the enclosing `with_vm` owns the outer `bx` (so the slot
|
|
/// reads `None` here) and restores it on the way out.
|
|
fn with_isolate_installed<R>(cx: &mut Cx, vm_id: SplashVmId, f: impl FnOnce(&mut Cx) -> R) -> R {
|
|
let mut isolated = cx
|
|
.global::<CxWidgetAsync>()
|
|
.isolated_vms
|
|
.vms
|
|
.remove(&vm_id)
|
|
.unwrap_or_else(|| panic!("missing Splash VM {:?}", vm_id));
|
|
|
|
let previous_vm_id = cx.global::<CxWidgetAsync>().current_vm_id;
|
|
cx.global::<CxWidgetAsync>().current_vm_id = vm_id;
|
|
|
|
let outer_std = std::mem::replace(&mut cx.script_data.std, isolated.std);
|
|
let outer_vm = cx.script_vm.take();
|
|
cx.script_vm = isolated.vm.take();
|
|
|
|
let out = f(cx);
|
|
|
|
isolated.vm = cx.script_vm.take();
|
|
cx.script_vm = outer_vm;
|
|
isolated.std = std::mem::replace(&mut cx.script_data.std, outer_std);
|
|
|
|
cx.global::<CxWidgetAsync>().current_vm_id = previous_vm_id;
|
|
cx.global::<CxWidgetAsync>()
|
|
.isolated_vms
|
|
.vms
|
|
.insert(vm_id, isolated);
|
|
|
|
out
|
|
}
|
|
|
|
/// A Splash isolate runs untrusted-ish user script on the UI thread; cap how long any
|
|
/// single entry into it may run.
|
|
fn with_splash_budget<R>(vm: &mut ScriptVm, f: impl FnOnce(&mut ScriptVm) -> R) -> R {
|
|
let old_budget = vm.bx.run_budget.replace(ScriptRunBudget::from_durations(
|
|
std::time::Duration::from_millis(64),
|
|
std::time::Duration::from_millis(64),
|
|
512,
|
|
));
|
|
let out = f(vm);
|
|
vm.bx.run_budget = old_budget;
|
|
out
|
|
}
|
|
|
|
pub trait CxSplashVmExt {
|
|
fn alloc_splash_vm(&mut self) -> SplashVmId;
|
|
fn alloc_splash_vm_with_network(&mut self, network_enabled: bool) -> SplashVmId;
|
|
fn with_script_vm_id<R>(&mut self, vm_id: SplashVmId, f: impl FnOnce(&mut ScriptVm) -> R) -> R;
|
|
fn with_script_vm_id_thread<R>(
|
|
&mut self,
|
|
vm_id: SplashVmId,
|
|
thread_id: ScriptThreadId,
|
|
f: impl FnOnce(&mut ScriptVm) -> R,
|
|
) -> R;
|
|
/// Resolve the VM that owns a widget's script objects directly from a ref
|
|
/// minted by that widget (its `source`, a template, an `on_click` fn). This
|
|
/// is exact — the heap identity comes from the ref itself — so it never
|
|
/// mis-routes lazily-created widgets the way a uid registry could.
|
|
fn script_ref_vm_id(&mut self, script_ref: &ScriptObjectRef) -> SplashVmId;
|
|
}
|
|
|
|
impl CxSplashVmExt for Cx {
|
|
fn alloc_splash_vm(&mut self) -> SplashVmId {
|
|
self.alloc_splash_vm_with_network(false)
|
|
}
|
|
|
|
fn alloc_splash_vm_with_network(&mut self, network_enabled: bool) -> SplashVmId {
|
|
ensure_widget_async_hooks_registered(self);
|
|
// Reclaim isolates from dropped Splashes before growing, so the live count
|
|
// tracks the number of live Splash widgets rather than accumulating.
|
|
gc_dead_splash_isolates(self);
|
|
|
|
let id = {
|
|
let state = self.global::<CxWidgetAsync>();
|
|
if state.isolated_vms.next_id == 0 {
|
|
state.isolated_vms.next_id = 1;
|
|
}
|
|
let id = SplashVmId(state.isolated_vms.next_id);
|
|
state.isolated_vms.next_id += 1;
|
|
id
|
|
};
|
|
|
|
let mut std = if network_enabled {
|
|
ScriptStd::with_network_runtime(self.net.clone())
|
|
} else {
|
|
ScriptStd::new()
|
|
};
|
|
let bx = {
|
|
let mut vm = ScriptVm {
|
|
host: self,
|
|
std: &mut std,
|
|
bx: Box::new(ScriptVmBase::new()),
|
|
};
|
|
crate::makepad_draw::makepad_platform::script::script_mod(&mut vm);
|
|
crate::script_mod(&mut vm);
|
|
// Splash isolates run untrusted-ish mini-app script; strip the
|
|
// ambient-authority modules from the isolate's namespace entirely:
|
|
// filesystem access (`fs`), child processes (`run`), and the resource
|
|
// loader (`res`), whose handles reach BOTH the filesystem (abs_path
|
|
// loads) and the network (web_url/http resources) without going
|
|
// through the gated net runtime. Raw sockets are gated separately:
|
|
// the stdlib's `net.socket_stream` errors when no net runtime is
|
|
// configured, same as `net.http_request`.
|
|
let strip = crate::makepad_script::script! {
|
|
mod.fs = nil
|
|
mod.run = nil
|
|
mod.res = nil
|
|
};
|
|
vm.eval(strip);
|
|
// Re-register `fs` as the JAILED per-app storage module: inside an
|
|
// isolate, "the filesystem" is the app's private sandbox directory
|
|
// (assigned by the host via Splash::set_sandbox_dir; without one,
|
|
// every call errors). See splash_storage.rs for the containment.
|
|
crate::splash_storage::script_mod(&mut vm);
|
|
vm.bx
|
|
};
|
|
|
|
// Record this isolate's heap identity so any ref minted here (widget
|
|
// sources, templates, on_click fns) routes back to this VM.
|
|
let heap_key = bx.heap.heap_key();
|
|
let state = self.global::<CxWidgetAsync>();
|
|
state.heap_to_vm.insert(heap_key, id);
|
|
state.isolated_vms.vms.insert(
|
|
id,
|
|
IsolatedSplashVm {
|
|
network_enabled,
|
|
std,
|
|
vm: Some(bx),
|
|
},
|
|
);
|
|
|
|
id
|
|
}
|
|
|
|
fn with_script_vm_id<R>(&mut self, vm_id: SplashVmId, f: impl FnOnce(&mut ScriptVm) -> R) -> R {
|
|
if vm_id == MAIN_SPLASH_VM_ID {
|
|
return self.with_vm(f);
|
|
}
|
|
|
|
if self.global::<CxWidgetAsync>().current_vm_id == vm_id {
|
|
return self.with_vm(f);
|
|
}
|
|
|
|
with_isolate_installed(self, vm_id, |cx| cx.with_vm(|vm| with_splash_budget(vm, f)))
|
|
}
|
|
|
|
fn with_script_vm_id_thread<R>(
|
|
&mut self,
|
|
vm_id: SplashVmId,
|
|
thread_id: ScriptThreadId,
|
|
f: impl FnOnce(&mut ScriptVm) -> R,
|
|
) -> R {
|
|
if vm_id == MAIN_SPLASH_VM_ID {
|
|
return self.with_vm_thread(thread_id, f);
|
|
}
|
|
|
|
if self.global::<CxWidgetAsync>().current_vm_id == vm_id {
|
|
return self.with_vm_thread(thread_id, f);
|
|
}
|
|
|
|
with_isolate_installed(self, vm_id, |cx| {
|
|
cx.with_vm_thread(thread_id, |vm| with_splash_budget(vm, f))
|
|
})
|
|
}
|
|
|
|
fn script_ref_vm_id(&mut self, script_ref: &ScriptObjectRef) -> SplashVmId {
|
|
let heap_key = script_ref.heap_key();
|
|
if heap_key == 0 {
|
|
return MAIN_SPLASH_VM_ID;
|
|
}
|
|
self.global::<CxWidgetAsync>()
|
|
.heap_to_vm
|
|
.get(&heap_key)
|
|
.copied()
|
|
.unwrap_or(MAIN_SPLASH_VM_ID)
|
|
}
|
|
}
|
|
|
|
/// Deliver `Event::NetworkResponses` to a Splash isolate's script (resolving its
|
|
/// `net.http_request` callbacks / promises). Responses that belong to other VMs simply
|
|
/// find no matching request id in this isolate's `ScriptStd` and are ignored.
|
|
pub(crate) fn handle_splash_network_responses(
|
|
cx: &mut Cx,
|
|
vm_id: SplashVmId,
|
|
responses: &[NetworkResponse],
|
|
) {
|
|
if vm_id == MAIN_SPLASH_VM_ID || responses.is_empty() {
|
|
return;
|
|
}
|
|
|
|
match cx.global::<CxWidgetAsync>().isolated_vms.vms.get(&vm_id) {
|
|
Some(isolated) if isolated.network_enabled => {}
|
|
_ => return,
|
|
}
|
|
|
|
// The isolate has to be installed on `Cx` while its handlers run — see
|
|
// `with_isolate_installed`.
|
|
with_isolate_installed(cx, vm_id, |cx| {
|
|
cx.handle_script_network_events_for_current_vm(responses)
|
|
});
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn set_widget_async_trace(_cx: &mut Cx, _enabled: bool) {}
|
|
|
|
fn force_set_map_value(heap: &mut ScriptHeap, obj: ScriptObject, key: LiveId, value: ScriptValue) {
|
|
heap.map_mut_with((key, value), obj, |(key, value), map| {
|
|
map.insert(
|
|
key.into(),
|
|
ScriptMapValue {
|
|
tag: Default::default(),
|
|
value,
|
|
},
|
|
);
|
|
});
|
|
}
|
|
|
|
#[doc(hidden)]
|
|
pub fn ensure_widget_async_hooks_registered(cx: &mut Cx) {
|
|
cx.global::<CxWidgetAsync>();
|
|
if cx.global::<CxWidgetAsyncHooksInstalled>().0 {
|
|
return;
|
|
}
|
|
register_task_hooks(cx);
|
|
cx.global::<CxWidgetAsyncHooksInstalled>().0 = true;
|
|
}
|
|
|
|
/// Inject `ui` as a real global into an isolated Splash VM, resolving against that splash's own
|
|
/// view root. The on_click/on_return callback path already injects `ui` into the *closure* scope,
|
|
/// but a closure that calls a helper `fn` (the natural way to write e.g. a calculator) leaves the
|
|
/// helper unable to see `ui`. Making `ui` a global on the splash VM fixes that so `ui.<id>` works
|
|
/// everywhere inside a runsplash block, not just inline in the handler.
|
|
pub(crate) fn inject_splash_ui_handle(cx: &mut Cx, vm_id: SplashVmId, root_uid: WidgetUid) {
|
|
if vm_id == MAIN_SPLASH_VM_ID {
|
|
return;
|
|
}
|
|
ensure_widget_async_hooks_registered(cx);
|
|
// Remember this isolate's view root so its ui handles stay confined to that subtree.
|
|
cx.global::<CxWidgetAsync>()
|
|
.vm_root_uids
|
|
.insert(vm_id, root_uid);
|
|
cx.with_script_vm_id(vm_id, |vm| {
|
|
let ui_handle = vm.build_ui_handle_for_uid(root_uid);
|
|
vm.set_injected_global(id!(ui), ui_handle);
|
|
});
|
|
}
|
|
|
|
pub(crate) fn update_global_ui_handle(cx: &mut Cx, root_uid: WidgetUid) {
|
|
ensure_widget_async_hooks_registered(cx);
|
|
if cx.global::<CxWidgetAsync>().global_ui_root_uid == root_uid {
|
|
return;
|
|
}
|
|
cx.global::<CxWidgetAsync>().global_ui_root_uid = root_uid;
|
|
cx.with_vm(|vm| {
|
|
vm.cx_mut().global::<CxWidgetAsync>().current_vm_id = MAIN_SPLASH_VM_ID;
|
|
let ui_handle = vm.build_ui_handle_for_uid(root_uid);
|
|
vm.set_injected_global(id!(ui), ui_handle);
|
|
});
|
|
}
|
|
|
|
trait WidgetToScriptCallExt {
|
|
fn build_ui_handle_for_uid(&mut self, target_uid: WidgetUid) -> ScriptValue;
|
|
|
|
fn make_call_args_object_with_context(
|
|
&mut self,
|
|
source: ScriptObject,
|
|
ui: ScriptValue,
|
|
forwarded_args: ScriptValue,
|
|
) -> ScriptObject;
|
|
|
|
fn widget_to_script_async_call_fwd(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
script_async: &mut ScriptAsyncCalls,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
from_method: LiveId,
|
|
) -> ScriptAsyncResult;
|
|
|
|
fn widget_to_script_async_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
script_async: &mut ScriptAsyncCalls,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: &[ScriptValue],
|
|
from_method: LiveId,
|
|
) -> ScriptAsyncResult;
|
|
|
|
fn widget_to_script_call_fwd(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
);
|
|
|
|
fn widget_to_script_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: &[ScriptValue],
|
|
);
|
|
|
|
fn enqueue_script_to_widget_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
method: LiveId,
|
|
args: ScriptValue,
|
|
);
|
|
}
|
|
|
|
impl<'a> WidgetToScriptCallExt for ScriptVm<'a> {
|
|
fn build_ui_handle_for_uid(&mut self, target_uid: WidgetUid) -> ScriptValue {
|
|
ensure_widget_async_hooks_registered(self.cx_mut());
|
|
let vm_id = self.cx_mut().global::<CxWidgetAsync>().current_vm_id;
|
|
if self
|
|
.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.ui_handle_types
|
|
.get(&vm_id)
|
|
.is_none()
|
|
{
|
|
register_ui_handle(self);
|
|
}
|
|
|
|
let ui_type = self
|
|
.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.ui_handle_types
|
|
.get(&vm_id)
|
|
.copied()
|
|
.expect("ui handle type not registered");
|
|
|
|
let gc = CxWidgetHandleGc {
|
|
handle: ScriptHandle::ZERO,
|
|
uid: target_uid,
|
|
};
|
|
self.bx.heap.new_handle(ui_type, Box::new(gc)).into()
|
|
}
|
|
|
|
fn make_call_args_object_with_context(
|
|
&mut self,
|
|
source: ScriptObject,
|
|
ui: ScriptValue,
|
|
forwarded_args: ScriptValue,
|
|
) -> ScriptObject {
|
|
let args_obj = self.bx.heap.new_object();
|
|
// Keep mixed (map + vec) semantics so named context vars like `ui` and `self`
|
|
// are stored in map keys, while positional forwarded args stay in vec.
|
|
self.bx.heap.set_object_storage_auto(args_obj);
|
|
self.bx.heap.clear_object_deep(args_obj);
|
|
|
|
let trap = self.bx.threads.cur().trap.pass();
|
|
if let Some(obj) = forwarded_args.as_object() {
|
|
self.bx.heap.merge_object(args_obj, obj, trap);
|
|
} else if let Some(arr) = forwarded_args.as_array() {
|
|
let len = self.bx.heap.array_len(arr);
|
|
for index in 0..len {
|
|
let value = self.bx.heap.array_index(arr, index, trap);
|
|
self.bx.heap.vec_push(args_obj, NIL, value, trap);
|
|
}
|
|
} else if !forwarded_args.is_nil() {
|
|
self.bx.heap.vec_push(args_obj, NIL, forwarded_args, trap);
|
|
}
|
|
|
|
self.bx
|
|
.heap
|
|
.set_value(args_obj, id!(self).into(), source.into(), trap);
|
|
self.bx.heap.set_value(args_obj, id!(ui).into(), ui, trap);
|
|
|
|
args_obj
|
|
}
|
|
|
|
fn widget_to_script_async_call_fwd(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
script_async: &mut ScriptAsyncCalls,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
from_method: LiveId,
|
|
) -> ScriptAsyncResult {
|
|
if script_fn.as_object() == ScriptObject::ZERO {
|
|
return ScriptAsyncResult::MethodNotFound;
|
|
}
|
|
|
|
let async_id = ScriptAsyncId::new();
|
|
let ui_handle = self.build_ui_handle_for_uid(target_uid);
|
|
let call_args =
|
|
self.make_call_args_object_with_context(source.as_object(), ui_handle, args);
|
|
let result = self.with_instruction_limit(WIDGET_SCRIPT_INSTRUCTION_LIMIT, |vm| {
|
|
vm.call_with_args_object_with_me(script_fn.clone().into(), call_args, me)
|
|
});
|
|
|
|
let thread = self.bx.threads.cur_ref();
|
|
if thread.is_paused() {
|
|
let thread_id = thread.thread_id();
|
|
script_async.calls.push(ScriptAsyncCall {
|
|
id: async_id,
|
|
method: from_method,
|
|
me,
|
|
thread_id: Some(thread_id),
|
|
});
|
|
let vm_id = self.cx_mut().global::<CxWidgetAsync>().current_vm_id;
|
|
self.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.thread_map
|
|
.insert((vm_id, thread_id.to_index()), (target_uid, async_id));
|
|
ScriptAsyncResult::Pending
|
|
} else {
|
|
script_async.calls.push(ScriptAsyncCall {
|
|
id: async_id,
|
|
method: from_method,
|
|
me,
|
|
thread_id: None,
|
|
});
|
|
let vm_id = self.cx_mut().global::<CxWidgetAsync>().current_vm_id;
|
|
self.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.done
|
|
.push_back(WidgetAsyncDone {
|
|
vm_id,
|
|
target_uid,
|
|
id: async_id,
|
|
result,
|
|
});
|
|
ScriptAsyncResult::Return(result)
|
|
}
|
|
}
|
|
|
|
fn widget_to_script_async_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
script_async: &mut ScriptAsyncCalls,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: &[ScriptValue],
|
|
from_method: LiveId,
|
|
) -> ScriptAsyncResult {
|
|
let args_obj = self.bx.heap.new_object();
|
|
self.bx.heap.set_object_storage_vec2(args_obj);
|
|
self.bx.heap.clear_object_deep(args_obj);
|
|
let trap = self.bx.threads.cur().trap.pass();
|
|
for value in args {
|
|
self.bx.heap.vec_push(args_obj, NIL, *value, trap);
|
|
}
|
|
self.widget_to_script_async_call_fwd(
|
|
target_uid,
|
|
script_async,
|
|
me,
|
|
source,
|
|
script_fn,
|
|
args_obj.into(),
|
|
from_method,
|
|
)
|
|
}
|
|
|
|
fn widget_to_script_call_fwd(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
) {
|
|
if script_fn.as_object() == ScriptObject::ZERO {
|
|
return;
|
|
}
|
|
let vm_id = self.cx_mut().global::<CxWidgetAsync>().current_vm_id;
|
|
self.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.widget_to_script_calls
|
|
.push_back(WidgetToScriptCallRequest {
|
|
vm_id,
|
|
target_uid,
|
|
me,
|
|
source,
|
|
script_fn,
|
|
args,
|
|
});
|
|
}
|
|
|
|
fn widget_to_script_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: &[ScriptValue],
|
|
) {
|
|
let args_obj = self.bx.heap.new_object();
|
|
self.bx.heap.set_object_storage_vec2(args_obj);
|
|
self.bx.heap.clear_object_deep(args_obj);
|
|
let trap = self.bx.threads.cur().trap.pass();
|
|
for value in args {
|
|
self.bx.heap.vec_push(args_obj, NIL, *value, trap);
|
|
}
|
|
self.widget_to_script_call_fwd(target_uid, me, source, script_fn, args_obj.into());
|
|
}
|
|
|
|
fn enqueue_script_to_widget_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
method: LiveId,
|
|
args: ScriptValue,
|
|
) {
|
|
let args_ref = if let Some(args_obj) = args.as_object() {
|
|
self.bx.heap.new_object_ref(args_obj)
|
|
} else {
|
|
let obj = self.bx.heap.new_object();
|
|
self.bx.heap.set_object_storage_vec2(obj);
|
|
self.bx.heap.clear_object_deep(obj);
|
|
if !args.is_nil() {
|
|
self.bx
|
|
.heap
|
|
.vec_push(obj, NIL, args, self.bx.threads.cur().trap.pass());
|
|
}
|
|
self.bx.heap.new_object_ref(obj)
|
|
};
|
|
|
|
let caller_thread = self.bx.threads.cur_ref().thread_id();
|
|
let vm_id = self.cx_mut().global::<CxWidgetAsync>().current_vm_id;
|
|
self.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.script_to_widget_calls
|
|
.push_back(ScriptToWidgetCallRequest {
|
|
vm_id,
|
|
target_uid,
|
|
method,
|
|
caller_thread,
|
|
args: args_ref,
|
|
});
|
|
|
|
self.bx.threads.cur().pause();
|
|
}
|
|
}
|
|
|
|
pub trait CxWidgetToScriptCallExt {
|
|
fn widget_to_script_async_call_fwd(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
script_async: &mut ScriptAsyncCalls,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
from_method: LiveId,
|
|
) -> ScriptAsyncResult;
|
|
|
|
fn widget_to_script_async_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
script_async: &mut ScriptAsyncCalls,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: &[ScriptValue],
|
|
from_method: LiveId,
|
|
) -> ScriptAsyncResult;
|
|
|
|
fn widget_to_script_call_fwd(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
);
|
|
|
|
fn widget_to_script_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: &[ScriptValue],
|
|
);
|
|
}
|
|
|
|
impl CxWidgetToScriptCallExt for Cx {
|
|
fn widget_to_script_async_call_fwd(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
script_async: &mut ScriptAsyncCalls,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
from_method: LiveId,
|
|
) -> ScriptAsyncResult {
|
|
let vm_id = self.script_ref_vm_id(&source);
|
|
self.with_script_vm_id(vm_id, |vm| {
|
|
vm.widget_to_script_async_call_fwd(
|
|
target_uid,
|
|
script_async,
|
|
me,
|
|
source,
|
|
script_fn,
|
|
args,
|
|
from_method,
|
|
)
|
|
})
|
|
}
|
|
|
|
fn widget_to_script_async_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
script_async: &mut ScriptAsyncCalls,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: &[ScriptValue],
|
|
from_method: LiveId,
|
|
) -> ScriptAsyncResult {
|
|
let vm_id = self.script_ref_vm_id(&source);
|
|
self.with_script_vm_id(vm_id, |vm| {
|
|
vm.widget_to_script_async_call(
|
|
target_uid,
|
|
script_async,
|
|
me,
|
|
source,
|
|
script_fn,
|
|
args,
|
|
from_method,
|
|
)
|
|
})
|
|
}
|
|
|
|
fn widget_to_script_call_fwd(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: ScriptValue,
|
|
) {
|
|
let vm_id = self.script_ref_vm_id(&source);
|
|
self.with_script_vm_id(vm_id, |vm| {
|
|
vm.widget_to_script_call_fwd(target_uid, me, source, script_fn, args);
|
|
});
|
|
}
|
|
|
|
fn widget_to_script_call(
|
|
&mut self,
|
|
target_uid: WidgetUid,
|
|
me: ScriptValue,
|
|
source: ScriptObjectRef,
|
|
script_fn: ScriptFnRef,
|
|
args: &[ScriptValue],
|
|
) {
|
|
let vm_id = self.script_ref_vm_id(&source);
|
|
self.with_script_vm_id(vm_id, |vm| {
|
|
vm.widget_to_script_call(target_uid, me, source, script_fn, args);
|
|
});
|
|
}
|
|
}
|
|
|
|
fn register_ui_handle(vm: &mut ScriptVm) {
|
|
let vm_id = vm.cx_mut().global::<CxWidgetAsync>().current_vm_id;
|
|
if vm
|
|
.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.ui_handle_types
|
|
.contains_key(&vm_id)
|
|
{
|
|
return;
|
|
}
|
|
|
|
let ui_type = vm.new_handle_type(id_lut!(ui));
|
|
|
|
vm.set_handle_getter(ui_type, move |vm, pself, prop| {
|
|
if let Some(handle) = pself.as_handle() {
|
|
let Some(target_uid) = vm
|
|
.downcast_handle_gc::<CxWidgetHandleGc>(handle)
|
|
.map(|gc| gc.uid)
|
|
else {
|
|
return script_err_not_found!(vm.trap(), "invalid ui handle");
|
|
};
|
|
|
|
// Isolate VMs are confined to their own splash subtree: `ui.root` resolves to
|
|
// the splash's view root (never the app root), and name lookups only search
|
|
// within that subtree. Without this, a mini-app could reach host widgets or
|
|
// widgets belonging to a sibling mini-app.
|
|
let cur_vm_id = vm.cx_mut().global::<CxWidgetAsync>().current_vm_id;
|
|
let confine_root = if cur_vm_id == MAIN_SPLASH_VM_ID {
|
|
None
|
|
} else {
|
|
Some(
|
|
vm.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.vm_root_uids
|
|
.get(&cur_vm_id)
|
|
.copied()
|
|
.unwrap_or(target_uid),
|
|
)
|
|
};
|
|
|
|
if prop == live_id!(root) {
|
|
let root_uid = match confine_root {
|
|
Some(root_uid) => root_uid,
|
|
None => vm.with_cx(|cx| cx.widget_tree().root_uid()),
|
|
};
|
|
if root_uid == WidgetUid(0) {
|
|
return script_err_not_found!(vm.trap(), "ui root not found");
|
|
}
|
|
return vm.build_ui_handle_for_uid(root_uid);
|
|
}
|
|
|
|
// Script UI handles intentionally use upward flood search semantics:
|
|
// look in current subtree first, then expand outward through ancestors.
|
|
let child_ref = vm.with_cx(|cx| {
|
|
if let Some(confine_root) = confine_root {
|
|
// Confined (isolate) search: the target's subtree first, then the
|
|
// splash root's subtree. Never the whole tree.
|
|
let child_ref = cx.widget_tree().find_within(target_uid, &[prop]);
|
|
if !child_ref.is_empty() {
|
|
return child_ref;
|
|
}
|
|
return cx.widget_tree().find_within(confine_root, &[prop]);
|
|
}
|
|
|
|
let child_ref = cx.widget_tree().find_flood(target_uid, &[prop]);
|
|
if !child_ref.is_empty() {
|
|
return child_ref;
|
|
}
|
|
|
|
let mut matches = cx
|
|
.widget_tree()
|
|
.find_all_anywhere_including_skipped(&[prop]);
|
|
if matches.len() == 1 {
|
|
return matches.pop().unwrap();
|
|
}
|
|
|
|
WidgetRef::empty()
|
|
});
|
|
if child_ref.is_empty() {
|
|
return script_err_not_found!(vm.trap(), "widget '{:?}' not found in tree", prop);
|
|
}
|
|
|
|
let child_uid = child_ref.widget_uid();
|
|
if child_uid == WidgetUid(0) {
|
|
return script_err_not_found!(vm.trap(), "widget has no uid");
|
|
}
|
|
|
|
let gc = CxWidgetHandleGc {
|
|
handle: ScriptHandle::ZERO,
|
|
uid: child_uid,
|
|
};
|
|
let child_handle = vm.bx.heap.new_handle(ui_type, Box::new(gc));
|
|
return child_handle.into();
|
|
}
|
|
|
|
script_err_not_found!(vm.trap(), "invalid ui handle")
|
|
});
|
|
|
|
vm.set_handle_call(ui_type, move |vm, args, method| {
|
|
let pself = script_value!(vm, args.self);
|
|
if let Some(handle) = pself.as_handle() {
|
|
let Some(uid) = vm
|
|
.downcast_handle_gc::<CxWidgetHandleGc>(handle)
|
|
.map(|gc| gc.uid)
|
|
else {
|
|
return script_err_not_found!(vm.trap(), "invalid ui handle");
|
|
};
|
|
|
|
let ui_handle = vm.build_ui_handle_for_uid(uid);
|
|
force_set_map_value(&mut vm.bx.heap, args, id!(ui), ui_handle);
|
|
|
|
let caller_thread = vm.bx.threads.cur_ref().thread_id();
|
|
let vm_id = vm.cx_mut().global::<CxWidgetAsync>().current_vm_id;
|
|
if let Some(pending) = vm
|
|
.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.pending_script_to_widget_returns
|
|
.remove(&(vm_id, caller_thread.to_index()))
|
|
{
|
|
if pending.vm_id == vm_id && pending.target_uid == uid && pending.method == method {
|
|
return pending.result;
|
|
}
|
|
vm.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.pending_script_to_widget_returns
|
|
.insert((vm_id, caller_thread.to_index()), pending);
|
|
}
|
|
|
|
vm.enqueue_script_to_widget_call(uid, method, args.into());
|
|
return NIL;
|
|
}
|
|
|
|
script_err_not_found!(vm.trap(), "invalid ui handle for method call")
|
|
});
|
|
|
|
vm.cx_mut()
|
|
.global::<CxWidgetAsync>()
|
|
.ui_handle_types
|
|
.insert(vm_id, ui_type);
|
|
}
|
|
|
|
fn on_widget_script_thread_completed(
|
|
cx: &mut Cx,
|
|
vm_id: SplashVmId,
|
|
thread_id: ScriptThreadId,
|
|
result: ScriptValue,
|
|
) -> bool {
|
|
cx.global::<CxWidgetAsync>()
|
|
.pending_script_to_widget_returns
|
|
.remove(&(vm_id, thread_id.to_index()));
|
|
|
|
let Some((target_uid, async_id)) = cx
|
|
.global::<CxWidgetAsync>()
|
|
.thread_map
|
|
.remove(&(vm_id, thread_id.to_index()))
|
|
else {
|
|
return false;
|
|
};
|
|
|
|
cx.global::<CxWidgetAsync>()
|
|
.done
|
|
.push_back(WidgetAsyncDone {
|
|
vm_id,
|
|
target_uid,
|
|
id: async_id,
|
|
result,
|
|
});
|
|
true
|
|
}
|
|
|
|
fn pump_widget_async(cx: &mut Cx) -> bool {
|
|
let mut progressed = false;
|
|
|
|
loop {
|
|
let req = cx
|
|
.global::<CxWidgetAsync>()
|
|
.widget_to_script_calls
|
|
.pop_front();
|
|
if let Some(req) = req {
|
|
progressed = true;
|
|
cx.with_script_vm_id(req.vm_id, |vm| {
|
|
if req.script_fn.as_object() != ScriptObject::ZERO {
|
|
let ui_handle = vm.build_ui_handle_for_uid(req.target_uid);
|
|
let call_args = vm.make_call_args_object_with_context(
|
|
req.source.as_object(),
|
|
ui_handle,
|
|
req.args,
|
|
);
|
|
let _ = vm.with_instruction_limit(WIDGET_SCRIPT_INSTRUCTION_LIMIT, |vm| {
|
|
vm.call_with_args_object_with_me(
|
|
req.script_fn.clone().into(),
|
|
call_args,
|
|
req.me,
|
|
)
|
|
});
|
|
}
|
|
});
|
|
continue;
|
|
}
|
|
|
|
let req = cx
|
|
.global::<CxWidgetAsync>()
|
|
.script_to_widget_calls
|
|
.pop_front();
|
|
if let Some(req) = req {
|
|
progressed = true;
|
|
let ret = cx.with_script_vm_id_thread(req.vm_id, req.caller_thread, |vm| {
|
|
let widget_ref = vm.with_cx(|cx| cx.widget_tree().widget(req.target_uid));
|
|
match widget_ref.script_call(vm, req.method, req.args.as_object().into()) {
|
|
ScriptAsyncResult::Return(value) => value,
|
|
ScriptAsyncResult::Pending => NIL,
|
|
ScriptAsyncResult::MethodNotFound => script_err_not_found!(
|
|
vm.trap(),
|
|
"widget method {:?} not found for uid {:?}",
|
|
req.method,
|
|
req.target_uid
|
|
),
|
|
}
|
|
});
|
|
cx.global::<CxWidgetAsync>()
|
|
.pending_script_to_widget_returns
|
|
.insert(
|
|
(req.vm_id, req.caller_thread.to_index()),
|
|
ScriptToWidgetReturn {
|
|
vm_id: req.vm_id,
|
|
target_uid: req.target_uid,
|
|
method: req.method,
|
|
result: ret,
|
|
},
|
|
);
|
|
let result = cx.with_script_vm_id_thread(req.vm_id, req.caller_thread, |vm| vm.resume());
|
|
let is_paused = cx.with_script_vm_id_thread(req.vm_id, req.caller_thread, |vm| {
|
|
vm.thread().is_paused()
|
|
});
|
|
if !is_paused {
|
|
on_widget_script_thread_completed(cx, req.vm_id, req.caller_thread, result);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
let done = cx.global::<CxWidgetAsync>().done.pop_front();
|
|
if let Some(done) = done {
|
|
progressed = true;
|
|
cx.with_script_vm_id(done.vm_id, |vm| {
|
|
let widget_ref = vm.with_cx(|cx| cx.widget_tree().widget(done.target_uid));
|
|
widget_ref.script_result(vm, done.id, done.result);
|
|
});
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
// Isolate maintenance — runs on every pump, cheap when idle. Without
|
|
// this, isolated Splash VMs never garbage-collect at all (only the app
|
|
// VM has a paint-loop GC): a 60Hz script host accumulates per-tick
|
|
// objects forever, and dead isolates only reclaimed on the next alloc.
|
|
gc_dead_splash_isolates(cx);
|
|
let state = cx.global::<CxWidgetAsync>();
|
|
if !state.isolated_vms.vms.is_empty() {
|
|
// Round-robin: give at most one isolate a GC opportunity per pump,
|
|
// gated on the heap's own growth heuristic (needs_gc). Mark/sweep
|
|
// runs directly on the parked ScriptVmBase — no Cx install needed.
|
|
// An isolate currently installed on Cx is absent from the map and
|
|
// naturally skipped.
|
|
let mut ids: Vec<u64> = state.isolated_vms.vms.keys().map(|v| v.0).collect();
|
|
ids.sort_unstable();
|
|
let last = state.gc_rr_last;
|
|
let next = ids.iter().copied().find(|id| *id > last).unwrap_or(ids[0]);
|
|
state.gc_rr_last = next;
|
|
if let Some(iso) = state.isolated_vms.vms.get_mut(&SplashVmId(next)) {
|
|
if let Some(bx) = iso.vm.as_mut() {
|
|
if bx.heap.needs_gc() {
|
|
bx.heap.mark(&bx.threads, &bx.code);
|
|
bx.heap.sweep(false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
progressed
|
|
}
|
|
|
|
fn register_task_hooks(cx: &mut Cx) {
|
|
cx.add_script_task_on_thread_completed_hook(on_widget_script_thread_completed_hook);
|
|
cx.add_script_task_pump_hook(pump_widget_async_hook);
|
|
cx.add_script_timer_dispatch_hook(script_timer_dispatch_hook);
|
|
}
|
|
|
|
/// Routes a firing script timer to the isolate VM that owns its callback. Without this,
|
|
/// `std.start_timeout`/`start_interval` called inside a Splash isolate would run their
|
|
/// callbacks on the main VM against the wrong heap.
|
|
fn script_timer_dispatch_hook(cx: &mut Cx, timer: &CxScriptTimer, time: ScriptValue) -> bool {
|
|
let heap_key = timer.callback.heap_key();
|
|
if heap_key == 0 {
|
|
return false;
|
|
}
|
|
let vm_id = cx
|
|
.global::<CxWidgetAsync>()
|
|
.heap_to_vm
|
|
.get(&heap_key)
|
|
.copied();
|
|
match vm_id {
|
|
Some(vm_id) => {
|
|
// Same budget/limit as any other isolate entry, so a runaway timer callback
|
|
// can't hang the host.
|
|
cx.with_script_vm_id(vm_id, |vm| {
|
|
vm.with_instruction_limit(WIDGET_SCRIPT_INSTRUCTION_LIMIT, |vm| {
|
|
vm.call(timer.callback.as_object().into(), &[time]);
|
|
});
|
|
});
|
|
true
|
|
}
|
|
None => {
|
|
// Not a live isolate's heap. The main VM's own timers fall through to the
|
|
// default dispatch; anything else is a stale timer from a dead isolate.
|
|
let main_heap_key = cx.with_vm(|vm| vm.bx.heap.heap_key());
|
|
if heap_key == main_heap_key {
|
|
false
|
|
} else {
|
|
cx.stop_timer(timer.timer);
|
|
let id = timer.id;
|
|
cx.script_data.timers.timers.retain(|t| t.id != id);
|
|
true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn on_widget_script_thread_completed_hook(
|
|
host: &mut dyn Any,
|
|
thread_id: ScriptThreadId,
|
|
result: ScriptValue,
|
|
) -> bool {
|
|
host.downcast_mut::<Cx>()
|
|
.map(|cx| on_widget_script_thread_completed(cx, MAIN_SPLASH_VM_ID, thread_id, result))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn pump_widget_async_hook(host: &mut dyn Any) -> bool {
|
|
host.downcast_mut::<Cx>()
|
|
.map(pump_widget_async)
|
|
.unwrap_or(false)
|
|
}
|