I have a fix for my needs.
In my specific case I’ve just added:
wait_for(V) :-
\+ var(V), !.
wait_for(V) :- !,
sleep(0),
wait_for(V).
:- meta_predicate(trampoline_loop(2, ?)).
trampoline_loop(T, In) :-
call(T, In, Next),
wait_for(Next),
trampoline_loop(T, Next).
In more detail:
The architecture of my user interface library revolves around the idea of a M0 ~> M1 term that describes a model now (M0) and a variable M1 that will be updated when the model changes.
I can also declare how sub-model changes affect model changes. For example:
assocs(M0 ~> M1) <-.. scroll_offset1-(SO10 ~> SO11)
succeeds when M0 is an association list, with key-value scroll_offset1-SO10.
If/when a new value is set for scroll_offset1 by unifying SO11 to a non-var, then M1 is bound to the updated association list. This all works via freeze/2.
These Before ~> After terms are renderable, and the view predicates are responsible for updating the data they render, hence propagating model changes upward.
At the top-level view, a model change recursively called the top-level view when updated (again, via freeze/2).
app(M0) :- on(~>M1, app(M1)), ... .
This blew out the stack.
Now I have:
app(M0, M1) : - ... and
trampoline_loop(app, M)
Functionally I have everything I want working as expected.